@objectstack/metadata 17.0.0-rc.5 → 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.
- package/CHANGELOG.md +511 -0
- package/dist/errors.cjs +56 -2
- package/dist/errors.cjs.map +1 -1
- package/dist/errors.d.cts +4 -0
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +56 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.cjs +184 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -17
- package/dist/index.d.ts +37 -17
- package/dist/index.js +184 -73
- package/dist/index.js.map +1 -1
- package/dist/migrations/index.cjs +0 -44
- package/dist/migrations/index.cjs.map +1 -1
- package/dist/migrations/index.d.cts +1 -38
- package/dist/migrations/index.d.ts +1 -38
- package/dist/migrations/index.js +0 -43
- package/dist/migrations/index.js.map +1 -1
- package/dist/node.cjs +184 -73
- package/dist/node.cjs.map +1 -1
- package/dist/node.js +184 -73
- package/dist/node.js.map +1 -1
- package/package.json +8 -8
package/dist/errors.cjs.map
CHANGED
|
@@ -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")
|
|
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;
|
package/dist/errors.js.map
CHANGED
|
@@ -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")
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
1644
|
+
const peeled = peelStoredEnvelope(item);
|
|
1645
|
+
const parsed = import_api.ApiEndpointSchema.safeParse(peeled.body);
|
|
1563
1646
|
if (!parsed.success) {
|
|
1564
|
-
const declaredName =
|
|
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
|
-
*
|
|
2064
|
-
*
|
|
2065
|
-
*
|
|
2066
|
-
*
|
|
2067
|
-
*
|
|
2068
|
-
*
|
|
2069
|
-
*
|
|
2070
|
-
*
|
|
2071
|
-
*
|
|
2072
|
-
*
|
|
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} }`)
|
|
2749
|
-
*
|
|
2750
|
-
*
|
|
2751
|
-
* the
|
|
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
|
|
2770
|
-
const parsed = import_api3.ApiEndpointSchema.safeParse(
|
|
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
|
|
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 = {}) {
|