Skip to content

Commit 6a6a4d3

Browse files
fix(durability): a refused syncObjectSchema logs error at the messaging, datasource and publish seams; the gate reads .call/.apply/.bind and declares it (#23052)
Fixes #22985 Clause-②: no (log levels and an existing gate's vocabulary; no accepted shape and no published name changes) ## What this changes `check:durability-log-level` now sees a refused `syncObjectSchema`, and the four places where one was logged below `error` each give AGENTS.md "Degradation log levels" its measured answer. | member | site | answer | evidence | |---|---|---|---| | 5, the gate | `scripts/check-durability-degradation-log-level.mjs` | the guarded-side read resolves `.call` / `.apply` / `.bind(recv)(…)` to the function they invoke, through a scope-aware `const` alias; `syncObjectSchema` joins `DURABILITY_CRITICAL_CALLEES` | red on the base members, green after (below); 5 self-test cases, 3 that must light and 2 that must not, each ablated | | 1 | `service-messaging` `provisionSystemTables` | `error`, both halves, once per table per boot, the refusal in meta (mirrors plugin-audit's line) | the pipeline tables are written as a side effect: `approval-service.ts` and `audit-writers.ts` (@mention) catch a failed `emit()` at `warn` and complete, so the action succeeds while its notification never lands | | 2 | `service-datasource` `connect()` bound-object loop | `error`, both halves, once per object per connect | pinned: after the refusal `connectDeclared` answers `status: 'connected'` and `getConnectionState` reads `connected` | | 3 | `metadata-protocol` `ensureObjectStorage` | `console.error`, both halves | pinned: `publishMetaItem` answers `success: true` while the object's storage sync was refused | | 4 | `cli` `measureComposedCoverage` | the caller is told: declared in `FAILURE_PROPAGATION_SITES` (`failures.set`, `effect`); the cli source is unchanged | not a durability seam (see below) | ### The gate (member 5) `calleeName()` is untouched; the read-seam rule and helper following still use it. A new `guardedCriticalCallee()` is used only by `collectGuardedCalls`. It reads the invoked operation first and the plain `calleeName()` second, so reach is only added. Scope rules for the alias: the nearest enclosing declaration wins, and a parameter, a `catch` binding, a loop variable or a function declaration of the same name shadows it. Only a `const` is an alias, because a `let` can be reassigned. A bare `.bind(recv)` invokes nothing, so it is not a guarded call. No new rule and no new gate. Measured on the tree, in order: | state | seams | red | |---|---|---| | base `edecd4485` | 46 | 0 | | unwrap only, vocabulary unchanged | 46 | 0 (no existing seam lost or gained) | | `syncObjectSchema` entry only, no unwrap (scratch copy) | 49 | 3: members 2, 3, 4. Member 1 and the audit site are invisible because `sync.call` reads as `call` | | gate change `a91725f9e` (unwrap + entry), members unfixed | 51 | 4: members 1–4. The audit site is a seam and loud | | merged `7d7de7be4`, members answered | 51 | 0 (7 propagating, declared) | Red run at `a91725f9e`, `--list` filtered to the five `syncObjectSchema` seams: ``` packages/cli/src/utils/schema-migration-plugins.ts:2401 guards syncObjectSchema()@2399 → SILENT packages/metadata-protocol/src/protocol.ts:19405 guards syncObjectSchema()@19404 → QUIET (warn@19406) packages/plugins/plugin-audit/src/audit-plugin.ts:653 guards syncObjectSchema()@652 → loud (error@660) packages/services/service-datasource/src/datasource-connection-service.ts:658 guards syncObjectSchema()@657 → QUIET (warn@659) packages/services/service-messaging/src/messaging-service-plugin.ts:491 guards syncObjectSchema()@490 → QUIET (warn@492) ✗ 4 durability report(s) degrade quietly ``` Green, on the merged head: ``` ✓ durability-degradation log levels: 51 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller (7 propagating, declared); 2 counter-guarded summary report(s) outside a catch, all of them able to print against a sink with no `error` (#9748). ``` ### Member 2: why `error`, and what it does not claim The catch cannot tell a managed object from a federated one, and both end up registered and served with no working storage while the datasource reads `connected`. A managed object's DDL never ran. A federated object's remote-table binding was never installed, so its reads go to a table named after the object. The loop only runs from `connectDeclared`: runtime-admin connects pass no objects. Every boot calls `connectDeclared`, so the fix the line names is "resolve the refusal, then restart". `os migrate` is not named, because it plans only the primary datasource and its telemetry sibling (`schema-migrate.ts`). For a federated object, `ObjectQLPlugin.reconcileFederatedBindings` re-drives the binding at `kernel:ready` and reports what is still unbound at `error`. A federated refusal can therefore print twice, and both lines are true. The logger contract has `warn` required and `error` optional, so the call is spelled `(logger.error ?? logger.warn).call(logger, …)`, never `error?.(…)`. A pin holds the `{ warn }`-only sink. ### Member 4: delivery shape, measured - The catch has no `return`. It runs `failed++` and `failures.set(message, …)`, then falls off the end. After the loop, every `failures` entry becomes one note of the returned `{ coverage, notes }`. `os migrate plan` / `apply` print those notes (`plan.ts`, `apply.ts`) next to `coverage.reasons.failed`. That is the CALL shape, which AGENTS.md declares through the propagation vocabulary. It is the same shape as `duplicatePackage`'s `failed.push`. - The repo's own instrument agrees. With `syncObjectSchema` in the vocabulary, `measure-return-propagating-durability-seams.mjs --sites` lists 0 members in "[1] the gate SEES this seam today", so this catch is not a RETURN-shaped member. - It is not a durability seam at all. This pass runs only on a boot that DEFERRED its DDL, so the guarded call registers metadata in memory and writes nothing. - The existing pin `schema-migration-plugins.test.ts` › "reports a REFUSING driver as a shortfall, quoting it" holds `reasons.failed` and the note. - No checker change was needed to excuse it, and no ⛔ move was made: no baseline entry, no `logger.error`, no `DURABILITY_CRITICAL_CALLEES` entry for the site. ## Pins and ablations Each `error` member has a forced-refusal test that asserts exactly one line, naming the subject and the remedy. The line has no newline, and the refusal travels in meta or as a separate argument. Each also asserts no quieter duplicate and has a CONTROL in which a clean run logs no `error`. Every negative pin was ablated through `scripts/ablation-replace.mjs`. In each leg the anchor hit once, the blob changed, and the restore was proven: blob equals HEAD and `git diff HEAD` is empty. | leg (mutation) | red | at | |---|---|---| | messaging `error` → `warn` | 8 of 9 (every object; CONTROL green) | `8a59d7e0a` | | datasource fallback → `logger.warn.call` | 2 of 4 | `8a59d7e0a` | | datasource fallback → `logger.error?.call` | 1 of 4 (the `{ warn }`-only sink) | `8a59d7e0a` | | protocol `console.error` → `console.warn` | 1 of 6 (CONTROL and the four existing cases green) | `d80c6d124` | | gate: guarded read back to plain `calleeName` | 3 self-test cases (`sync.call` flags; cast alias is a seam; apply/bind ×3) | `8a59d7e0a` | | gate: bare `.bind` read as an invocation | the must-not-light bind case | `8a59d7e0a` | | gate: parameter no longer shadows | the must-not-light shadow case | `8a59d7e0a` | | gate: `let` trusted as alias | the must-not-light shadow case | `8a59d7e0a` | | gate: member 4's site key renamed | real tree red at the cli site + a stale-site report | `8a59d7e0a` | ## Files beyond the claim's surface, and why - `packages/metadata-protocol/src/protocol.record-package-commit-durability.test.ts`: its stub engine gains `syncObjectSchema`. `HostEngine` declares it, and an object publish calls it. Without it the publish threw a TypeError, which the protocol now reports at `error`, so the file's "logs nothing" control went red (7 failures, measured). The alternative, a `typeof` guard in `ensureObjectStorage`, would have been consumer-side tolerance of an off-contract engine. - `packages/metadata-protocol/src/protocol.publish-side-effects-canonical-type.test.ts`: member 3's pin lives here. Its stub already carries the publish path and is already in both double ledgers. A copied stub in a new file reddened `check:engine-double-contract` (3 RETAINED) and `check:objectql-double-limit` (1 BLIND). The stub's `syncObjectSchema` now takes a set of refused names. - `scripts/measure-durability-swallow-family.mjs`: a by-value copy of the gate vocabulary, self-tested against it. `check:swallow-census-controls` reds on the drift until the copy gains `syncObjectSchema`. - `ensureObjectStorage`'s docblock gained two sentences, beside its catch. ## Verification Checks run at `7d7de7be4` (`origin/main` merged once, at `7d7de7be4`; full `turbo run build` after the merge): - `pnpm --filter @objectstack/service-messaging test`: 48 files, 549 passed; `typecheck` green. - `pnpm --filter @objectstack/service-datasource test`: 43 files, 769 passed; `typecheck` green. - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 227 files passed, 3 skipped; 28192 passed; `typecheck` green. - `tsc --listFiles` includes each touched test file in its package's program. - Gates: the 89 commands `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives for this change set (it includes everything the dispatch listed except `check:i18n-coverage` and `check:i18n-walk-parity`, which were run as well), plus `measure-return-propagating-durability-seams.mjs --self-test` and `measure-durability-swallow-family.mjs --self-test`. All 93 exited 0, and the exit code was captured before any pipe. `dispatch-gates --ran` with those codes reports `89 derived famil(ies) accounted for — 89 run, 0 NOT-MEASURED (a DERIVED zero — all 89 recorded an exit code and none of them is 3)`. This includes `check:durability-log-level` (self-test 68 + 57 cases), `check:swallow-census-controls`, `check:engine-double-contract`, `check:objectql-double-limit`, `check:dual-build-cjs-loads` and `check:i18n`. The last two read built output, so they ran after the post-merge full build. - Lint, narrowed and proven: `eslint --no-inline-config --format json` over the 8 changed `.ts`/`.mjs` files. The JSON lists all 8 as linted and none as ignored: 0 errors, 0 warnings. The population is all of the changed code files, read from eslint's own output. The narrowing cannot move any other file's verdict, because `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no typed rules; its own header says so). - NOT MEASURED: - cli tests: no cli source changed, so the `unit` layer is not owed and `integration` is left to CI. - Downstream suites that drive the real protocol (`objectql`, `rest`, `runtime`): not run locally. A static read of every test there that spies `console.error` found no count-based assertion over an object publish whose engine lacks `syncObjectSchema`. - Member 1 at a public door after the fix (`os serve`): not run. The base-side reading by the #22913 dev stands, and the unit pin holds the level. ## Acceptance notes - **Observed, not filed (reach not measured):** under `OS_SKIP_SCHEMA_SYNC=1`, member 2's loop still issues DDL for a managed object bound to a declared datasource. `connectDeclared` is called unconditionally from `AppPlugin.start()`, and `syncObjectSchema` is ungated. This is the shape #22984 repaired at the two provisioning sites. It is code reading only, with no public-door measurement. - **Doc drift:** `ObjectQLPlugin.reconcileFederatedBindings`' docblock says `DatasourceConnectionService` re-drives "not at all when `OS_SKIP_SCHEMA_SYNC` is set". No such gate exists in `AppPlugin` or the connection service. - The two census instruments (`measure-return-propagating-durability-seams.mjs`, `measure-durability-swallow-family.mjs`) keep their own guarded-call readers, which do not resolve `.call`. They read the gate's vocabulary but are not the gate. --- _Generated by [Claude Code](https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e49aaec commit 6a6a4d3

10 files changed

Lines changed: 718 additions & 15 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
'@objectstack/service-datasource': patch
4+
'@objectstack/metadata-protocol': patch
5+
---
6+
7+
fix: a refused on-demand schema sync (`syncObjectSchema`) is logged at `error`, naming what was not created and how to fix it (#22985)
8+
9+
Three places create an object's storage after the boot's own schema sync has run. When the driver refused that DDL, each of them logged a `warn` and carried on, so the deployment looked healthy while a table did not exist. Each now logs one `error` line that says what was not created, that nothing else will look wrong, and what fixes it. The driver's refusal is kept out of the line itself. Nothing else changes: the boot, the connect and the publish still complete as before.
10+
11+
- **`@objectstack/service-messaging`**: boot-time provisioning of the eight notification tables. A refused table logs `[messaging] could not provision <table> storage — its storage was NOT created. …` at `error`, once per table per boot, with the refusal in the record's meta. The fix it names: resolve the refusal, then restart or run `os migrate apply`. This used to be a `warn`. Under `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` the provisioning still issues no DDL, so this line cannot appear.
12+
- **`@objectstack/service-datasource`**: connecting a declared datasource re-runs the schema sync of every object bound to it. A refused object logs `datasource '<name>': object '<object>' has NO working storage here — …` at `error`, once per object per connect, with the refusal in meta. On a logger that has no `error`, the same line is logged at `warn`. The fix it names: resolve the refusal, then restart. This used to be a `warn` reading `syncObjectSchema('<object>') failed`.
13+
- **`@objectstack/metadata-protocol`**: publishing an object whose storage sync is refused logs `[Protocol] object '<name>' was published but its storage was NOT created or altered — …` with `console.error`. The driver's refusal is the second argument. The fix it names: resolve the refusal, then publish the object again or restart. The publish still answers success. This replaces the `console.warn` line `[Protocol] table sync failed for object '<name>': …`, so a log search for that text now finds nothing.

‎packages/metadata-protocol/src/protocol.publish-side-effects-canonical-type.test.ts‎

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
// this helper calling `syncObjectSchema` with the folded
5656
// spelling, rather than passing for want of an assertion)
5757

58-
import { describe, expect, it, vi } from 'vitest';
58+
import { afterEach, describe, expect, it, vi } from 'vitest';
5959
// [#5619] The producer's OWN write-verb dispatch decisions, imported from
6060
// `@objectstack/metadata-core` and NOT from `@objectstack/objectql`: objectql
6161
// depends on this package, so that import would close a dependency cycle turbo
@@ -103,7 +103,7 @@ function matchesWhere(r: Row, where: Record<string, unknown>): boolean {
103103
return true;
104104
}
105105

106-
function makeStubEngine() {
106+
function makeStubEngine(refused: ReadonlySet<string> = new Set()) {
107107
const rows = new Map<string, Row>();
108108
const historyRows: HistoryRow[] = [];
109109
let nextId = 0;
@@ -183,7 +183,9 @@ function makeStubEngine() {
183183
async transaction<T>(cb: (ctx: unknown, info: { owned: boolean }) => Promise<T>): Promise<T> {
184184
return cb(undefined, { owned: true });
185185
},
186+
// [#22985] A name in `refused` is the driver refusing that object's DDL.
186187
syncObjectSchema: vi.fn(async (name: string) => {
188+
if (refused.has(name)) throw new Error(`there is already an index named ${name}`);
187189
syncedObjects.push(name);
188190
return true;
189191
}),
@@ -200,8 +202,8 @@ function makeStubEngine() {
200202
return { engine, rows, syncedObjects };
201203
}
202204

203-
function makeProtocol() {
204-
const { engine, rows, syncedObjects } = makeStubEngine();
205+
function makeProtocol(refused?: ReadonlySet<string>) {
206+
const { engine, rows, syncedObjects } = makeStubEngine(refused);
205207
const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_prod') as any;
206208
return { protocol, rows, syncedObjects, engine };
207209
}
@@ -327,3 +329,78 @@ describe('[#8820] publish Phase 2 resolves the object table from the folded type
327329
expect(seen).toEqual(['object/ticket']);
328330
});
329331
});
332+
333+
// ---------------------------------------------------------------------------
334+
// [#22985] A REFUSED storage sync on publish is an `error`, once — and the
335+
// publish is not failed.
336+
// ---------------------------------------------------------------------------
337+
// `ensureObjectStorage` runs after the publish has been written and registered,
338+
// so when the driver refuses its `syncObjectSchema` the publish still answers
339+
// success and the object is served with no table behind it. From the outside
340+
// nothing looks wrong, which is the class AGENTS.md "Degradation log levels"
341+
// sends to `error`. The wording is not pinned: the pins hold the level, the
342+
// count, the named object, the named remedies and the driver's refusal as its
343+
// own argument — and the "looks healthy" half, as the publish verdict the
344+
// caller reads. Driven through this file's stub because it is the one that
345+
// already carries the publish path end to end.
346+
347+
/** The fragment an operator greps for; the rest of the wording is not pinned. */
348+
const REFUSED_HEADLINE = 'its storage was NOT created or altered';
349+
350+
describe('[#22985] a refused object-storage sync on publish is an error, once', () => {
351+
afterEach(() => {
352+
vi.restoreAllMocks();
353+
});
354+
355+
function spyConsole() {
356+
return {
357+
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
358+
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
359+
};
360+
}
361+
362+
async function publish(protocol: any, name: string) {
363+
await protocol.saveMetaItem({
364+
type: 'object', name, item: objectBody(name), packageId: PKG, mode: 'draft',
365+
});
366+
return protocol.publishMetaItem({ type: 'object', name });
367+
}
368+
369+
it('the publish still answers success, and exactly one error line names the object, the remedy and the refusal', async () => {
370+
const spy = spyConsole();
371+
const { protocol, syncedObjects } = makeProtocol(new Set(['ticket']));
372+
373+
const res = await publish(protocol, 'ticket');
374+
375+
// The half that makes it the durability class: the caller is told the
376+
// publish succeeded, while the storage it was published for is absent.
377+
expect(res.success).toBe(true);
378+
expect(syncedObjects).toEqual([]);
379+
380+
const reports = spy.error.mock.calls.filter((args) => String(args[0]).includes(REFUSED_HEADLINE));
381+
expect(reports).toHaveLength(1);
382+
const [message, refusal] = reports[0];
383+
expect(message).toContain("object 'ticket'");
384+
// ② the fix, by its named subjects.
385+
expect(message).toContain('publish the object again');
386+
expect(message).toContain('restart');
387+
// One line: the first line carries both halves, and the driver's
388+
// refusal rides as its own argument rather than inside it.
389+
expect(message).not.toContain('\n');
390+
expect(message).not.toContain('there is already an index');
391+
expect(refusal).toBe('there is already an index named ticket');
392+
// …and it is not ALSO reported at a quieter level.
393+
expect(spy.warn.mock.calls.filter((args) => /table sync failed|ticket/.test(String(args[0])))).toEqual([]);
394+
});
395+
396+
it('CONTROL: a publish whose sync succeeds logs no error at all', async () => {
397+
const spy = spyConsole();
398+
const { protocol, syncedObjects } = makeProtocol();
399+
400+
const res = await publish(protocol, 'ticket');
401+
402+
expect(res.success).toBe(true);
403+
expect(syncedObjects).toEqual(['ticket']);
404+
expect(spy.error).not.toHaveBeenCalled();
405+
});
406+
});

‎packages/metadata-protocol/src/protocol.record-package-commit-durability.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@ function makeStubEngine() {
173173
async transaction<T>(cb: (ctx: undefined, info: { owned: boolean }) => Promise<T>): Promise<T> {
174174
return cb(undefined, { owned: true });
175175
},
176+
// [#22985] `HostEngine` declares it, and a publish of an object calls
177+
// it. Without it the publish's storage sync threw a TypeError that the
178+
// protocol now reports at `error` — which the "logs nothing" control
179+
// below would read as a commit-store report.
180+
async syncObjectSchema() {},
176181
registry: {
177182
registerItem: () => {},
178183
registerObject: () => {},

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19387,7 +19387,10 @@ export class ObjectStackProtocolImplementation implements
1938719387
* this, inserting into a freshly-published object fails with "no such
1938819388
* table" (surfaced as `object_not_found`) until the next restart.
1938919389
* Best-effort + non-fatal: drivers without DDL (or read-only datasources)
19390-
* simply no-op, and a sync failure must not abort the publish.
19390+
* simply no-op, and a sync failure must not abort the publish. Non-fatal is
19391+
* not quiet: a refused sync is reported at `error` with its consequence
19392+
* and fix (#22985), because the publish it follows has already answered
19393+
* success.
1939119394
*/
1939219395
private async ensureObjectStorage(type: string, name: string): Promise<void> {
1939319396
// [#8862] The `&& type !== 'objects'` limb is GONE. Both call sites
@@ -19403,7 +19406,22 @@ export class ObjectStackProtocolImplementation implements
1940319406
try {
1940419407
await this.engine.syncObjectSchema(name);
1940519408
} catch (err: any) {
19406-
console.warn(`[Protocol] table sync failed for object '${name}': ${err?.message ?? err}`);
19409+
// [#22985] `error`, not `warn`, by the one question AGENTS.md
19410+
// "Degradation log levels" turns this on: the publish still
19411+
// answers success and the object is registered and served, while
19412+
// the storage it was published for does not exist. Said once per
19413+
// publish of the object, never per write; the publish is
19414+
// deliberately NOT failed (see the docblock). The driver's refusal
19415+
// is a separate argument, so a multi-line driver error cannot split
19416+
// the line that carries the level.
19417+
console.error(
19418+
`[Protocol] object '${name}' was published but its storage was NOT created or altered — its schema `
19419+
+ 'sync was refused. The publish answered success and the object is registered and served, so it '
19420+
+ 'looks usable while its writes fail (or drop the columns this publish added) until the sync runs. '
19421+
+ "Fix: resolve the driver's refusal (logged after this line), then publish the object again, which "
19422+
+ 're-runs this sync, or restart, since the boot syncs every registered object\'s storage.',
19423+
err?.message ?? err,
19424+
);
1940719425
}
1940819426
}
1940919427

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22985] A bound object's schema sync refused at connect is a DURABILITY
5+
* degradation, so it is reported at `error` — once per object per connect —
6+
* and the connect is not failed.
7+
*
8+
* `connect()` re-drives `syncObjectSchema` for every object bound to the
9+
* datasource it just connected, because the boot's own schema sync ran before
10+
* this driver existed. When that call throws, the datasource still answers
11+
* `connected` and the object stays registered and served with no working
12+
* storage — a managed object has no table, a federated one is not bound to its
13+
* remote table. Nothing about the connect looks wrong from the outside, which
14+
* is the class AGENTS.md "Degradation log levels" sends to `error`.
15+
*
16+
* The wording is not pinned: the pins hold the level, the count, the named
17+
* object and datasource, the named remedy and the driver's refusal in meta —
18+
* and the "looks healthy" half, as the connect verdict an operator reads.
19+
*/
20+
21+
import { describe, it, expect } from 'vitest';
22+
import {
23+
DatasourceConnectionService,
24+
type ConnectableDatasource,
25+
type ConnectionEngineLike,
26+
} from '../datasource-connection-service.js';
27+
import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js';
28+
import type { IDataDriver } from '@objectstack/spec/contracts';
29+
30+
/** An engine whose schema sync refuses the objects in `refused` and syncs the rest. */
31+
function engineRefusing(refused: Set<string>) {
32+
const drivers = new Map<string, { name?: string }>();
33+
const synced: string[] = [];
34+
const engine: ConnectionEngineLike = {
35+
registerDriver: (driver) => {
36+
if (!drivers.has(driver.name)) drivers.set(driver.name, driver);
37+
},
38+
registerDatasourceDef: () => {},
39+
getDriverByName: (name) => drivers.get(name) as IDataDriver | undefined,
40+
syncObjectSchema: async (name) => {
41+
if (refused.has(name)) throw new Error(`there is already an index named ${name}`);
42+
synced.push(name);
43+
},
44+
};
45+
return { engine, synced };
46+
}
47+
48+
function factory(): IDatasourceDriverFactory {
49+
return {
50+
supports: () => true,
51+
create: async () => ({ driver: { name: 'com.fake.driver' }, connect: async () => {} }),
52+
};
53+
}
54+
55+
type ErrorCall = { message: string; meta: unknown };
56+
57+
/** A sink recording both channels; `withError: false` is the contract's `{ warn }`-only sink. */
58+
function sink(withError = true) {
59+
const warn: string[] = [];
60+
const error: ErrorCall[] = [];
61+
const logger = {
62+
warn: (message: string) => { warn.push(message); },
63+
info: () => {},
64+
...(withError ? { error: (message: string, meta?: unknown) => { error.push({ message, meta }); } } : {}),
65+
};
66+
return { logger, warn, error };
67+
}
68+
69+
const reporting: ConnectableDatasource = {
70+
name: 'reporting',
71+
driver: 'sqlite',
72+
schemaMode: 'managed',
73+
config: {},
74+
};
75+
const OBJECTS = [
76+
{ name: 'report_row', datasource: 'reporting' },
77+
{ name: 'report_rollup', datasource: 'reporting' },
78+
];
79+
80+
async function connect(refused: Set<string>, withError = true) {
81+
const { engine, synced } = engineRefusing(refused);
82+
const log = sink(withError);
83+
const service = new DatasourceConnectionService({
84+
factory: () => factory(),
85+
engine: () => engine,
86+
logger: log.logger,
87+
});
88+
const results = await service.connectDeclared({ datasources: [reporting], objects: OBJECTS });
89+
return { results, synced, service, log };
90+
}
91+
92+
describe('DatasourceConnectionService — a refused bound-object sync is an error, once per object (#22985)', () => {
93+
it('a refused sync logs exactly one error line naming the object, the datasource, the remedy and the refusal', async () => {
94+
const { results, synced, service, log } = await connect(new Set(['report_row']));
95+
96+
// The half that makes it the durability class: from the outside the
97+
// connect looks healthy — the verdict an operator and the admin list read.
98+
expect(results).toEqual([{ name: 'reporting', status: 'connected' }]);
99+
expect(service.getConnectionState('reporting')?.status).toBe('connected');
100+
101+
expect(log.error).toHaveLength(1);
102+
const [line] = log.error;
103+
expect(line.message).toContain("object 'report_row'");
104+
expect(line.message).toContain("datasource 'reporting'");
105+
// ② the fix, by its named subject.
106+
expect(line.message).toContain('restart');
107+
// One line: the first line carries both halves.
108+
expect(line.message).not.toContain('\n');
109+
// The driver's refusal travels in meta, never interpolated into the line.
110+
expect(line.message).not.toContain('there is already an index');
111+
expect(line.meta).toEqual({
112+
datasource: 'reporting',
113+
object: 'report_row',
114+
error: 'there is already an index named report_row',
115+
});
116+
// …and it is not ALSO reported at a quieter level.
117+
expect(log.warn).toEqual([]);
118+
// The connect is not failed: the other bound object still synced.
119+
expect(synced).toEqual(['report_rollup']);
120+
});
121+
122+
it('every refused object gets its own line', async () => {
123+
const { log } = await connect(new Set(['report_row', 'report_rollup']));
124+
125+
expect(log.error.map((c) => (c.meta as { object: string }).object)).toEqual(['report_row', 'report_rollup']);
126+
});
127+
128+
it('a `{ warn }`-only sink still prints the line — the fallback, never an optional `error?.()`', async () => {
129+
const { log } = await connect(new Set(['report_row']), false);
130+
131+
expect(log.warn).toHaveLength(1);
132+
expect(log.warn[0]).toContain("object 'report_row'");
133+
});
134+
135+
it('CONTROL: a clean connect logs no error and syncs every bound object', async () => {
136+
const { results, synced, log } = await connect(new Set());
137+
138+
expect(results).toEqual([{ name: 'reporting', status: 'connected' }]);
139+
expect(log.error).toEqual([]);
140+
expect(log.warn).toEqual([]);
141+
expect(synced).toEqual(['report_row', 'report_rollup']);
142+
});
143+
});

0 commit comments

Comments
 (0)