@objectstack/metadata 17.0.0-rc.4 → 17.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/errors.ts","../src/utils/schema-sync-errors.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * `@objectstack/metadata/errors` — the shared driver-error discriminators for\n * the metadata storage seams (#4728 / #4825 / #4867 family).\n *\n * ## Why this subpath exists\n *\n * The \"which driver failures may be silenced?\" question is not local to one\n * package. It was answered first for DDL in `@objectstack/metadata`\n * (`ensureSchema`, #4728), then for reads on the legacy `DatabaseLoader` path\n * (`nextEventSeq`, #4825) — and the *canonical* transactional producer of the\n * very same numbers, `SysMetadataRepository`, lives in a different package\n * (`@objectstack/metadata-protocol`, #4867) and carried the identical defect.\n *\n * Three ways to serve that second package were considered; the third is the\n * one taken, and the first is the one this module exists to prevent:\n *\n * 1. **Copy the predicate.** Rejected. Two hand-rolled vocabularies of\n * \"benign driver error\" is precisely the dual-source debt #4825 killed:\n * a driver quirk taught to one copy and not the other produces two\n * packages that disagree about whether data may be silently invented.\n * 2. **Sink it into a common dependency** (`@objectstack/types`,\n * `@objectstack/spec/shared`). Architecturally attractive and explicitly\n * *not* precluded by this module — but out of scope on the round that\n * needed it (spec was frozen; types was under concurrent change).\n * 3. **Export it deliberately from its current home** — this file. One\n * declaration, one implementation, one place a new driver quirk is taught.\n *\n * ## Why a subpath and not the package entry\n *\n * `@objectstack/metadata`'s root entry pulls the manager, every loader and the\n * YAML/filesystem machinery behind them. A consumer that wants a 40-line\n * predicate should not have to load any of that, and the weight is exactly\n * what would tempt the next author back to option 1. This entry re-exports\n * one leaf module and nothing else, so the cross-package edge stays a leaf\n * edge — and stays a single, greppable seam to delete if the maintainer later\n * takes option 2.\n *\n * ## Scope of the promise\n *\n * Only {@link isMissingTableError} is exported: it has a cross-package\n * consumer today. Its sibling `isSchemaAlreadyExistsError` deliberately stays\n * internal to this package — it has no consumer outside it, and an exported\n * symbol nobody imports is a promise made for nothing (Prime Directive #10,\n * pointed at our own API surface). Add it here the day something outside\n * `@objectstack/metadata` needs it, not before.\n */\n\nexport { isMissingTableError } from './utils/schema-sync-errors.js';\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Driver-error classification for the metadata storage seams (#4728, #4825;\n * rule from #4632).\n *\n * Two questions live here, and they share one mechanism on purpose. A second\n * hand-rolled `catch`-and-guess elsewhere in this package would be a second\n * de-facto vocabulary of \"which driver errors are benign\" — the exact debt this\n * module exists to retire. Both predicates below are thin wrappers over one\n * signature matcher, so a driver quirk is taught to the package once.\n *\n * 1. {@link isSchemaAlreadyExistsError} — \"was this DDL failure just the table\n * already being there?\" (#4728, `ensureSchema` / `ensureHistorySchema`).\n * 2. {@link isMissingTableError} — \"did this READ fail because the table has\n * not been provisioned yet?\" (#4825, `nextEventSeq`).\n *\n * They are deliberately **not** each other's negation. Each answers \"is this\n * the one benign reason?\" and defaults to *not benign*, so an error neither\n * recognises is loud under both.\n *\n * ---\n *\n * ## 1. DDL failure classification (#4728)\n *\n * `IDataDriver.syncSchema()` is contractually **idempotent** (\"creates tables if\n * missing, adds columns, updates indexes\"), so in principle a re-sync of an\n * existing table should not throw at all. In practice a driver may surface the\n * already-provisioned case as an error instead of a no-op — `CREATE TABLE`\n * without `IF NOT EXISTS`, an `ALTER TABLE ADD COLUMN` for a column that is\n * already there. That single failure reason is benign: the table and its columns\n * exist, so the bytes will land.\n *\n * **Every other** DDL failure is not benign, and the difference is the whole\n * point of this module. Insufficient privileges, a datasource that never\n * connected, an incompatible column type — after those, the table or column does\n * not exist, yet the process keeps looking healthy while everything it claims to\n * persist has nowhere to land. That is the #4420 shape, and AGENTS.md →\n * \"Degradation log levels\" requires it to be reported at `error`.\n *\n * The defect this replaces was a `catch` whose comment named the benign reason\n * (\"e.g. table already exists\") and used it to excuse **all** of them. Callers\n * must therefore ask the question by error *type*:\n *\n * ```ts\n * catch (error) {\n * if (!isSchemaAlreadyExistsError(error)) {\n * console.error('… consequence … fix …', error); // loud, and stay not-ready\n * return;\n * }\n * // benign only: the table is already provisioned, carry on\n * }\n * ```\n *\n * Classification is deliberately conservative — anything not positively\n * recognised as \"already exists\" is treated as a real failure, because the cost\n * of a false \"benign\" (silent data loss) is far higher than the cost of a false\n * \"real\" (one extra error line).\n *\n * ---\n *\n * ## 2. Missing-table classification for reads (#4825)\n *\n * `DatabaseLoader.nextEventSeq()` reads `sys_metadata_history` to decide what\n * `event_seq` the NEXT history row gets. Its `catch` named both reasons a read\n * can fail — \"table not provisioned yet\" (benign: 1 really is the next number)\n * and \"driver error\" (**not** benign) — and answered both with `return 1`.\n *\n * That is the #4728 shape one layer down, but the damage is the opposite kind\n * and worse. #4728 was *bytes that never landed*; this is **bytes that land\n * wrong**: with N rows already in the table, one flaky read hands the next row\n * `event_seq = 1`, colliding with an existing row. The insert **succeeds**, no\n * line is logged, and `event_seq` — the ordering key that history listing and\n * rollback targeting both stand on — is now silently untrustworthy.\n *\n * So the read seam gets the same treatment, with the same conservative default:\n *\n * ```ts\n * catch (error) {\n * if (isMissingTableError(error)) return 1; // benign: nothing to collide with\n * throw error; // caller reports the consequence\n * }\n * ```\n */\n\n/** One \"which errors mean X?\" vocabulary, in the three forms drivers use. */\ninterface DriverErrorSignature {\n /** `error.code` — Postgres SQLSTATE, or mysql2's symbolic name. */\n readonly codes: ReadonlySet<string>;\n /** `error.errno` — MySQL/MariaDB numeric equivalents. */\n readonly errnos: ReadonlySet<number>;\n /** `error.message` — the only signal SQLite-family drivers give. */\n readonly message: RegExp;\n}\n\n/**\n * Driver/SQLSTATE codes that mean \"the thing you asked me to create is already\n * there\". Postgres reports SQLSTATE on `code`; mysql2 reports its symbolic name.\n */\nconst ALREADY_EXISTS: DriverErrorSignature = {\n codes: new Set([\n // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)\n '42P07', // duplicate_table\n '42701', // duplicate_column\n '42710', // duplicate_object — index / constraint already exists\n // MySQL / MariaDB (mysql2 puts the symbolic name on `code`)\n 'ER_TABLE_EXISTS_ERROR', // 1050\n 'ER_DUP_FIELDNAME', // 1060\n 'ER_DUP_KEYNAME', // 1061\n ]),\n errnos: new Set([1050, 1060, 1061]),\n /**\n * Message fallback for drivers that carry no machine-readable code —\n * notably SQLite, whose `code` is the undifferentiated `SQLITE_ERROR` for\n * every DDL failure, so the message is the only signal available:\n * - `table sys_metadata already exists`\n * - `duplicate column name: environment_id`\n * - `index idx_x already exists`\n * Postgres phrases its own as `relation \"x\" already exists` /\n * `column \"x\" of relation \"y\" already exists`, which matches the same test.\n */\n message: /already exists|duplicate column name|duplicate key name/i,\n};\n\n/**\n * Codes/messages that mean \"the table you tried to READ has not been created\".\n *\n * Narrower than it looks, on purpose. `does not exist` on its own also covers\n * `role \"x\" does not exist` (42704), `database \"x\" does not exist` (3D000) and\n * `column \"x\" does not exist` (42703) — every one of them a **real** failure\n * that must stay loud, and every one of them a case where \"start numbering at\n * 1\" would be the wrong answer against a table that may be full of rows. So the\n * message test demands the word table/relation next to the phrase rather than\n * the phrase alone, and the code set carries only the table-scoped SQLSTATEs.\n */\nconst MISSING_TABLE: DriverErrorSignature = {\n codes: new Set([\n '42P01', // PostgreSQL undefined_table\n 'ER_NO_SUCH_TABLE', // MySQL / MariaDB 1146\n ]),\n errnos: new Set([1146]),\n /**\n * - SQLite / libsql: `no such table: sys_metadata_history`\n * - PostgreSQL: `relation \"sys_metadata_history\" does not exist`\n * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`\n */\n message:\n /no such table|relation [\"'`][^\"'`]+[\"'`] does not exist|table [\"'`][^\"'`]+[\"'`] doesn'?t exist|unknown table/i,\n};\n\n/** How far to follow an `error.cause` chain — drivers wrap, but not deeply. */\nconst MAX_CAUSE_DEPTH = 4;\n\n/**\n * The single matcher both predicates run on: code, then errno, then message,\n * then one step down the `cause` chain.\n *\n * Unrecognised is always `false` — a benign verdict must be *earned*, never\n * defaulted to, because a false \"benign\" corrupts data while a false \"real\"\n * costs one error line.\n */\nfunction matchesDriverError(\n error: unknown,\n signature: DriverErrorSignature,\n depth: number,\n): boolean {\n if (error === null || error === undefined || depth > MAX_CAUSE_DEPTH) return false;\n\n if (typeof error === 'string') return signature.message.test(error);\n if (typeof error !== 'object') return false;\n\n const err = error as {\n code?: unknown;\n errno?: unknown;\n message?: unknown;\n cause?: unknown;\n };\n\n if (typeof err.code === 'string' && signature.codes.has(err.code)) return true;\n if (typeof err.errno === 'number' && signature.errnos.has(err.errno)) return true;\n if (typeof err.message === 'string' && signature.message.test(err.message)) return true;\n\n // Drivers commonly re-throw with the original attached as `cause`.\n return matchesDriverError(err.cause, signature, depth + 1);\n}\n\n/**\n * Is this DDL error the benign \"already provisioned\" case?\n *\n * @param error - The value thrown by `syncSchema()` (or any DDL call).\n * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.\n * @returns `true` only when the error positively identifies as\n * table/column/index-already-exists. Anything else — including an\n * unrecognised error, `undefined`, or a permission/connection failure —\n * returns `false` and MUST be reported loudly by the caller.\n */\nexport function isSchemaAlreadyExistsError(error: unknown, depth = 0): boolean {\n return matchesDriverError(error, ALREADY_EXISTS, depth);\n}\n\n/**\n * Is this READ error the benign \"table has not been provisioned yet\" case?\n *\n * The only failure that licenses a caller to treat an empty table as the truth\n * — there are no rows, so there is nothing to be inconsistent with. A\n * connection drop, a timeout, a permission denial or a query error all mean the\n * rows may well exist and simply were not seen; those return `false` and the\n * caller must report the consequence and give up rather than compute an answer\n * from data it never read (#4825).\n *\n * @param error - The value thrown by a driver/engine read (`find`, `findOne`, …).\n * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.\n * @returns `true` only when the error positively identifies as\n * table/relation-does-not-exist.\n */\nexport function isMissingTableError(error: unknown, depth = 0): boolean {\n return matchesDriverError(error, MISSING_TABLE, depth);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACuIA,IAAM,gBAAsC;AAAA,EACxC,OAAO,oBAAI,IAAI;AAAA,IACX;AAAA;AAAA,IACA;AAAA;AAAA,EACJ,CAAC;AAAA,EACD,QAAQ,oBAAI,IAAI,CAAC,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMtB,SACI;AACR;AAGA,IAAM,kBAAkB;AAUxB,SAAS,mBACL,OACA,WACA,OACO;AACP,MAAI,UAAU,QAAQ,UAAU,UAAa,QAAQ,gBAAiB,QAAO;AAE7E,MAAI,OAAO,UAAU,SAAU,QAAO,UAAU,QAAQ,KAAK,KAAK;AAClE,MAAI,OAAO,UAAU,SAAU,QAAO;AAEtC,QAAM,MAAM;AAOZ,MAAI,OAAO,IAAI,SAAS,YAAY,UAAU,MAAM,IAAI,IAAI,IAAI,EAAG,QAAO;AAC1E,MAAI,OAAO,IAAI,UAAU,YAAY,UAAU,OAAO,IAAI,IAAI,KAAK,EAAG,QAAO;AAC7E,MAAI,OAAO,IAAI,YAAY,YAAY,UAAU,QAAQ,KAAK,IAAI,OAAO,EAAG,QAAO;AAGnF,SAAO,mBAAmB,IAAI,OAAO,WAAW,QAAQ,CAAC;AAC7D;AA+BO,SAAS,oBAAoB,OAAgB,QAAQ,GAAY;AACpE,SAAO,mBAAmB,OAAO,eAAe,KAAK;AACzD;","names":[]}
1
+ {"version":3,"sources":["../src/errors.ts","../src/utils/schema-sync-errors.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * `@objectstack/metadata/errors` — the shared driver-error discriminators for\n * the metadata storage seams (#4728 / #4825 / #4867 family).\n *\n * ## Why this subpath exists\n *\n * The \"which driver failures may be silenced?\" question is not local to one\n * package. It was answered first for DDL in `@objectstack/metadata`\n * (`ensureSchema`, #4728), then for reads on the legacy `DatabaseLoader` path\n * (`nextEventSeq`, #4825) — and the *canonical* transactional producer of the\n * very same numbers, `SysMetadataRepository`, lives in a different package\n * (`@objectstack/metadata-protocol`, #4867) and carried the identical defect.\n *\n * Three ways to serve that second package were considered; the third is the\n * one taken, and the first is the one this module exists to prevent:\n *\n * 1. **Copy the predicate.** Rejected. Two hand-rolled vocabularies of\n * \"benign driver error\" is precisely the dual-source debt #4825 killed:\n * a driver quirk taught to one copy and not the other produces two\n * packages that disagree about whether data may be silently invented.\n * 2. **Sink it into a common dependency** (`@objectstack/types`,\n * `@objectstack/spec/shared`). Architecturally attractive and explicitly\n * *not* precluded by this module — but out of scope on the round that\n * needed it (spec was frozen; types was under concurrent change).\n * 3. **Export it deliberately from its current home** — this file. One\n * declaration, one implementation, one place a new driver quirk is taught.\n *\n * ## Why a subpath and not the package entry\n *\n * `@objectstack/metadata`'s root entry pulls the manager, every loader and the\n * YAML/filesystem machinery behind them. A consumer that wants a 40-line\n * predicate should not have to load any of that, and the weight is exactly\n * what would tempt the next author back to option 1. This entry re-exports\n * one leaf module and nothing else, so the cross-package edge stays a leaf\n * edge — and stays a single, greppable seam to delete if the maintainer later\n * takes option 2.\n *\n * ## Scope of the promise\n *\n * Only {@link isMissingTableError} is exported: it has a cross-package\n * consumer today. Its sibling `isSchemaAlreadyExistsError` deliberately stays\n * internal to this package — it has no consumer outside it, and an exported\n * symbol nobody imports is a promise made for nothing (Prime Directive #10,\n * pointed at our own API surface). Add it here the day something outside\n * `@objectstack/metadata` needs it, not before.\n */\n\nexport { isMissingTableError } from './utils/schema-sync-errors.js';\n","// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Driver-error classification for the metadata storage seams (#4728, #4825;\n * rule from #4632).\n *\n * Two questions live here, and they share one mechanism on purpose. A second\n * hand-rolled `catch`-and-guess elsewhere in this package would be a second\n * de-facto vocabulary of \"which driver errors are benign\" — the exact debt this\n * module exists to retire. Both predicates below are thin wrappers over one\n * signature matcher, so a driver quirk is taught to the package once.\n *\n * 1. {@link isSchemaAlreadyExistsError} — \"was this DDL failure just the table\n * already being there?\" (#4728, `ensureSchema` / `ensureHistorySchema`).\n * 2. {@link isMissingTableError} — \"did this READ fail because the table has\n * not been provisioned yet?\" (#4825, `nextEventSeq`).\n *\n * They are deliberately **not** each other's negation. Each answers \"is this\n * the one benign reason?\" and defaults to *not benign*, so an error neither\n * recognises is loud under both.\n *\n * ---\n *\n * ## 1. DDL failure classification (#4728)\n *\n * `IDataDriver.syncSchema()` is contractually **idempotent** (\"creates tables if\n * missing, adds columns, updates indexes\"), so in principle a re-sync of an\n * existing table should not throw at all. In practice a driver may surface the\n * already-provisioned case as an error instead of a no-op — `CREATE TABLE`\n * without `IF NOT EXISTS`, an `ALTER TABLE ADD COLUMN` for a column that is\n * already there. That single failure reason is benign: the table and its columns\n * exist, so the bytes will land.\n *\n * **Every other** DDL failure is not benign, and the difference is the whole\n * point of this module. Insufficient privileges, a datasource that never\n * connected, an incompatible column type — after those, the table or column does\n * not exist, yet the process keeps looking healthy while everything it claims to\n * persist has nowhere to land. That is the #4420 shape, and AGENTS.md →\n * \"Degradation log levels\" requires it to be reported at `error`.\n *\n * The defect this replaces was a `catch` whose comment named the benign reason\n * (\"e.g. table already exists\") and used it to excuse **all** of them. Callers\n * must therefore ask the question by error *type*:\n *\n * ```ts\n * catch (error) {\n * if (!isSchemaAlreadyExistsError(error)) {\n * console.error('… consequence … fix …', error); // loud, and stay not-ready\n * return;\n * }\n * // benign only: the table is already provisioned, carry on\n * }\n * ```\n *\n * Classification is deliberately conservative — anything not positively\n * recognised as \"already exists\" is treated as a real failure, because the cost\n * of a false \"benign\" (silent data loss) is far higher than the cost of a false\n * \"real\" (one extra error line).\n *\n * ---\n *\n * ## 2. Missing-table classification for reads (#4825)\n *\n * `DatabaseLoader.nextEventSeq()` reads `sys_metadata_history` to decide what\n * `event_seq` the NEXT history row gets. Its `catch` named both reasons a read\n * can fail — \"table not provisioned yet\" (benign: 1 really is the next number)\n * and \"driver error\" (**not** benign) — and answered both with `return 1`.\n *\n * That is the #4728 shape one layer down, but the damage is the opposite kind\n * and worse. #4728 was *bytes that never landed*; this is **bytes that land\n * wrong**: with N rows already in the table, one flaky read hands the next row\n * `event_seq = 1`, colliding with an existing row. The insert **succeeds**, no\n * line is logged, and `event_seq` — the ordering key that history listing and\n * rollback targeting both stand on — is now silently untrustworthy.\n *\n * So the read seam gets the same treatment, with the same conservative default:\n *\n * ```ts\n * catch (error) {\n * if (isMissingTableError(error)) return 1; // benign: nothing to collide with\n * throw error; // caller reports the consequence\n * }\n * ```\n */\n\n// [#6615] The Postgres `\"x\" of relation \"y\"` phrase, owned once — see the\n// module docblock in `@objectstack/types` for the superstring hole it closes\n// and for why the exclusion's width deliberately differs from the extractor's.\nimport { isRelationSubObjectPhrase } from '@objectstack/types';\n\n/** One \"which errors mean X?\" vocabulary, in the three forms drivers use. */\ninterface DriverErrorSignature {\n /** `error.code` — Postgres SQLSTATE, or mysql2's symbolic name. */\n readonly codes: ReadonlySet<string>;\n /** `error.errno` — MySQL/MariaDB numeric equivalents. */\n readonly errnos: ReadonlySet<number>;\n /** `error.message` — the only signal SQLite-family drivers give. */\n readonly message: RegExp;\n /**\n * Optional **front-exclusion**, evaluated before any positive test (#6347).\n *\n * A message test can never exclude a *superstring*: once a legal phrase for\n * X appears inside a longer phrase that means NOT-X, no amount of widening\n * the X regex removes the match — the phrase really is in there. The only\n * repair is to recognise the not-X shape first and stop. So this is a\n * separate channel rather than another alternation in {@link message}.\n */\n readonly excludes?: {\n /** SQLSTATEs / driver codes that positively mean \"**not** this case\". */\n readonly codes: ReadonlySet<string>;\n /**\n * Message shapes that carry a legal match for this case as a substring.\n *\n * A predicate rather than a `RegExp` since #6615, so this channel can be\n * satisfied by a shared, named question from `@objectstack/types` instead\n * of a pattern this file owns alone. The phrase it tests is the same one\n * `@objectstack/rest` and `@objectstack/service-analytics` read.\n */\n readonly matchesMessage: (message: string) => boolean;\n };\n}\n\n/**\n * Driver/SQLSTATE codes that mean \"the thing you asked me to create is already\n * there\". Postgres reports SQLSTATE on `code`; mysql2 reports its symbolic name.\n */\nconst ALREADY_EXISTS: DriverErrorSignature = {\n codes: new Set([\n // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)\n '42P07', // duplicate_table\n '42701', // duplicate_column\n '42710', // duplicate_object — index / constraint already exists\n // MySQL / MariaDB (mysql2 puts the symbolic name on `code`)\n 'ER_TABLE_EXISTS_ERROR', // 1050\n 'ER_DUP_FIELDNAME', // 1060\n 'ER_DUP_KEYNAME', // 1061\n ]),\n errnos: new Set([1050, 1060, 1061]),\n /**\n * Message fallback for drivers that carry no machine-readable code —\n * notably SQLite, whose `code` is the undifferentiated `SQLITE_ERROR` for\n * every DDL failure, so the message is the only signal available:\n * - `table sys_metadata already exists`\n * - `duplicate column name: environment_id`\n * - `index idx_x already exists`\n * Postgres phrases its own as `relation \"x\" already exists` /\n * `column \"x\" of relation \"y\" already exists`, which matches the same test.\n */\n message: /already exists|duplicate column name|duplicate key name/i,\n};\n\n/**\n * Codes/messages that mean \"the table you tried to READ has not been created\".\n *\n * Narrower than it looks, on purpose. `does not exist` on its own also covers\n * `role \"x\" does not exist` (42704), `database \"x\" does not exist` (3D000) and\n * `column \"x\" does not exist` (42703) — every one of them a **real** failure\n * that must stay loud, and every one of them a case where \"start numbering at\n * 1\" would be the wrong answer against a table that may be full of rows. So the\n * message test demands the word table/relation next to the phrase rather than\n * the phrase alone, and the code set carries only the table-scoped SQLSTATEs.\n *\n * That was not enough on its own, and #6347 is why. Postgres has **two**\n * missing-column phrasings, one per direction:\n *\n * | path | phrase | SQLSTATE | matched the message test? |\n * |:---|:---|:---|:---|\n * | read (`SELECT`) | `column \"bogus\" does not exist` | 42703 | no |\n * | write (`INSERT`/`UPDATE`/`ALTER`) | `column \"label\" of relation \"sys_team\" does not exist` | 42703 | **yes** |\n *\n * The write-path phrase contains a complete, legal missing-table phrase —\n * `relation \"sys_team\" does not exist` — as a substring, so the table-scoped\n * test above matched it and answered *benign* about an error the docblock two\n * paragraphs up already named as one that must stay loud. The same holds for\n * every other sub-object of a relation Postgres phrases this way, e.g.\n * `constraint \"uq_x\" of relation \"sys_team\" does not exist` (42704). And\n * code-first does not rescue it: {@link matchesDriverError} is a sequential OR,\n * so a `code: '42703'` error simply falls past the two code lines and is\n * decided by the message.\n *\n * Hence {@link DriverErrorSignature.excludes}: the not-a-table shapes are\n * recognised FIRST, and recognition ends the question with `false`.\n */\nconst MISSING_TABLE: DriverErrorSignature = {\n codes: new Set([\n '42P01', // PostgreSQL undefined_table\n 'ER_NO_SUCH_TABLE', // MySQL / MariaDB 1146\n ]),\n errnos: new Set([1146]),\n /**\n * - SQLite / libsql: `no such table: sys_metadata_history`\n * - PostgreSQL: `relation \"sys_metadata_history\" does not exist`\n * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`\n */\n message:\n /no such table|relation [\"'`][^\"'`]+[\"'`] does not exist|table [\"'`][^\"'`]+[\"'`] doesn'?t exist|unknown table/i,\n excludes: {\n /**\n * Exactly the three SQLSTATEs the docblock above already names as\n * must-stay-loud neighbours of `does not exist`. They are listed here\n * rather than merely trusted to miss the message test, because two of\n * them (42703 columns, 42704 constraints/triggers) have a phrasing that\n * *does* hit it, and because a code is a fact where prose is a guess.\n *\n * Postgres-shaped on purpose: measured, neither MySQL\n * (`Unknown column 'label' in 'field list'`) nor SQLite\n * (`no such column: bogus`, `table t has no column named label`)\n * phrases a sub-object failure so that a missing-table phrase falls out\n * of it, so there is nothing there to exclude. Adding their codes would\n * be surface with no defect behind it.\n */\n codes: new Set([\n '42703', // undefined_column\n '42704', // undefined_object — constraint, trigger, role, type, …\n '3D000', // invalid_catalog_name — `database \"x\" does not exist`\n ]),\n /**\n * `«sub-object» \"x\" of relation \"y\" …` — Postgres' phrasing for a\n * failure about something *inside* a relation, which therefore says the\n * relation itself is present. The two in-repo siblings that carry this\n * phrase are `mapDataError` (`packages/rest`, #5352) and\n * `service-analytics`'s missing-column subtraction (#6035/PR #6346).\n *\n * [#6615] All three now read one home — `@objectstack/types` — instead\n * of three hand-kept copies, so the phrase can no longer be taught to\n * the repo a fourth time or drift in one package only. The **width**\n * difference that used to justify the copy is preserved and is the\n * reason the home exports two functions rather than one: those two\n * *extract* the column name to phrase a better error, so a miss costs a\n * vaguer message; this one *excludes*, so a miss restores the\n * corruption. {@link isRelationSubObjectPhrase} is therefore the wider\n * question — it drops their `column`/`[a-z0-9_]+`/`does not exist`\n * anchors: any sub-object, any quoted identifier, any verdict.\n * Over-matching here only ever converts a benign verdict into a loud\n * one, which is the direction this whole module already errs in.\n */\n matchesMessage: isRelationSubObjectPhrase,\n },\n};\n\n/** How far to follow an `error.cause` chain — drivers wrap, but not deeply. */\nconst MAX_CAUSE_DEPTH = 4;\n\n/**\n * The single matcher both predicates run on: exclusions, then code, then errno,\n * then message, then one step down the `cause` chain.\n *\n * Unrecognised is always `false` — a benign verdict must be *earned*, never\n * defaulted to, because a false \"benign\" corrupts data while a false \"real\"\n * costs one error line.\n *\n * The exclusion runs at every node and, when it fires, returns `false` **without\n * descending into `cause`** (#6347). Two reasons, both the conservative\n * direction: an error that positively identifies as \"a column of an existing\n * relation\" *is* that error, whatever it wraps; and stopping can only ever\n * subtract benign verdicts, never add one.\n */\nfunction matchesDriverError(\n error: unknown,\n signature: DriverErrorSignature,\n depth: number,\n): boolean {\n if (error === null || error === undefined || depth > MAX_CAUSE_DEPTH) return false;\n\n if (typeof error === 'string') {\n if (signature.excludes?.matchesMessage(error)) return false;\n return signature.message.test(error);\n }\n if (typeof error !== 'object') return false;\n\n const err = error as {\n code?: unknown;\n errno?: unknown;\n message?: unknown;\n cause?: unknown;\n };\n\n const excludes = signature.excludes;\n if (excludes) {\n if (typeof err.code === 'string' && excludes.codes.has(err.code)) return false;\n if (typeof err.message === 'string' && excludes.matchesMessage(err.message)) return false;\n }\n\n if (typeof err.code === 'string' && signature.codes.has(err.code)) return true;\n if (typeof err.errno === 'number' && signature.errnos.has(err.errno)) return true;\n if (typeof err.message === 'string' && signature.message.test(err.message)) return true;\n\n // Drivers commonly re-throw with the original attached as `cause`.\n return matchesDriverError(err.cause, signature, depth + 1);\n}\n\n/**\n * Is this DDL error the benign \"already provisioned\" case?\n *\n * @param error - The value thrown by `syncSchema()` (or any DDL call).\n * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.\n * @returns `true` only when the error positively identifies as\n * table/column/index-already-exists. Anything else — including an\n * unrecognised error, `undefined`, or a permission/connection failure —\n * returns `false` and MUST be reported loudly by the caller.\n */\nexport function isSchemaAlreadyExistsError(error: unknown, depth = 0): boolean {\n return matchesDriverError(error, ALREADY_EXISTS, depth);\n}\n\n/**\n * Is this READ error the benign \"table has not been provisioned yet\" case?\n *\n * The only failure that licenses a caller to treat an empty table as the truth\n * — there are no rows, so there is nothing to be inconsistent with. A\n * connection drop, a timeout, a permission denial or a query error all mean the\n * rows may well exist and simply were not seen; those return `false` and the\n * caller must report the consequence and give up rather than compute an answer\n * from data it never read (#4825).\n *\n * A failure about a **column** of a relation is never this case, in either of\n * Postgres' two phrasings — the relation is right there in the message because\n * it exists (#6347). See {@link MISSING_TABLE}'s `excludes`.\n *\n * @param error - The value thrown by a driver/engine read (`find`, `findOne`, …).\n * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.\n * @returns `true` only when the error positively identifies as\n * table/relation-does-not-exist.\n */\nexport function isMissingTableError(error: unknown, depth = 0): boolean {\n return matchesDriverError(error, MISSING_TABLE, depth);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACwFA,mBAA0C;AA+F1C,IAAM,gBAAsC;AAAA,EACxC,OAAO,oBAAI,IAAI;AAAA,IACX;AAAA;AAAA,IACA;AAAA;AAAA,EACJ,CAAC;AAAA,EACD,QAAQ,oBAAI,IAAI,CAAC,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMtB,SACI;AAAA,EACJ,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAeN,OAAO,oBAAI,IAAI;AAAA,MACX;AAAA;AAAA,MACA;AAAA;AAAA,MACA;AAAA;AAAA,IACJ,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAqBD,gBAAgB;AAAA,EACpB;AACJ;AAGA,IAAM,kBAAkB;AAgBxB,SAAS,mBACL,OACA,WACA,OACO;AACP,MAAI,UAAU,QAAQ,UAAU,UAAa,QAAQ,gBAAiB,QAAO;AAE7E,MAAI,OAAO,UAAU,UAAU;AAC3B,QAAI,UAAU,UAAU,eAAe,KAAK,EAAG,QAAO;AACtD,WAAO,UAAU,QAAQ,KAAK,KAAK;AAAA,EACvC;AACA,MAAI,OAAO,UAAU,SAAU,QAAO;AAEtC,QAAM,MAAM;AAOZ,QAAM,WAAW,UAAU;AAC3B,MAAI,UAAU;AACV,QAAI,OAAO,IAAI,SAAS,YAAY,SAAS,MAAM,IAAI,IAAI,IAAI,EAAG,QAAO;AACzE,QAAI,OAAO,IAAI,YAAY,YAAY,SAAS,eAAe,IAAI,OAAO,EAAG,QAAO;AAAA,EACxF;AAEA,MAAI,OAAO,IAAI,SAAS,YAAY,UAAU,MAAM,IAAI,IAAI,IAAI,EAAG,QAAO;AAC1E,MAAI,OAAO,IAAI,UAAU,YAAY,UAAU,OAAO,IAAI,IAAI,KAAK,EAAG,QAAO;AAC7E,MAAI,OAAO,IAAI,YAAY,YAAY,UAAU,QAAQ,KAAK,IAAI,OAAO,EAAG,QAAO;AAGnF,SAAO,mBAAmB,IAAI,OAAO,WAAW,QAAQ,CAAC;AAC7D;AAmCO,SAAS,oBAAoB,OAAgB,QAAQ,GAAY;AACpE,SAAO,mBAAmB,OAAO,eAAe,KAAK;AACzD;","names":[]}
package/dist/errors.d.cts CHANGED
@@ -8,6 +8,10 @@
8
8
  * caller must report the consequence and give up rather than compute an answer
9
9
  * from data it never read (#4825).
10
10
  *
11
+ * A failure about a **column** of a relation is never this case, in either of
12
+ * Postgres' two phrasings — the relation is right there in the message because
13
+ * it exists (#6347). See {@link MISSING_TABLE}'s `excludes`.
14
+ *
11
15
  * @param error - The value thrown by a driver/engine read (`find`, `findOne`, …).
12
16
  * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.
13
17
  * @returns `true` only when the error positively identifies as
package/dist/errors.d.ts CHANGED
@@ -8,6 +8,10 @@
8
8
  * caller must report the consequence and give up rather than compute an answer
9
9
  * from data it never read (#4825).
10
10
  *
11
+ * A failure about a **column** of a relation is never this case, in either of
12
+ * Postgres' two phrasings — the relation is right there in the message because
13
+ * it exists (#6347). See {@link MISSING_TABLE}'s `excludes`.
14
+ *
11
15
  * @param error - The value thrown by a driver/engine read (`find`, `findOne`, …).
12
16
  * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.
13
17
  * @returns `true` only when the error positively identifies as
package/dist/errors.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/utils/schema-sync-errors.ts
2
+ import { isRelationSubObjectPhrase } from "@objectstack/types";
2
3
  var MISSING_TABLE = {
3
4
  codes: /* @__PURE__ */ new Set([
4
5
  "42P01",
@@ -12,14 +13,67 @@ var MISSING_TABLE = {
12
13
  * - PostgreSQL: `relation "sys_metadata_history" does not exist`
13
14
  * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
14
15
  */
15
- message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i
16
+ message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i,
17
+ excludes: {
18
+ /**
19
+ * Exactly the three SQLSTATEs the docblock above already names as
20
+ * must-stay-loud neighbours of `does not exist`. They are listed here
21
+ * rather than merely trusted to miss the message test, because two of
22
+ * them (42703 columns, 42704 constraints/triggers) have a phrasing that
23
+ * *does* hit it, and because a code is a fact where prose is a guess.
24
+ *
25
+ * Postgres-shaped on purpose: measured, neither MySQL
26
+ * (`Unknown column 'label' in 'field list'`) nor SQLite
27
+ * (`no such column: bogus`, `table t has no column named label`)
28
+ * phrases a sub-object failure so that a missing-table phrase falls out
29
+ * of it, so there is nothing there to exclude. Adding their codes would
30
+ * be surface with no defect behind it.
31
+ */
32
+ codes: /* @__PURE__ */ new Set([
33
+ "42703",
34
+ // undefined_column
35
+ "42704",
36
+ // undefined_object — constraint, trigger, role, type, …
37
+ "3D000"
38
+ // invalid_catalog_name — `database "x" does not exist`
39
+ ]),
40
+ /**
41
+ * `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a
42
+ * failure about something *inside* a relation, which therefore says the
43
+ * relation itself is present. The two in-repo siblings that carry this
44
+ * phrase are `mapDataError` (`packages/rest`, #5352) and
45
+ * `service-analytics`'s missing-column subtraction (#6035/PR #6346).
46
+ *
47
+ * [#6615] All three now read one home — `@objectstack/types` — instead
48
+ * of three hand-kept copies, so the phrase can no longer be taught to
49
+ * the repo a fourth time or drift in one package only. The **width**
50
+ * difference that used to justify the copy is preserved and is the
51
+ * reason the home exports two functions rather than one: those two
52
+ * *extract* the column name to phrase a better error, so a miss costs a
53
+ * vaguer message; this one *excludes*, so a miss restores the
54
+ * corruption. {@link isRelationSubObjectPhrase} is therefore the wider
55
+ * question — it drops their `column`/`[a-z0-9_]+`/`does not exist`
56
+ * anchors: any sub-object, any quoted identifier, any verdict.
57
+ * Over-matching here only ever converts a benign verdict into a loud
58
+ * one, which is the direction this whole module already errs in.
59
+ */
60
+ matchesMessage: isRelationSubObjectPhrase
61
+ }
16
62
  };
17
63
  var MAX_CAUSE_DEPTH = 4;
18
64
  function matchesDriverError(error, signature, depth) {
19
65
  if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH) return false;
20
- if (typeof error === "string") return signature.message.test(error);
66
+ if (typeof error === "string") {
67
+ if (signature.excludes?.matchesMessage(error)) return false;
68
+ return signature.message.test(error);
69
+ }
21
70
  if (typeof error !== "object") return false;
22
71
  const err = error;
72
+ const excludes = signature.excludes;
73
+ if (excludes) {
74
+ if (typeof err.code === "string" && excludes.codes.has(err.code)) return false;
75
+ if (typeof err.message === "string" && excludes.matchesMessage(err.message)) return false;
76
+ }
23
77
  if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
24
78
  if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
25
79
  if (typeof err.message === "string" && signature.message.test(err.message)) return true;
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/utils/schema-sync-errors.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Driver-error classification for the metadata storage seams (#4728, #4825;\n * rule from #4632).\n *\n * Two questions live here, and they share one mechanism on purpose. A second\n * hand-rolled `catch`-and-guess elsewhere in this package would be a second\n * de-facto vocabulary of \"which driver errors are benign\" — the exact debt this\n * module exists to retire. Both predicates below are thin wrappers over one\n * signature matcher, so a driver quirk is taught to the package once.\n *\n * 1. {@link isSchemaAlreadyExistsError} — \"was this DDL failure just the table\n * already being there?\" (#4728, `ensureSchema` / `ensureHistorySchema`).\n * 2. {@link isMissingTableError} — \"did this READ fail because the table has\n * not been provisioned yet?\" (#4825, `nextEventSeq`).\n *\n * They are deliberately **not** each other's negation. Each answers \"is this\n * the one benign reason?\" and defaults to *not benign*, so an error neither\n * recognises is loud under both.\n *\n * ---\n *\n * ## 1. DDL failure classification (#4728)\n *\n * `IDataDriver.syncSchema()` is contractually **idempotent** (\"creates tables if\n * missing, adds columns, updates indexes\"), so in principle a re-sync of an\n * existing table should not throw at all. In practice a driver may surface the\n * already-provisioned case as an error instead of a no-op — `CREATE TABLE`\n * without `IF NOT EXISTS`, an `ALTER TABLE ADD COLUMN` for a column that is\n * already there. That single failure reason is benign: the table and its columns\n * exist, so the bytes will land.\n *\n * **Every other** DDL failure is not benign, and the difference is the whole\n * point of this module. Insufficient privileges, a datasource that never\n * connected, an incompatible column type — after those, the table or column does\n * not exist, yet the process keeps looking healthy while everything it claims to\n * persist has nowhere to land. That is the #4420 shape, and AGENTS.md →\n * \"Degradation log levels\" requires it to be reported at `error`.\n *\n * The defect this replaces was a `catch` whose comment named the benign reason\n * (\"e.g. table already exists\") and used it to excuse **all** of them. Callers\n * must therefore ask the question by error *type*:\n *\n * ```ts\n * catch (error) {\n * if (!isSchemaAlreadyExistsError(error)) {\n * console.error('… consequence … fix …', error); // loud, and stay not-ready\n * return;\n * }\n * // benign only: the table is already provisioned, carry on\n * }\n * ```\n *\n * Classification is deliberately conservative — anything not positively\n * recognised as \"already exists\" is treated as a real failure, because the cost\n * of a false \"benign\" (silent data loss) is far higher than the cost of a false\n * \"real\" (one extra error line).\n *\n * ---\n *\n * ## 2. Missing-table classification for reads (#4825)\n *\n * `DatabaseLoader.nextEventSeq()` reads `sys_metadata_history` to decide what\n * `event_seq` the NEXT history row gets. Its `catch` named both reasons a read\n * can fail — \"table not provisioned yet\" (benign: 1 really is the next number)\n * and \"driver error\" (**not** benign) — and answered both with `return 1`.\n *\n * That is the #4728 shape one layer down, but the damage is the opposite kind\n * and worse. #4728 was *bytes that never landed*; this is **bytes that land\n * wrong**: with N rows already in the table, one flaky read hands the next row\n * `event_seq = 1`, colliding with an existing row. The insert **succeeds**, no\n * line is logged, and `event_seq` — the ordering key that history listing and\n * rollback targeting both stand on — is now silently untrustworthy.\n *\n * So the read seam gets the same treatment, with the same conservative default:\n *\n * ```ts\n * catch (error) {\n * if (isMissingTableError(error)) return 1; // benign: nothing to collide with\n * throw error; // caller reports the consequence\n * }\n * ```\n */\n\n/** One \"which errors mean X?\" vocabulary, in the three forms drivers use. */\ninterface DriverErrorSignature {\n /** `error.code` — Postgres SQLSTATE, or mysql2's symbolic name. */\n readonly codes: ReadonlySet<string>;\n /** `error.errno` — MySQL/MariaDB numeric equivalents. */\n readonly errnos: ReadonlySet<number>;\n /** `error.message` — the only signal SQLite-family drivers give. */\n readonly message: RegExp;\n}\n\n/**\n * Driver/SQLSTATE codes that mean \"the thing you asked me to create is already\n * there\". Postgres reports SQLSTATE on `code`; mysql2 reports its symbolic name.\n */\nconst ALREADY_EXISTS: DriverErrorSignature = {\n codes: new Set([\n // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)\n '42P07', // duplicate_table\n '42701', // duplicate_column\n '42710', // duplicate_object — index / constraint already exists\n // MySQL / MariaDB (mysql2 puts the symbolic name on `code`)\n 'ER_TABLE_EXISTS_ERROR', // 1050\n 'ER_DUP_FIELDNAME', // 1060\n 'ER_DUP_KEYNAME', // 1061\n ]),\n errnos: new Set([1050, 1060, 1061]),\n /**\n * Message fallback for drivers that carry no machine-readable code —\n * notably SQLite, whose `code` is the undifferentiated `SQLITE_ERROR` for\n * every DDL failure, so the message is the only signal available:\n * - `table sys_metadata already exists`\n * - `duplicate column name: environment_id`\n * - `index idx_x already exists`\n * Postgres phrases its own as `relation \"x\" already exists` /\n * `column \"x\" of relation \"y\" already exists`, which matches the same test.\n */\n message: /already exists|duplicate column name|duplicate key name/i,\n};\n\n/**\n * Codes/messages that mean \"the table you tried to READ has not been created\".\n *\n * Narrower than it looks, on purpose. `does not exist` on its own also covers\n * `role \"x\" does not exist` (42704), `database \"x\" does not exist` (3D000) and\n * `column \"x\" does not exist` (42703) — every one of them a **real** failure\n * that must stay loud, and every one of them a case where \"start numbering at\n * 1\" would be the wrong answer against a table that may be full of rows. So the\n * message test demands the word table/relation next to the phrase rather than\n * the phrase alone, and the code set carries only the table-scoped SQLSTATEs.\n */\nconst MISSING_TABLE: DriverErrorSignature = {\n codes: new Set([\n '42P01', // PostgreSQL undefined_table\n 'ER_NO_SUCH_TABLE', // MySQL / MariaDB 1146\n ]),\n errnos: new Set([1146]),\n /**\n * - SQLite / libsql: `no such table: sys_metadata_history`\n * - PostgreSQL: `relation \"sys_metadata_history\" does not exist`\n * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`\n */\n message:\n /no such table|relation [\"'`][^\"'`]+[\"'`] does not exist|table [\"'`][^\"'`]+[\"'`] doesn'?t exist|unknown table/i,\n};\n\n/** How far to follow an `error.cause` chain — drivers wrap, but not deeply. */\nconst MAX_CAUSE_DEPTH = 4;\n\n/**\n * The single matcher both predicates run on: code, then errno, then message,\n * then one step down the `cause` chain.\n *\n * Unrecognised is always `false` — a benign verdict must be *earned*, never\n * defaulted to, because a false \"benign\" corrupts data while a false \"real\"\n * costs one error line.\n */\nfunction matchesDriverError(\n error: unknown,\n signature: DriverErrorSignature,\n depth: number,\n): boolean {\n if (error === null || error === undefined || depth > MAX_CAUSE_DEPTH) return false;\n\n if (typeof error === 'string') return signature.message.test(error);\n if (typeof error !== 'object') return false;\n\n const err = error as {\n code?: unknown;\n errno?: unknown;\n message?: unknown;\n cause?: unknown;\n };\n\n if (typeof err.code === 'string' && signature.codes.has(err.code)) return true;\n if (typeof err.errno === 'number' && signature.errnos.has(err.errno)) return true;\n if (typeof err.message === 'string' && signature.message.test(err.message)) return true;\n\n // Drivers commonly re-throw with the original attached as `cause`.\n return matchesDriverError(err.cause, signature, depth + 1);\n}\n\n/**\n * Is this DDL error the benign \"already provisioned\" case?\n *\n * @param error - The value thrown by `syncSchema()` (or any DDL call).\n * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.\n * @returns `true` only when the error positively identifies as\n * table/column/index-already-exists. Anything else — including an\n * unrecognised error, `undefined`, or a permission/connection failure —\n * returns `false` and MUST be reported loudly by the caller.\n */\nexport function isSchemaAlreadyExistsError(error: unknown, depth = 0): boolean {\n return matchesDriverError(error, ALREADY_EXISTS, depth);\n}\n\n/**\n * Is this READ error the benign \"table has not been provisioned yet\" case?\n *\n * The only failure that licenses a caller to treat an empty table as the truth\n * — there are no rows, so there is nothing to be inconsistent with. A\n * connection drop, a timeout, a permission denial or a query error all mean the\n * rows may well exist and simply were not seen; those return `false` and the\n * caller must report the consequence and give up rather than compute an answer\n * from data it never read (#4825).\n *\n * @param error - The value thrown by a driver/engine read (`find`, `findOne`, …).\n * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.\n * @returns `true` only when the error positively identifies as\n * table/relation-does-not-exist.\n */\nexport function isMissingTableError(error: unknown, depth = 0): boolean {\n return matchesDriverError(error, MISSING_TABLE, depth);\n}\n"],"mappings":";AAuIA,IAAM,gBAAsC;AAAA,EACxC,OAAO,oBAAI,IAAI;AAAA,IACX;AAAA;AAAA,IACA;AAAA;AAAA,EACJ,CAAC;AAAA,EACD,QAAQ,oBAAI,IAAI,CAAC,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMtB,SACI;AACR;AAGA,IAAM,kBAAkB;AAUxB,SAAS,mBACL,OACA,WACA,OACO;AACP,MAAI,UAAU,QAAQ,UAAU,UAAa,QAAQ,gBAAiB,QAAO;AAE7E,MAAI,OAAO,UAAU,SAAU,QAAO,UAAU,QAAQ,KAAK,KAAK;AAClE,MAAI,OAAO,UAAU,SAAU,QAAO;AAEtC,QAAM,MAAM;AAOZ,MAAI,OAAO,IAAI,SAAS,YAAY,UAAU,MAAM,IAAI,IAAI,IAAI,EAAG,QAAO;AAC1E,MAAI,OAAO,IAAI,UAAU,YAAY,UAAU,OAAO,IAAI,IAAI,KAAK,EAAG,QAAO;AAC7E,MAAI,OAAO,IAAI,YAAY,YAAY,UAAU,QAAQ,KAAK,IAAI,OAAO,EAAG,QAAO;AAGnF,SAAO,mBAAmB,IAAI,OAAO,WAAW,QAAQ,CAAC;AAC7D;AA+BO,SAAS,oBAAoB,OAAgB,QAAQ,GAAY;AACpE,SAAO,mBAAmB,OAAO,eAAe,KAAK;AACzD;","names":[]}
1
+ {"version":3,"sources":["../src/utils/schema-sync-errors.ts"],"sourcesContent":["// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.\n\n/**\n * Driver-error classification for the metadata storage seams (#4728, #4825;\n * rule from #4632).\n *\n * Two questions live here, and they share one mechanism on purpose. A second\n * hand-rolled `catch`-and-guess elsewhere in this package would be a second\n * de-facto vocabulary of \"which driver errors are benign\" — the exact debt this\n * module exists to retire. Both predicates below are thin wrappers over one\n * signature matcher, so a driver quirk is taught to the package once.\n *\n * 1. {@link isSchemaAlreadyExistsError} — \"was this DDL failure just the table\n * already being there?\" (#4728, `ensureSchema` / `ensureHistorySchema`).\n * 2. {@link isMissingTableError} — \"did this READ fail because the table has\n * not been provisioned yet?\" (#4825, `nextEventSeq`).\n *\n * They are deliberately **not** each other's negation. Each answers \"is this\n * the one benign reason?\" and defaults to *not benign*, so an error neither\n * recognises is loud under both.\n *\n * ---\n *\n * ## 1. DDL failure classification (#4728)\n *\n * `IDataDriver.syncSchema()` is contractually **idempotent** (\"creates tables if\n * missing, adds columns, updates indexes\"), so in principle a re-sync of an\n * existing table should not throw at all. In practice a driver may surface the\n * already-provisioned case as an error instead of a no-op — `CREATE TABLE`\n * without `IF NOT EXISTS`, an `ALTER TABLE ADD COLUMN` for a column that is\n * already there. That single failure reason is benign: the table and its columns\n * exist, so the bytes will land.\n *\n * **Every other** DDL failure is not benign, and the difference is the whole\n * point of this module. Insufficient privileges, a datasource that never\n * connected, an incompatible column type — after those, the table or column does\n * not exist, yet the process keeps looking healthy while everything it claims to\n * persist has nowhere to land. That is the #4420 shape, and AGENTS.md →\n * \"Degradation log levels\" requires it to be reported at `error`.\n *\n * The defect this replaces was a `catch` whose comment named the benign reason\n * (\"e.g. table already exists\") and used it to excuse **all** of them. Callers\n * must therefore ask the question by error *type*:\n *\n * ```ts\n * catch (error) {\n * if (!isSchemaAlreadyExistsError(error)) {\n * console.error('… consequence … fix …', error); // loud, and stay not-ready\n * return;\n * }\n * // benign only: the table is already provisioned, carry on\n * }\n * ```\n *\n * Classification is deliberately conservative — anything not positively\n * recognised as \"already exists\" is treated as a real failure, because the cost\n * of a false \"benign\" (silent data loss) is far higher than the cost of a false\n * \"real\" (one extra error line).\n *\n * ---\n *\n * ## 2. Missing-table classification for reads (#4825)\n *\n * `DatabaseLoader.nextEventSeq()` reads `sys_metadata_history` to decide what\n * `event_seq` the NEXT history row gets. Its `catch` named both reasons a read\n * can fail — \"table not provisioned yet\" (benign: 1 really is the next number)\n * and \"driver error\" (**not** benign) — and answered both with `return 1`.\n *\n * That is the #4728 shape one layer down, but the damage is the opposite kind\n * and worse. #4728 was *bytes that never landed*; this is **bytes that land\n * wrong**: with N rows already in the table, one flaky read hands the next row\n * `event_seq = 1`, colliding with an existing row. The insert **succeeds**, no\n * line is logged, and `event_seq` — the ordering key that history listing and\n * rollback targeting both stand on — is now silently untrustworthy.\n *\n * So the read seam gets the same treatment, with the same conservative default:\n *\n * ```ts\n * catch (error) {\n * if (isMissingTableError(error)) return 1; // benign: nothing to collide with\n * throw error; // caller reports the consequence\n * }\n * ```\n */\n\n// [#6615] The Postgres `\"x\" of relation \"y\"` phrase, owned once — see the\n// module docblock in `@objectstack/types` for the superstring hole it closes\n// and for why the exclusion's width deliberately differs from the extractor's.\nimport { isRelationSubObjectPhrase } from '@objectstack/types';\n\n/** One \"which errors mean X?\" vocabulary, in the three forms drivers use. */\ninterface DriverErrorSignature {\n /** `error.code` — Postgres SQLSTATE, or mysql2's symbolic name. */\n readonly codes: ReadonlySet<string>;\n /** `error.errno` — MySQL/MariaDB numeric equivalents. */\n readonly errnos: ReadonlySet<number>;\n /** `error.message` — the only signal SQLite-family drivers give. */\n readonly message: RegExp;\n /**\n * Optional **front-exclusion**, evaluated before any positive test (#6347).\n *\n * A message test can never exclude a *superstring*: once a legal phrase for\n * X appears inside a longer phrase that means NOT-X, no amount of widening\n * the X regex removes the match — the phrase really is in there. The only\n * repair is to recognise the not-X shape first and stop. So this is a\n * separate channel rather than another alternation in {@link message}.\n */\n readonly excludes?: {\n /** SQLSTATEs / driver codes that positively mean \"**not** this case\". */\n readonly codes: ReadonlySet<string>;\n /**\n * Message shapes that carry a legal match for this case as a substring.\n *\n * A predicate rather than a `RegExp` since #6615, so this channel can be\n * satisfied by a shared, named question from `@objectstack/types` instead\n * of a pattern this file owns alone. The phrase it tests is the same one\n * `@objectstack/rest` and `@objectstack/service-analytics` read.\n */\n readonly matchesMessage: (message: string) => boolean;\n };\n}\n\n/**\n * Driver/SQLSTATE codes that mean \"the thing you asked me to create is already\n * there\". Postgres reports SQLSTATE on `code`; mysql2 reports its symbolic name.\n */\nconst ALREADY_EXISTS: DriverErrorSignature = {\n codes: new Set([\n // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)\n '42P07', // duplicate_table\n '42701', // duplicate_column\n '42710', // duplicate_object — index / constraint already exists\n // MySQL / MariaDB (mysql2 puts the symbolic name on `code`)\n 'ER_TABLE_EXISTS_ERROR', // 1050\n 'ER_DUP_FIELDNAME', // 1060\n 'ER_DUP_KEYNAME', // 1061\n ]),\n errnos: new Set([1050, 1060, 1061]),\n /**\n * Message fallback for drivers that carry no machine-readable code —\n * notably SQLite, whose `code` is the undifferentiated `SQLITE_ERROR` for\n * every DDL failure, so the message is the only signal available:\n * - `table sys_metadata already exists`\n * - `duplicate column name: environment_id`\n * - `index idx_x already exists`\n * Postgres phrases its own as `relation \"x\" already exists` /\n * `column \"x\" of relation \"y\" already exists`, which matches the same test.\n */\n message: /already exists|duplicate column name|duplicate key name/i,\n};\n\n/**\n * Codes/messages that mean \"the table you tried to READ has not been created\".\n *\n * Narrower than it looks, on purpose. `does not exist` on its own also covers\n * `role \"x\" does not exist` (42704), `database \"x\" does not exist` (3D000) and\n * `column \"x\" does not exist` (42703) — every one of them a **real** failure\n * that must stay loud, and every one of them a case where \"start numbering at\n * 1\" would be the wrong answer against a table that may be full of rows. So the\n * message test demands the word table/relation next to the phrase rather than\n * the phrase alone, and the code set carries only the table-scoped SQLSTATEs.\n *\n * That was not enough on its own, and #6347 is why. Postgres has **two**\n * missing-column phrasings, one per direction:\n *\n * | path | phrase | SQLSTATE | matched the message test? |\n * |:---|:---|:---|:---|\n * | read (`SELECT`) | `column \"bogus\" does not exist` | 42703 | no |\n * | write (`INSERT`/`UPDATE`/`ALTER`) | `column \"label\" of relation \"sys_team\" does not exist` | 42703 | **yes** |\n *\n * The write-path phrase contains a complete, legal missing-table phrase —\n * `relation \"sys_team\" does not exist` — as a substring, so the table-scoped\n * test above matched it and answered *benign* about an error the docblock two\n * paragraphs up already named as one that must stay loud. The same holds for\n * every other sub-object of a relation Postgres phrases this way, e.g.\n * `constraint \"uq_x\" of relation \"sys_team\" does not exist` (42704). And\n * code-first does not rescue it: {@link matchesDriverError} is a sequential OR,\n * so a `code: '42703'` error simply falls past the two code lines and is\n * decided by the message.\n *\n * Hence {@link DriverErrorSignature.excludes}: the not-a-table shapes are\n * recognised FIRST, and recognition ends the question with `false`.\n */\nconst MISSING_TABLE: DriverErrorSignature = {\n codes: new Set([\n '42P01', // PostgreSQL undefined_table\n 'ER_NO_SUCH_TABLE', // MySQL / MariaDB 1146\n ]),\n errnos: new Set([1146]),\n /**\n * - SQLite / libsql: `no such table: sys_metadata_history`\n * - PostgreSQL: `relation \"sys_metadata_history\" does not exist`\n * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`\n */\n message:\n /no such table|relation [\"'`][^\"'`]+[\"'`] does not exist|table [\"'`][^\"'`]+[\"'`] doesn'?t exist|unknown table/i,\n excludes: {\n /**\n * Exactly the three SQLSTATEs the docblock above already names as\n * must-stay-loud neighbours of `does not exist`. They are listed here\n * rather than merely trusted to miss the message test, because two of\n * them (42703 columns, 42704 constraints/triggers) have a phrasing that\n * *does* hit it, and because a code is a fact where prose is a guess.\n *\n * Postgres-shaped on purpose: measured, neither MySQL\n * (`Unknown column 'label' in 'field list'`) nor SQLite\n * (`no such column: bogus`, `table t has no column named label`)\n * phrases a sub-object failure so that a missing-table phrase falls out\n * of it, so there is nothing there to exclude. Adding their codes would\n * be surface with no defect behind it.\n */\n codes: new Set([\n '42703', // undefined_column\n '42704', // undefined_object — constraint, trigger, role, type, …\n '3D000', // invalid_catalog_name — `database \"x\" does not exist`\n ]),\n /**\n * `«sub-object» \"x\" of relation \"y\" …` — Postgres' phrasing for a\n * failure about something *inside* a relation, which therefore says the\n * relation itself is present. The two in-repo siblings that carry this\n * phrase are `mapDataError` (`packages/rest`, #5352) and\n * `service-analytics`'s missing-column subtraction (#6035/PR #6346).\n *\n * [#6615] All three now read one home — `@objectstack/types` — instead\n * of three hand-kept copies, so the phrase can no longer be taught to\n * the repo a fourth time or drift in one package only. The **width**\n * difference that used to justify the copy is preserved and is the\n * reason the home exports two functions rather than one: those two\n * *extract* the column name to phrase a better error, so a miss costs a\n * vaguer message; this one *excludes*, so a miss restores the\n * corruption. {@link isRelationSubObjectPhrase} is therefore the wider\n * question — it drops their `column`/`[a-z0-9_]+`/`does not exist`\n * anchors: any sub-object, any quoted identifier, any verdict.\n * Over-matching here only ever converts a benign verdict into a loud\n * one, which is the direction this whole module already errs in.\n */\n matchesMessage: isRelationSubObjectPhrase,\n },\n};\n\n/** How far to follow an `error.cause` chain — drivers wrap, but not deeply. */\nconst MAX_CAUSE_DEPTH = 4;\n\n/**\n * The single matcher both predicates run on: exclusions, then code, then errno,\n * then message, then one step down the `cause` chain.\n *\n * Unrecognised is always `false` — a benign verdict must be *earned*, never\n * defaulted to, because a false \"benign\" corrupts data while a false \"real\"\n * costs one error line.\n *\n * The exclusion runs at every node and, when it fires, returns `false` **without\n * descending into `cause`** (#6347). Two reasons, both the conservative\n * direction: an error that positively identifies as \"a column of an existing\n * relation\" *is* that error, whatever it wraps; and stopping can only ever\n * subtract benign verdicts, never add one.\n */\nfunction matchesDriverError(\n error: unknown,\n signature: DriverErrorSignature,\n depth: number,\n): boolean {\n if (error === null || error === undefined || depth > MAX_CAUSE_DEPTH) return false;\n\n if (typeof error === 'string') {\n if (signature.excludes?.matchesMessage(error)) return false;\n return signature.message.test(error);\n }\n if (typeof error !== 'object') return false;\n\n const err = error as {\n code?: unknown;\n errno?: unknown;\n message?: unknown;\n cause?: unknown;\n };\n\n const excludes = signature.excludes;\n if (excludes) {\n if (typeof err.code === 'string' && excludes.codes.has(err.code)) return false;\n if (typeof err.message === 'string' && excludes.matchesMessage(err.message)) return false;\n }\n\n if (typeof err.code === 'string' && signature.codes.has(err.code)) return true;\n if (typeof err.errno === 'number' && signature.errnos.has(err.errno)) return true;\n if (typeof err.message === 'string' && signature.message.test(err.message)) return true;\n\n // Drivers commonly re-throw with the original attached as `cause`.\n return matchesDriverError(err.cause, signature, depth + 1);\n}\n\n/**\n * Is this DDL error the benign \"already provisioned\" case?\n *\n * @param error - The value thrown by `syncSchema()` (or any DDL call).\n * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.\n * @returns `true` only when the error positively identifies as\n * table/column/index-already-exists. Anything else — including an\n * unrecognised error, `undefined`, or a permission/connection failure —\n * returns `false` and MUST be reported loudly by the caller.\n */\nexport function isSchemaAlreadyExistsError(error: unknown, depth = 0): boolean {\n return matchesDriverError(error, ALREADY_EXISTS, depth);\n}\n\n/**\n * Is this READ error the benign \"table has not been provisioned yet\" case?\n *\n * The only failure that licenses a caller to treat an empty table as the truth\n * — there are no rows, so there is nothing to be inconsistent with. A\n * connection drop, a timeout, a permission denial or a query error all mean the\n * rows may well exist and simply were not seen; those return `false` and the\n * caller must report the consequence and give up rather than compute an answer\n * from data it never read (#4825).\n *\n * A failure about a **column** of a relation is never this case, in either of\n * Postgres' two phrasings — the relation is right there in the message because\n * it exists (#6347). See {@link MISSING_TABLE}'s `excludes`.\n *\n * @param error - The value thrown by a driver/engine read (`find`, `findOne`, …).\n * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.\n * @returns `true` only when the error positively identifies as\n * table/relation-does-not-exist.\n */\nexport function isMissingTableError(error: unknown, depth = 0): boolean {\n return matchesDriverError(error, MISSING_TABLE, depth);\n}\n"],"mappings":";AAwFA,SAAS,iCAAiC;AA+F1C,IAAM,gBAAsC;AAAA,EACxC,OAAO,oBAAI,IAAI;AAAA,IACX;AAAA;AAAA,IACA;AAAA;AAAA,EACJ,CAAC;AAAA,EACD,QAAQ,oBAAI,IAAI,CAAC,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMtB,SACI;AAAA,EACJ,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAeN,OAAO,oBAAI,IAAI;AAAA,MACX;AAAA;AAAA,MACA;AAAA;AAAA,MACA;AAAA;AAAA,IACJ,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAqBD,gBAAgB;AAAA,EACpB;AACJ;AAGA,IAAM,kBAAkB;AAgBxB,SAAS,mBACL,OACA,WACA,OACO;AACP,MAAI,UAAU,QAAQ,UAAU,UAAa,QAAQ,gBAAiB,QAAO;AAE7E,MAAI,OAAO,UAAU,UAAU;AAC3B,QAAI,UAAU,UAAU,eAAe,KAAK,EAAG,QAAO;AACtD,WAAO,UAAU,QAAQ,KAAK,KAAK;AAAA,EACvC;AACA,MAAI,OAAO,UAAU,SAAU,QAAO;AAEtC,QAAM,MAAM;AAOZ,QAAM,WAAW,UAAU;AAC3B,MAAI,UAAU;AACV,QAAI,OAAO,IAAI,SAAS,YAAY,SAAS,MAAM,IAAI,IAAI,IAAI,EAAG,QAAO;AACzE,QAAI,OAAO,IAAI,YAAY,YAAY,SAAS,eAAe,IAAI,OAAO,EAAG,QAAO;AAAA,EACxF;AAEA,MAAI,OAAO,IAAI,SAAS,YAAY,UAAU,MAAM,IAAI,IAAI,IAAI,EAAG,QAAO;AAC1E,MAAI,OAAO,IAAI,UAAU,YAAY,UAAU,OAAO,IAAI,IAAI,KAAK,EAAG,QAAO;AAC7E,MAAI,OAAO,IAAI,YAAY,YAAY,UAAU,QAAQ,KAAK,IAAI,OAAO,EAAG,QAAO;AAGnF,SAAO,mBAAmB,IAAI,OAAO,WAAW,QAAQ,CAAC;AAC7D;AAmCO,SAAS,oBAAoB,OAAgB,QAAQ,GAAY;AACpE,SAAO,mBAAmB,OAAO,eAAe,KAAK;AACzD;","names":[]}
package/dist/index.cjs CHANGED
@@ -553,6 +553,7 @@ var LRUCache = class {
553
553
  };
554
554
 
555
555
  // src/utils/schema-sync-errors.ts
556
+ var import_types = require("@objectstack/types");
556
557
  var ALREADY_EXISTS = {
557
558
  codes: /* @__PURE__ */ new Set([
558
559
  // PostgreSQL SQLSTATE (class 42 — syntax error or access rule violation)
@@ -596,14 +597,67 @@ var MISSING_TABLE = {
596
597
  * - PostgreSQL: `relation "sys_metadata_history" does not exist`
597
598
  * - MySQL/MariaDB: `Table 'app.sys_metadata_history' doesn't exist`
598
599
  */
599
- message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i
600
+ message: /no such table|relation ["'`][^"'`]+["'`] does not exist|table ["'`][^"'`]+["'`] doesn'?t exist|unknown table/i,
601
+ excludes: {
602
+ /**
603
+ * Exactly the three SQLSTATEs the docblock above already names as
604
+ * must-stay-loud neighbours of `does not exist`. They are listed here
605
+ * rather than merely trusted to miss the message test, because two of
606
+ * them (42703 columns, 42704 constraints/triggers) have a phrasing that
607
+ * *does* hit it, and because a code is a fact where prose is a guess.
608
+ *
609
+ * Postgres-shaped on purpose: measured, neither MySQL
610
+ * (`Unknown column 'label' in 'field list'`) nor SQLite
611
+ * (`no such column: bogus`, `table t has no column named label`)
612
+ * phrases a sub-object failure so that a missing-table phrase falls out
613
+ * of it, so there is nothing there to exclude. Adding their codes would
614
+ * be surface with no defect behind it.
615
+ */
616
+ codes: /* @__PURE__ */ new Set([
617
+ "42703",
618
+ // undefined_column
619
+ "42704",
620
+ // undefined_object — constraint, trigger, role, type, …
621
+ "3D000"
622
+ // invalid_catalog_name — `database "x" does not exist`
623
+ ]),
624
+ /**
625
+ * `«sub-object» "x" of relation "y" …` — Postgres' phrasing for a
626
+ * failure about something *inside* a relation, which therefore says the
627
+ * relation itself is present. The two in-repo siblings that carry this
628
+ * phrase are `mapDataError` (`packages/rest`, #5352) and
629
+ * `service-analytics`'s missing-column subtraction (#6035/PR #6346).
630
+ *
631
+ * [#6615] All three now read one home — `@objectstack/types` — instead
632
+ * of three hand-kept copies, so the phrase can no longer be taught to
633
+ * the repo a fourth time or drift in one package only. The **width**
634
+ * difference that used to justify the copy is preserved and is the
635
+ * reason the home exports two functions rather than one: those two
636
+ * *extract* the column name to phrase a better error, so a miss costs a
637
+ * vaguer message; this one *excludes*, so a miss restores the
638
+ * corruption. {@link isRelationSubObjectPhrase} is therefore the wider
639
+ * question — it drops their `column`/`[a-z0-9_]+`/`does not exist`
640
+ * anchors: any sub-object, any quoted identifier, any verdict.
641
+ * Over-matching here only ever converts a benign verdict into a loud
642
+ * one, which is the direction this whole module already errs in.
643
+ */
644
+ matchesMessage: import_types.isRelationSubObjectPhrase
645
+ }
600
646
  };
601
647
  var MAX_CAUSE_DEPTH = 4;
602
648
  function matchesDriverError(error, signature, depth) {
603
649
  if (error === null || error === void 0 || depth > MAX_CAUSE_DEPTH) return false;
604
- if (typeof error === "string") return signature.message.test(error);
650
+ if (typeof error === "string") {
651
+ if (signature.excludes?.matchesMessage(error)) return false;
652
+ return signature.message.test(error);
653
+ }
605
654
  if (typeof error !== "object") return false;
606
655
  const err = error;
656
+ const excludes = signature.excludes;
657
+ if (excludes) {
658
+ if (typeof err.code === "string" && excludes.codes.has(err.code)) return false;
659
+ if (typeof err.message === "string" && excludes.matchesMessage(err.message)) return false;
660
+ }
607
661
  if (typeof err.code === "string" && signature.codes.has(err.code)) return true;
608
662
  if (typeof err.errno === "number" && signature.errnos.has(err.errno)) return true;
609
663
  if (typeof err.message === "string" && signature.message.test(err.message)) return true;
@@ -616,48 +670,6 @@ function isMissingTableError(error, depth = 0) {
616
670
  return matchesDriverError(error, MISSING_TABLE, depth);
617
671
  }
618
672
 
619
- // src/migrations/add-sys-metadata-overlay-index.ts
620
- var INDEX_NAME = "idx_sys_metadata_overlay_active";
621
- var TABLE = "sys_metadata";
622
- var COLUMNS = "(type, name, organization_id, environment_id, scope)";
623
- var WHERE = "state = 'active'";
624
- async function addSysMetadataOverlayIndex(driver) {
625
- const driverAny = driver;
626
- const exec = async (sql) => {
627
- if (typeof driverAny.raw === "function") {
628
- await driverAny.raw(sql);
629
- } else if (typeof driverAny.execute === "function") {
630
- await driverAny.execute(sql);
631
- } else {
632
- throw new Error("driver has neither raw nor execute");
633
- }
634
- };
635
- const partialSql = `CREATE UNIQUE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS} WHERE ${WHERE}`;
636
- const fallbackSql = `CREATE INDEX IF NOT EXISTS ${INDEX_NAME} ON ${TABLE} ${COLUMNS}`;
637
- try {
638
- await exec(partialSql);
639
- return { index: INDEX_NAME, status: "created" };
640
- } catch (err) {
641
- const msg = err instanceof Error ? err.message : String(err);
642
- if (/partial|where clause|syntax/i.test(msg)) {
643
- try {
644
- await exec(fallbackSql);
645
- return { index: INDEX_NAME, status: "fallback_non_unique" };
646
- } catch (fallbackErr) {
647
- return {
648
- index: INDEX_NAME,
649
- status: "error",
650
- error: fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)
651
- };
652
- }
653
- }
654
- if (/already exists/i.test(msg)) {
655
- return { index: INDEX_NAME, status: "already_exists" };
656
- }
657
- return { index: INDEX_NAME, status: "error", error: msg };
658
- }
659
- }
660
-
661
673
  // src/migrations/migrate-project-id-to-environment-id.ts
662
674
  var AFFECTED_TABLES = [
663
675
  "sys_metadata",
@@ -807,23 +819,40 @@ var DatabaseLoader = class {
807
819
  // ==========================================
808
820
  // Internal CRUD helpers (driver vs engine)
809
821
  // ==========================================
822
+ // NOTE (#6231, closed out by #7178): BOTH branches below now take `query`
823
+ // unchanged and uncast. `DriverQuery` is `Omit<QueryAST, 'object'>`, so the
824
+ // object name travels as argument one only — that was always enough for the
825
+ // driver branch. The ENGINE branch used to carry `as any`, for one reason:
826
+ // `EngineQueryOptionsSchema.search` admitted only the structured
827
+ // `FullTextSearchSchema`, while `QueryAST.search` (hence `DriverQuery`) also
828
+ // admits the bare query string that ADR-0061 D1 calls the canonical Tier-1
829
+ // spelling and that the engine actually serves, so `DriverQuery` was not
830
+ // assignable to `EngineQueryOptionsParsed`. #7178 aligned the two schemas;
831
+ // the casts are now genuinely vestigial and are gone, which restores real
832
+ // `where`/`orderBy`/`fields` checking on the metadata main read path — this
833
+ // schema is not `.strict()`, so an unknown key here is SILENTLY DROPPED
834
+ // (`check:query-options-erasure`'s own rationale) and the erased type was
835
+ // the only thing standing between a typo and that silence.
836
+ //
837
+ // If a future edit makes one of these stop compiling, the honest fix is to
838
+ // reconcile the two schemas again — not to reinstate the cast.
810
839
  async _find(table, query) {
811
840
  if (this.engine) {
812
841
  return this.engine.find(table, query);
813
842
  }
814
- return this.driver.find(table, { object: table, ...query });
843
+ return this.driver.find(table, query);
815
844
  }
816
845
  async _findOne(table, query) {
817
846
  if (this.engine) {
818
847
  return this.engine.findOne(table, query);
819
848
  }
820
- return this.driver.findOne(table, { object: table, ...query });
849
+ return this.driver.findOne(table, query);
821
850
  }
822
851
  async _count(table, query) {
823
852
  if (this.engine) {
824
853
  return this.engine.count(table, query);
825
854
  }
826
- return this.driver.count(table, { object: table, ...query });
855
+ return this.driver.count(table, query);
827
856
  }
828
857
  async _create(table, data) {
829
858
  if (this.engine) {
@@ -905,9 +934,12 @@ var DatabaseLoader = class {
905
934
  }
906
935
  if (driver) {
907
936
  await migrateProjectIdToEnvironmentId(driver).catch(() => void 0);
908
- await addSysMetadataOverlayIndex(driver);
909
937
  }
910
- } catch {
938
+ } catch (error) {
939
+ console.warn(
940
+ `[Metadata] Could not resolve a raw-SQL driver from the engine for \`${this.tableName}\` \u2014 the project_id\u2192environment_id forward migration was SKIPPED. Legacy rows (if any) keep the pre-v5.0 column and read back as unset. Metadata reads and writes are otherwise unaffected. Re-run it explicitly with \`migrateProjectIdToEnvironmentId(driver)\` from \`@objectstack/metadata/migrations\` once the datasource is reachable.`,
941
+ error
942
+ );
911
943
  }
912
944
  return;
913
945
  }
@@ -939,10 +971,6 @@ var DatabaseLoader = class {
939
971
  await migrateProjectIdToEnvironmentId(this.driver);
940
972
  } catch {
941
973
  }
942
- try {
943
- await addSysMetadataOverlayIndex(this.driver);
944
- } catch {
945
- }
946
974
  }
947
975
  /**
948
976
  * Ensure the history table exists.
@@ -1550,6 +1578,60 @@ function generateId() {
1550
1578
 
1551
1579
  // src/endpoint-matcher.ts
1552
1580
  var import_api = require("@objectstack/spec/api");
1581
+
1582
+ // src/stored-envelope.ts
1583
+ var STORED_ENVELOPE_KEYS = Object.freeze([
1584
+ "package",
1585
+ "packageId",
1586
+ "publishedAt",
1587
+ "publishedBy",
1588
+ "publishedDefinition",
1589
+ "state",
1590
+ "version"
1591
+ ]);
1592
+ var STORED_BODY_KEY = "metadata";
1593
+ var ENVELOPE_KEYS = /* @__PURE__ */ new Set([...STORED_ENVELOPE_KEYS, STORED_BODY_KEY]);
1594
+ var EMPTY_ENVELOPE = Object.freeze({});
1595
+ function peelStoredEnvelope(item) {
1596
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
1597
+ return { envelope: EMPTY_ENVELOPE, body: item, wrapped: false };
1598
+ }
1599
+ const row = item;
1600
+ const wrappedBody = row[STORED_BODY_KEY];
1601
+ if (wrappedBody !== void 0 && wrappedBody !== null) {
1602
+ const envelope2 = {};
1603
+ for (const key of Object.keys(row)) {
1604
+ if (key === STORED_BODY_KEY) continue;
1605
+ envelope2[key] = row[key];
1606
+ }
1607
+ return { envelope: Object.freeze(envelope2), body: wrappedBody, wrapped: true };
1608
+ }
1609
+ let envelope;
1610
+ for (const key of Object.keys(row)) {
1611
+ if (!ENVELOPE_KEYS.has(key)) continue;
1612
+ envelope ?? (envelope = {});
1613
+ envelope[key] = row[key];
1614
+ }
1615
+ if (!envelope) return { envelope: EMPTY_ENVELOPE, body: row, wrapped: false };
1616
+ const body = {};
1617
+ for (const key of Object.keys(row)) {
1618
+ if (ENVELOPE_KEYS.has(key)) continue;
1619
+ body[key] = row[key];
1620
+ }
1621
+ return { envelope: Object.freeze(envelope), body, wrapped: false };
1622
+ }
1623
+ function storedItemName(peeled) {
1624
+ const fromEnvelope = peeled.envelope.name;
1625
+ if (typeof fromEnvelope === "string") return fromEnvelope;
1626
+ const body = peeled.body;
1627
+ if (body && typeof body === "object" && !Array.isArray(body)) {
1628
+ const fromBody = body.name;
1629
+ if (typeof fromBody === "string") return fromBody;
1630
+ }
1631
+ return void 0;
1632
+ }
1633
+
1634
+ // src/endpoint-matcher.ts
1553
1635
  function normalizeEndpointMethod(method) {
1554
1636
  return String(method ?? "").toUpperCase();
1555
1637
  }
@@ -1559,9 +1641,10 @@ function endpointIndexKey(method, path3) {
1559
1641
  function buildEndpointIndex(items, logger) {
1560
1642
  const index = /* @__PURE__ */ new Map();
1561
1643
  for (const item of items) {
1562
- const parsed = import_api.ApiEndpointSchema.safeParse(item);
1644
+ const peeled = peelStoredEnvelope(item);
1645
+ const parsed = import_api.ApiEndpointSchema.safeParse(peeled.body);
1563
1646
  if (!parsed.success) {
1564
- const declaredName = item && typeof item === "object" && typeof item.name === "string" ? item.name : "<unnamed>";
1647
+ const declaredName = storedItemName(peeled) ?? "<unnamed>";
1565
1648
  logger.error(
1566
1649
  `[EndpointMatcher] stored api item '${declaredName}' does not satisfy ApiEndpointSchema \u2014 it is EXCLUDED from endpoint matching and its declared route will answer 404. Fix the declaration (or remove it); the endpoint index never serves a half-valid shape.`,
1567
1650
  void 0,
@@ -2060,16 +2143,30 @@ var _MetadataManager = class _MetadataManager {
2060
2143
  * has with {@link loadDiagnosed}, so every existing caller keeps its exact
2061
2144
  * behaviour and only callers that ASK for the verdict pay for it.
2062
2145
  *
2063
- * [#5840] Deliberately NOT expressed as `(await getDiagnosed(…)).data`,
2064
- * although that is what it computes. The obvious delegation adds one
2065
- * `await` hop, and a registry hit here is observed one microtask sooner than
2066
- * it would be through a second async frame — which `register()`'s watchers
2067
- * depend on, because `notifyWatchers` does not await its handlers and
2068
- * ObjectQL's bridge re-reads through `get()` on the event rather than
2069
- * trusting the payload (`register-notifies-watchers.test.ts` pins it, and
2070
- * went red on the delegating version). The duplication is three lines and is
2071
- * pinned from the other side: `get()` and `getDiagnosed().data` are asserted
2072
- * to agree on every case in `metadata-manager-get-diagnosed.test.ts`.
2146
+ * Not expressed as `(await getDiagnosed(…)).data`, although that is what it
2147
+ * computes and the reason has CHANGED, so do not read the duplication as a
2148
+ * standing constraint.
2149
+ *
2150
+ * [#5840] recorded the delegation as unsafe: it adds one `await` hop, and
2151
+ * `register-notifies-watchers.test.ts` went red on the delegating version, so
2152
+ * three lines were duplicated to hold the frame count fixed. [#6043] measured
2153
+ * that test and found it was pinning this method's microtask depth rather than
2154
+ * the ordering guarantee it named `notifyWatchers` never awaits its handlers,
2155
+ * so a subscriber's `await get(…)` had simply been settling inside the
2156
+ * microtasks `await register(…)` yields. That case now asserts the ordering
2157
+ * synchronously against the registry and does not observe this method's frame
2158
+ * count at all; the whole `@objectstack/metadata` suite was re-measured on the
2159
+ * delegating version and stayed green.
2160
+ *
2161
+ * What survives is a plain, local reason: the registry hit is the hot path and
2162
+ * answering it without a second async frame is worth three lines. Nothing
2163
+ * external depends on the hop count any more. Consolidating the two into one
2164
+ * delegation is therefore a viable, deliberately un-taken change (#6043 was
2165
+ * test-scoped) — if you take it, note that `get()`'s callers outside this
2166
+ * package were never surveyed for timing sensitivity, only this package's
2167
+ * tests. Either way the two stay pinned to each other from the other side:
2168
+ * `get()` and `getDiagnosed().data` are asserted to agree on every case in
2169
+ * `metadata-manager-get-diagnosed.test.ts`.
2073
2170
  */
2074
2171
  async get(type, name) {
2075
2172
  const typeStore = this.registry.get(type);
@@ -2745,10 +2842,16 @@ var _MetadataManager = class _MetadataManager {
2745
2842
  * ## What it judges, and on what
2746
2843
  *
2747
2844
  * The registry stores either a raw spec document or a publish envelope
2748
- * (`{ name, packageId, state, metadata: {…spec} }`); the endpoint is read
2749
- * out with the SAME rule this method's caller uses for
2750
- * `publishedDefinition` (`data.metadata ?? data`), so publish gates exactly
2751
- * the document publish is about to snapshot. An item that does not satisfy
2845
+ * (`{ name, packageId, state, metadata: {…spec} }`), and in BOTH shapes the
2846
+ * row carries the metadata layer's bookkeeping. [#5309] The envelope is
2847
+ * peeled off first (`peelStoredEnvelope`) and the gate judges the authored
2848
+ * BODY: the wrapped half of that peel is the `data.metadata ?? data` rule
2849
+ * this method used to spell inline — the same document `publishedDefinition`
2850
+ * snapshots — and the flat half additionally removes `packageId` / `state` /
2851
+ * `version` / `published*`, which are storage identity, never endpoint
2852
+ * vocabulary. (What publish SNAPSHOTS is unchanged: `publishedDefinition`
2853
+ * still stores `data.metadata ?? data` verbatim, envelope included, because
2854
+ * `revertPackage` restores from it.) An item whose body does not satisfy
2752
2855
  * `ApiEndpointSchema` fails here too — not extra strictness but a
2753
2856
  * precondition: an unparsed shape cannot be gated, and it could never be
2754
2857
  * served either (the matcher's own loud skip refuses it at load).
@@ -2766,8 +2869,8 @@ var _MetadataManager = class _MetadataManager {
2766
2869
  const endpoints = [];
2767
2870
  const gatedItems = [];
2768
2871
  for (const item of apiItems) {
2769
- const document = item.data?.metadata ?? item.data;
2770
- const parsed = import_api3.ApiEndpointSchema.safeParse(document);
2872
+ const { body } = peelStoredEnvelope(item.data);
2873
+ const parsed = import_api3.ApiEndpointSchema.safeParse(body);
2771
2874
  if (!parsed.success) {
2772
2875
  for (const issue of parsed.error.issues) {
2773
2876
  errors.push({
@@ -4351,8 +4454,16 @@ var ARTIFACT_FIELD_TO_TYPE = {
4351
4454
  connectors: "connector",
4352
4455
  emailTemplates: "email_template",
4353
4456
  docs: "doc",
4354
- books: "book",
4355
- data: "dataset"
4457
+ books: "book"
4458
+ // `data:` (the SEED collection) is deliberately absent — #6242 row 4(a).
4459
+ // It used to map to `'dataset'`, the ADR-0021 analytics kind: the exact name
4460
+ // collision `metadata-plugin.zod.ts` warns about in prose. The entry never
4461
+ // registered anything (SeedSchema declares no `name`, and the loop below
4462
+ // skips nameless items) — a dead pointer aimed at the wrong kind, which
4463
+ // would have begun mis-registering the day either side moved. Removed rather
4464
+ // than repointed at `'seed'`: seeds are APPLIED by SeedLoaderService off the
4465
+ // bundle, never registered as metadata items, so a `seed` mapping would be
4466
+ // new behaviour rather than a corrected name.
4356
4467
  };
4357
4468
  var MetadataPlugin = class {
4358
4469
  constructor(options = {}) {