Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .changeset/17175-non-raising-table-presence-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
'@objectstack/metadata-protocol': minor
---

The `kernel:ready` migrations ask whether a table exists WITHOUT running a statement that has to be refused, so a normal boot stops printing `[sql-driver] DATABASE_ERROR … no such table` (#17175)

Two migrations on the boot hook asked "does this table exist?" with a statement
that cannot succeed when the answer is no — `SELECT "tenant_id" FROM
"_objectstack_sequences" WHERE 1 = 0` in `seed-tenancy-backfill.ts`, and `SELECT
1 FROM sys_setting WHERE 1 = 0` in `sys-setting-identity-index.ts` — and read
the refusal as "no". Both are correct on their own terms. Both make
`SqlDriver.execute()`'s raw terminal write the statement and the dialect's
message to the operator's log on the way out.

Measured on this tree against real `better-sqlite3`: exactly one line per probe,
on `console.warn` — i.e. **stderr** — carrying both the `DATABASE_ERROR` token
and `no such table`. It fires on **every boot** of every install that has never
allocated an autonumber, and again on every boot of every kernel that does not
register the optional `service-settings`.

⭐ The cost is not the line. It is that operators learn this product prints
errors when nothing is wrong, and then miss the one that matters. A consumer told
to read the boot log (`objectstack-ai/hotclm`'s `AGENTS.md` names `no such table`
as a failing boot) must either ignore an unactionable ERROR every boot or chase a
platform-internal probe.

**The question is now asked of the CATALOG.** A new shared
`migrations/read-probe.ts` compiles one arm per dialect family — `sqlite_master`
for SQLite, `to_regclass` for Postgres, `information_schema.tables` scoped with
`DATABASE()` for MySQL — each of which returns zero rows for a table that is not
there instead of being refused. Both migrations call it; the probe lives once,
not once per site.

**⛔ Why not in the driver.** Quietening a refusal requires classifying it, this
repo has one predicate for that (`isMissingTableError`), and it needs the name of
the thing the caller was reading — which the raw path structurally does not have
(`rawStatementFaultError` declares no targeted table, and
`driver-error-classification.callers.test.ts` fails any in-repo call that omits
`readObject`). An unclassified demotion of the driver's raw terminal would
quieten real failures too. The caller knows the table; the driver does not.

**⛔ The fence, and it is the one way this repair can go wrong.** A catalog arm
mis-compiled for some dialect would be refused, caught by the same `catch` the
expected miss uses, and read as "the table is not there" — turning a stored-row
data repair into a silent no-op on whichever dialect nobody exercised. So the
probe answers four verdicts rather than a boolean, and `'unreadable'` is never
folded into `'absent'`: it is returned, and reported at `warn`. An unrecognised
dialect gets no guessed catalog statement at all — it keeps the caller's own
`WHERE 1 = 0` probe, whose refusal is now *classified* with
`isMissingTableError(error, table)` rather than swallowed as absence.

**Why `minor`.**

- `SeedTenancyBackfillStatus` gains `'unreadable'`. It is an OUTPUT union, so no
input a caller writes is affected; the one consumer shape that could break is
an exhaustive `switch` with a `never` default, which is why this is not a
`patch`.
- `ensureSysSettingIdentityIndex` gains an optional third parameter
(`{ client? }`). Callers that pass two arguments are unchanged and keep
today's behaviour exactly — without a client there is no catalog arm and the
pre-existing probe runs.
- `buildSequencesPresenceSql` and `buildSysSettingPresenceSql` are unchanged in
text and still exported. They are no longer what the boot path runs first.
- `isResultSet` and `normalizeRows` moved to `migrations/read-probe.ts` and are
re-exported from `seed-tenancy-backfill.ts` unchanged, so the package index and
every importer see no difference.

**What did NOT change.** #10789's ruling stands: a seam that accepts a statement
and returns no result set still reports `absent` with the `detail` that separates
it. The driver's error channel is untouched — a statement the backend genuinely
refuses is still written to the log in full, asserted against the same driver and
the same sink in the same test as the silence.

**Dialect coverage, stated rather than implied.** The SQLite arm is pinned end to
end against a real `SqlDriver` (`packages/runtime`'s
`seed-tenancy-autonumber-split.integration.test.ts`); the MySQL arm runs against
the live server in `seed-tenancy-backfill.live-mysql.test.ts`, in both directions
and with the connected-schema scope measured. ⛔ The **Postgres** arm is NOT
MEASURED against a live server: this package has no live-PG harness, no `pg`
dependency, and its CI leg supplies `OS_TEST_MYSQL_URL` only while filtering to
`live-mysql`. Its statement text is pinned; running it is not.
36 changes: 36 additions & 0 deletions packages/metadata-protocol/src/migrations/driver-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,39 @@ export function resolveDriverExec(driver: IDataDriver | null | undefined): Drive
}
return undefined;
}

/**
* [#17175] The knex client name `driver` speaks, or `undefined`.
*
* Re-homed here from `seed-tenancy-backfill.ts`'s private `resolveClientName`
* when a SECOND site needed it: `sys-setting-identity-index.ts` has to compile a
* dialect-specific catalog statement through `read-probe.ts`, and a second copy
* of this walk is the duplication this module exists to prevent — the same
* argument its header makes for the three copies of the exec resolution.
*
* ⚠️ Three lookups, in this order, because the answer sits in a different place
* depending on how the driver was built: its own `config.client`, then the knex
* instance's (`knex.client.config.client`), then a knex bound to a context
* (`knex.context.client.config.client`). Each read is individually guarded, so a
* getter that throws yields `undefined` rather than escaping into a boot hook.
*
* `undefined` is a REAL answer — "this host did not say" — and its consumers
* must treat it as one. ⛔ It is never defaulted to a dialect: guessing is what
* `read-probe.ts`'s fence is written against.
*/
export function resolveDriverClientName(driver: unknown): string | undefined {
const candidate = driver as any;
const read = (fn: () => unknown): string | undefined => {
try {
const v = fn();
return typeof v === 'string' && v.length > 0 ? v : undefined;
} catch {
return undefined;
}
};
return (
read(() => candidate?.config?.client) ??
read(() => candidate?.knex?.client?.config?.client) ??
read(() => candidate?.knex?.context?.client?.config?.client)
);
}
28 changes: 26 additions & 2 deletions packages/metadata-protocol/src/migrations/partial-index-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@

import { isUniqueViolationError, operatorFacingErrorText } from '@objectstack/types';

import { driverCanRunSql, resolveDriverExec } from './driver-exec.js';
import { driverCanRunSql, resolveDriverClientName, resolveDriverExec } from './driver-exec.js';

/**
* Raw-SQL seam. The surface is resolved by `./driver-exec.ts`: `execute()`
Expand Down Expand Up @@ -85,6 +85,26 @@ export type IndexExec = (sql: string) => Promise<unknown>;
* `getDriverForObject` is here to prevent.
*/
export function resolveIndexExecForTable(engine: unknown, table: string): IndexExec | undefined {
return resolveIndexSeamForTable(engine, table)?.exec;
}

/**
* The raw-SQL seam for ONE table, PLUS the dialect the driver behind it speaks.
*
* [#17175] The pair, for the same structural reason `seed-tenancy-backfill.ts`
* takes a seam rather than a bare exec (#9381): a statement compiled for a
* dialect nobody resolved is a statement compiled for a guess. The presence
* probe in `read-probe.ts` needs the dialect to pick a catalog arm, and
* {@link resolveIndexExecForTable} — the pre-existing entry point, unchanged in
* signature and behaviour — now reads its `exec` off this.
*
* `client` is `undefined` on a host that does not say, which is a real answer
* and ⛔ never defaulted; the probe falls back to the caller's own statement.
*/
export function resolveIndexSeamForTable(
engine: unknown,
table: string,
): { exec: IndexExec; client?: string } | undefined {
const engineAny = engine as any;
const attempt = (fn: () => unknown): any => {
try {
Expand All @@ -108,7 +128,11 @@ export function resolveIndexExecForTable(engine: unknown, table: string): IndexE
}
}
if (!canRunSql(driver)) return undefined;
return resolveDriverExec(driver);
const exec = resolveDriverExec(driver);
// `canRunSql` above is defined AS this resolution succeeding, so `exec` is
// present here; the guard is for the type, not for a reachable state.
if (!exec) return undefined;
return { exec, client: resolveDriverClientName(driver) };
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
ORGANIZATION_TABLE,
SEQUENCES_TABLE,
} from './seed-tenancy-backfill.js';
import { TABLE_IS_PRESENT_ROWS, isTablePresenceCatalogSql } from './read-probe.testkit.js';

/** `rawStatementFaultError`'s composed message, verbatim (`sql-driver.ts`). */
const COMPOSED =
Expand Down Expand Up @@ -230,6 +231,7 @@ describe('[#16657] seed-tenancy-backfill — the stored operator record', () =>
function seamExec(refuse: (sql: string) => boolean) {
return async (sql: string): Promise<unknown> => {
if (refuse(sql)) throw rawStatementFault();
if (isTablePresenceCatalogSql(sql, SEQUENCES_TABLE)) return TABLE_IS_PRESENT_ROWS;
if (sql.includes('WHERE 1 = 0')) return [];
if (sql.includes('LEFT JOIN')) {
return [
Expand Down
Loading
Loading