@happyvertical/smrt-core 0.51.17 → 0.51.19

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.
Files changed (38) hide show
  1. package/AGENTS.md +7 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -1
  5. package/dist/manifest/manifest-loader.d.ts.map +1 -1
  6. package/dist/manifest/manifest-loader.js +4 -0
  7. package/dist/manifest/manifest-loader.js.map +1 -1
  8. package/dist/manifest/static-manifest.js +1 -1
  9. package/dist/manifest/static-manifest.js.map +1 -1
  10. package/dist/manifest/store.d.ts.map +1 -1
  11. package/dist/manifest/store.js +10 -3
  12. package/dist/manifest/store.js.map +1 -1
  13. package/dist/manifest.json +1 -1
  14. package/dist/registry/class-registration.d.ts +5 -0
  15. package/dist/registry/class-registration.d.ts.map +1 -1
  16. package/dist/registry/class-registration.js +25 -0
  17. package/dist/registry/class-registration.js.map +1 -1
  18. package/dist/registry/generation.d.ts +47 -0
  19. package/dist/registry/generation.d.ts.map +1 -0
  20. package/dist/registry/generation.js +56 -0
  21. package/dist/registry/generation.js.map +1 -0
  22. package/dist/registry/name-resolver.d.ts.map +1 -1
  23. package/dist/registry/name-resolver.js +48 -11
  24. package/dist/registry/name-resolver.js.map +1 -1
  25. package/dist/registry/shared-state.d.ts.map +1 -1
  26. package/dist/registry/shared-state.js +6 -2
  27. package/dist/registry/shared-state.js.map +1 -1
  28. package/dist/registry.d.ts.map +1 -1
  29. package/dist/registry.js +39 -7
  30. package/dist/registry.js.map +1 -1
  31. package/dist/schema/text-cast-probe.d.ts +15 -9
  32. package/dist/schema/text-cast-probe.d.ts.map +1 -1
  33. package/dist/schema/text-cast-probe.js +25 -17
  34. package/dist/schema/text-cast-probe.js.map +1 -1
  35. package/dist/smrt-knowledge.json +5 -5
  36. package/dist/utils/chunk.js +23 -1
  37. package/dist/utils/chunk.js.map +1 -1
  38. package/package.json +4 -4
@@ -27,15 +27,21 @@ export type ShapeProbeResult = {
27
27
  */
28
28
  export declare function maskSampleValue(value: string): string;
29
29
  /**
30
- * Probe every non-null value of a candidate `text` column for whether it can
31
- * cast losslessly to `timestamptz` or `jsonb`, using a real, exception-safe
32
- * cast attempt (not a shape heuristic) so an invalid-calendar timestamp or a
33
- * structurally-malformed-but-bracket-balanced JSON document is caught the
34
- * same as an obviously wrong value. Any failure to run the probe (adapter
35
- * without transaction support, a missing table mid-run, a test double
36
- * without a realistic response) resolves to `unavailable` — callers must not
37
- * treat that as "clean"; SMRT never coerces or discards data on an unproven
38
- * assumption.
30
+ * Probe every non-null value of a candidate `text` (or, for `jsonb`, native
31
+ * `json`) column for whether it can cast losslessly to `timestamptz` or
32
+ * `jsonb`, in two stages:
33
+ *
34
+ * 1. Cast: a real, exception-safe cast attempt (not a shape heuristic) so an
35
+ * invalid-calendar timestamp or a structurally-malformed-but-bracket-
36
+ * balanced JSON document is caught the same as an obviously wrong value.
37
+ * Any failure to run it (adapter without transaction support, a missing
38
+ * table mid-run, a test double without a realistic response) resolves to
39
+ * `unavailable` -- callers must not treat that as "clean".
40
+ * 2. Preservation (`jsonb` only, after a clean cast): duplicate object keys
41
+ * are `dirty` with reason `duplicate_keys`; a walk that does not finish is
42
+ * `dirty` with reason `preservation_unverified` and `count: 0`.
43
+ *
44
+ * SMRT never coerces or discards data on an unproven assumption.
39
45
  */
40
46
  export declare function probeCastSafety(db: DatabaseInterface, tableName: string, columnName: string, targetType: 'timestamptz' | 'jsonb'): Promise<ShapeProbeResult>;
41
47
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"text-cast-probe.d.ts","sourceRoot":"","sources":["../../src/schema/text-cast-probe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,oFAAoF;AACpF,MAAM,MAAM,gBAAgB,GACxB;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GACnB;IACE,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,gBAAgB,GAAG,yBAAyB,CAAC;IACtD,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GACD;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9C;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKrD;AAgHD;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,iBAAiB,EACrB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,aAAa,GAAG,OAAO,GAClC,OAAO,CAAC,gBAAgB,CAAC,CAM3B;AA2JD;;;;;;;;;;;;;;;;;GAiBG;AACH,UAAU,uBAAuB;IAC/B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,uBAA4B,GACpC,MAAM,EAAE,CAqBV;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,uBAA4B,GACpC,MAAM,EAAE,CAqBV"}
1
+ {"version":3,"file":"text-cast-probe.d.ts","sourceRoot":"","sources":["../../src/schema/text-cast-probe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,oFAAoF;AACpF,MAAM,MAAM,gBAAgB,GACxB;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,GACnB;IACE,MAAM,EAAE,OAAO,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;OAMG;IACH,MAAM,CAAC,EAAE,gBAAgB,GAAG,yBAAyB,CAAC;IACtD,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GACD;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9C;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKrD;AAiHD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,eAAe,CACnC,EAAE,EAAE,iBAAiB,EACrB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,aAAa,GAAG,OAAO,GAClC,OAAO,CAAC,gBAAgB,CAAC,CAM3B;AA4JD;;;;;;;;;;;;;;;;;GAiBG;AACH,UAAU,uBAAuB;IAC/B,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAC/C,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,uBAA4B,GACpC,MAAM,EAAE,CAqBV;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CACzC,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,uBAA4B,GACpC,MAAM,EAAE,CAqBV"}
@@ -60,7 +60,8 @@ function renderProbeQuerySql(tableName, columnName, targetType) {
60
60
  /**
61
61
  * One set-based jsonb cast over every non-null value: `count(expr)` forces
62
62
  * the cast for each row, and any value jsonb rejects raises instead of
63
- * returning, so success alone proves the column clean.
63
+ * returning, so success proves every value castable (not that the
64
+ * conversion preserves it -- see {@link probeJsonbKeyPreservation}).
64
65
  */
65
66
  function renderSetCastQuerySql(tableName, columnName) {
66
67
  return `SELECT count((${quoteIdentifier(columnName)}::text)::jsonb) AS cast_count FROM ${quoteIdentifier(tableName)}`;
@@ -80,15 +81,21 @@ function classifyProbeRows(rows) {
80
81
  };
81
82
  }
82
83
  /**
83
- * Probe every non-null value of a candidate `text` column for whether it can
84
- * cast losslessly to `timestamptz` or `jsonb`, using a real, exception-safe
85
- * cast attempt (not a shape heuristic) so an invalid-calendar timestamp or a
86
- * structurally-malformed-but-bracket-balanced JSON document is caught the
87
- * same as an obviously wrong value. Any failure to run the probe (adapter
88
- * without transaction support, a missing table mid-run, a test double
89
- * without a realistic response) resolves to `unavailable` — callers must not
90
- * treat that as "clean"; SMRT never coerces or discards data on an unproven
91
- * assumption.
84
+ * Probe every non-null value of a candidate `text` (or, for `jsonb`, native
85
+ * `json`) column for whether it can cast losslessly to `timestamptz` or
86
+ * `jsonb`, in two stages:
87
+ *
88
+ * 1. Cast: a real, exception-safe cast attempt (not a shape heuristic) so an
89
+ * invalid-calendar timestamp or a structurally-malformed-but-bracket-
90
+ * balanced JSON document is caught the same as an obviously wrong value.
91
+ * Any failure to run it (adapter without transaction support, a missing
92
+ * table mid-run, a test double without a realistic response) resolves to
93
+ * `unavailable` -- callers must not treat that as "clean".
94
+ * 2. Preservation (`jsonb` only, after a clean cast): duplicate object keys
95
+ * are `dirty` with reason `duplicate_keys`; a walk that does not finish is
96
+ * `dirty` with reason `preservation_unverified` and `count: 0`.
97
+ *
98
+ * SMRT never coerces or discards data on an unproven assumption.
92
99
  */
93
100
  async function probeCastSafety(db, tableName, columnName, targetType) {
94
101
  const castResult = await probeCastOnly(db, tableName, columnName, targetType);
@@ -99,9 +106,9 @@ async function probeCastSafety(db, tableName, columnName, targetType) {
99
106
  * #3041 review finding: a successful jsonb cast proves castability, not
100
107
  * preservation. `jsonb` keeps only the last of duplicate object keys (it
101
108
  * also drops key order and insignificant whitespace, which SMRT never
102
- * treats as data). Walk every object node, nested ones included, and
103
- * compare its key count as `json` against `jsonb`; any object that would
104
- * lose a key makes the column dirty so the conversion stays a fail-closed
109
+ * treats as data). Walk every container node, nested ones included, and
110
+ * look for a key that appears more than once in an object's own `json` key
111
+ * list (no jsonb re-parse); any such object makes the column dirty so the conversion stays a fail-closed
105
112
  * advisory instead of a silent, irreversible rewrite. The result columns
106
113
  * reuse the cast probe's names so one classifier reads both.
107
114
  */
@@ -136,10 +143,11 @@ function renderDuplicateKeyQuerySql(tableName, columnName) {
136
143
  return `WITH RECURSIVE nodes(v) AS (SELECT (${column}::text)::json FROM ${table} WHERE ${column} IS NOT NULL AND json_typeof((${column}::text)::json) IN ('object', 'array') UNION ALL SELECT child.value FROM nodes CROSS JOIN LATERAL (SELECT value FROM json_each(CASE WHEN json_typeof(nodes.v) = 'object' THEN nodes.v ELSE '{}'::json END) WHERE ${containers} UNION ALL SELECT value FROM json_array_elements(CASE WHEN json_typeof(nodes.v) = 'array' THEN nodes.v ELSE '[]'::json END) WHERE ${containers}) AS child) SELECT count(*) AS invalid_count, min(v::text) AS sample_value FROM nodes WHERE json_typeof(v) = 'object' AND EXISTS (SELECT 1 FROM json_object_keys(v) AS k(key) GROUP BY k.key HAVING count(*) > 1)`;
137
144
  }
138
145
  /**
139
- * Upper bound for the preservation walk. A timeout resolves to
140
- * `unavailable` -- the conversion is then not proposed (never a silent
141
- * collapse), and `db:status` live parity still reports the json/jsonb drift
142
- * without probing, so the column does not disappear from view.
146
+ * Upper bound for the preservation walk. A walk that does not finish (this
147
+ * timeout, a lock wait) resolves to `dirty` with reason
148
+ * `preservation_unverified`: the differ then emits a visible warning
149
+ * advisory with no conversion SQL (never a silent collapse, and never the
150
+ * silent withdrawal `unavailable` would mean).
143
151
  */
144
152
  var PRESERVATION_PROBE_TIMEOUT = "60s";
145
153
  async function probeCastOnly(db, tableName, columnName, targetType) {
@@ -1 +1 @@
1
- {"version":3,"file":"text-cast-probe.js","names":[],"sources":["../../src/schema/text-cast-probe.ts"],"sourcesContent":["/**\n * Shared \"probe, then cast\" helpers for text-column type convergence (#2771,\n * #2772).\n *\n * Two drift shapes on a table SMRT itself creates share one problem: a live\n * `text` column holds values that would parse losslessly into the manifest's\n * declared PostgreSQL type (`timestamptz` or `jsonb`), but the differ cannot\n * know that without looking at the data. `probeCastSafety()` answers that with\n * a genuine, exception-safe cast attempt — not a shape heuristic — over every\n * non-null value, and returns one sample so a fail-closed diagnostic can name\n * the offending value without dumping the whole column into JS.\n *\n * PostgreSQL has no `TRY_CAST`/safe-cast builtin before 17 (`pg_input_is_valid`),\n * and this fleet targets 16 (see `.github/workflows/postgres-tests.yml`), so a\n * real per-row safe cast needs PL/pgSQL's exception handling. That handler is\n * created as a session-scoped (`pg_temp`) function, which is why the create\n * statement and the probe query run inside one `db.transaction()` — the two\n * must land on the same physical connection, and a pooled adapter only\n * guarantees that within one transaction. `target_type` is never derived from\n * column data (always the literal `'jsonb'`/`'timestamptz'` this module\n * passes), so the dynamic `EXECUTE format(...)` it builds cannot be steered by\n * a row value; `%L` quotes the probed value as a literal.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { formatDefaultValue, quoteIdentifier } from './sql-identifiers.js';\n\n/** Outcome of a server-side cast-safety probe over one column's non-null values. */\nexport type ShapeProbeResult =\n | { status: 'clean' }\n | {\n status: 'dirty';\n count: number;\n sample?: string;\n /**\n * `duplicate_keys`: every value casts, but `count` JSON objects carry\n * duplicate keys that `jsonb` would silently collapse (#3041).\n * `preservation_unverified`: every value casts, but the duplicate-key\n * walk did not finish (its bound, a lock wait); `count` is 0 and no\n * conversion may be offered, but the decision must stay visible.\n */\n reason?: 'duplicate_keys' | 'preservation_unverified';\n /** Why the preservation walk did not finish (`preservation_unverified`). */\n detail?: string;\n }\n | { status: 'unavailable'; reason: string };\n\n/**\n * Mask a probed sample value for display in a diagnostic message: keep just\n * enough of the value to recognize it without echoing a full record verbatim\n * into logs/CI output (`created_at`/`_meta_data` values are user data).\n */\nexport function maskSampleValue(value: string): string {\n if (value.length === 0) return '(empty)';\n if (value.length <= 6)\n return `${'•'.repeat(value.length)} (length ${value.length})`;\n return `${value.slice(0, 3)}…${value.slice(-3)} (length ${value.length})`;\n}\n\n/** Session-scoped helper function name; `pg_temp` keeps it off the real schema. */\nconst PROBE_FUNCTION = 'pg_temp.smrt_probe_cast_ok';\n\n/**\n * PostgreSQL's \"special\" date/time input values (see the \"Special Values\"\n * table in the PostgreSQL docs). Every one of these parses successfully to\n * `timestamptz` — `probeCastSafety`'s real-cast test alone would call a text\n * column literally holding the string `now` or `infinity` \"clean\" — but\n * casting them evaluates to the *migration's* execution time or an infinite\n * sentinel, silently destroying the original value with no error and no\n * advisory. They must never be treated as safe, regardless of what a bare\n * cast attempt reports.\n */\nconst TIMESTAMPTZ_SPECIAL_VALUES = new Set([\n 'epoch',\n 'infinity',\n '-infinity',\n 'now',\n 'today',\n 'tomorrow',\n 'yesterday',\n 'allballs',\n]);\n\n/**\n * A `timestamptz` cast is only safe to apply unattended when the source text\n * already carries an explicit UTC offset (`Z` or `+HH[:MM]`/`-HH[:MM]`) —\n * without one, PostgreSQL interprets the value under the *migrating\n * session's* `TimeZone` setting, so the exact same text can cast to a\n * different instant on a different run/server. That ambiguity is exactly\n * what `--legacy-timezone=UTC` exists to resolve explicitly; the probe must\n * fail closed (not silently guess UTC) so naive values route to that opt-in\n * path instead of being auto-converged.\n */\n// PostgreSQL's ARE (\"advanced regular expression\") dialect, matched with the\n// case-insensitive `~*` operator server-side — not a JS RegExp, and not\n// derived from one, since the two dialects diverge on escaping/anchoring.\nconst TIMESTAMPTZ_EXPLICIT_OFFSET_SQL_PATTERN =\n '^[0-9]{4}-[0-9]{2}-[0-9]{2}([ T][0-9]{2}:[0-9]{2}(:[0-9]{2}(\\\\.[0-9]+)?)?)?[[:space:]]*(Z|[+-][0-9]{2}(:?[0-9]{2})?)$';\n\n/**\n * `target` is one of this module's own literal type names — never data —\n * so building the DDL by interpolation is safe; there is no column, table,\n * or row value in this string.\n */\nfunction renderCreateProbeFunctionSql(): string {\n const specialValuesArray = [...TIMESTAMPTZ_SPECIAL_VALUES]\n .map((value) => `'${value}'`)\n .join(',');\n return (\n `CREATE OR REPLACE FUNCTION ${PROBE_FUNCTION}(value text, target_type text) ` +\n 'RETURNS boolean LANGUAGE plpgsql AS $$ ' +\n 'BEGIN ' +\n 'IF value IS NULL THEN RETURN true; END IF; ' +\n \"IF target_type = 'timestamptz' THEN \" +\n `IF lower(trim(value)) = ANY (ARRAY[${specialValuesArray}]) THEN RETURN false; END IF; ` +\n `IF trim(value) !~* '${TIMESTAMPTZ_EXPLICIT_OFFSET_SQL_PATTERN}' THEN RETURN false; END IF; ` +\n 'END IF; ' +\n \"EXECUTE format('SELECT %L::%s', value, target_type); \" +\n 'RETURN true; ' +\n 'EXCEPTION WHEN OTHERS THEN RETURN false; ' +\n 'END; $$'\n );\n}\n\nfunction renderProbeQuerySql(\n tableName: string,\n columnName: string,\n targetType: 'timestamptz' | 'jsonb',\n): string {\n const column = quoteIdentifier(columnName);\n const table = quoteIdentifier(tableName);\n const isValid = `${PROBE_FUNCTION}(${column}::text, '${targetType}')`;\n return (\n `SELECT count(*) AS invalid_count, ` +\n `min(CASE WHEN NOT ${isValid} THEN ${column}::text END) AS sample_value ` +\n `FROM ${table} WHERE ${column} IS NOT NULL AND NOT ${isValid}`\n );\n}\n\n/**\n * One set-based jsonb cast over every non-null value: `count(expr)` forces\n * the cast for each row, and any value jsonb rejects raises instead of\n * returning, so success alone proves the column clean.\n */\nfunction renderSetCastQuerySql(tableName: string, columnName: string): string {\n const column = quoteIdentifier(columnName);\n const table = quoteIdentifier(tableName);\n return `SELECT count((${column}::text)::jsonb) AS cast_count FROM ${table}`;\n}\n\nfunction classifyProbeRows(\n rows: { invalid_count?: unknown; sample_value?: unknown }[],\n): ShapeProbeResult {\n const count = Number(rows[0]?.invalid_count);\n if (!Number.isFinite(count)) {\n return {\n status: 'unavailable',\n reason: 'probe returned a non-numeric count',\n };\n }\n if (count === 0) return { status: 'clean' };\n const sample = rows[0]?.sample_value;\n return {\n status: 'dirty',\n count,\n ...(typeof sample === 'string' ? { sample } : {}),\n };\n}\n\n/**\n * Probe every non-null value of a candidate `text` column for whether it can\n * cast losslessly to `timestamptz` or `jsonb`, using a real, exception-safe\n * cast attempt (not a shape heuristic) so an invalid-calendar timestamp or a\n * structurally-malformed-but-bracket-balanced JSON document is caught the\n * same as an obviously wrong value. Any failure to run the probe (adapter\n * without transaction support, a missing table mid-run, a test double\n * without a realistic response) resolves to `unavailable` — callers must not\n * treat that as \"clean\"; SMRT never coerces or discards data on an unproven\n * assumption.\n */\nexport async function probeCastSafety(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n targetType: 'timestamptz' | 'jsonb',\n): Promise<ShapeProbeResult> {\n const castResult = await probeCastOnly(db, tableName, columnName, targetType);\n if (targetType !== 'jsonb' || castResult.status !== 'clean') {\n return castResult;\n }\n return probeJsonbKeyPreservation(db, tableName, columnName);\n}\n\n/**\n * #3041 review finding: a successful jsonb cast proves castability, not\n * preservation. `jsonb` keeps only the last of duplicate object keys (it\n * also drops key order and insignificant whitespace, which SMRT never\n * treats as data). Walk every object node, nested ones included, and\n * compare its key count as `json` against `jsonb`; any object that would\n * lose a key makes the column dirty so the conversion stays a fail-closed\n * advisory instead of a silent, irreversible rewrite. The result columns\n * reuse the cast probe's names so one classifier reads both.\n */\nasync function probeJsonbKeyPreservation(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n): Promise<ShapeProbeResult> {\n if (!db.transaction) {\n return {\n status: 'unavailable',\n reason: 'adapter does not support transactions',\n };\n }\n try {\n const result = await db.transaction(async (tx) => {\n await tx.query(\n `SET LOCAL statement_timeout = '${PRESERVATION_PROBE_TIMEOUT}'`,\n );\n return tx.query(renderDuplicateKeyQuerySql(tableName, columnName));\n });\n const rows = (Array.isArray(result) ? result : (result?.rows ?? [])) as {\n invalid_count?: unknown;\n sample_value?: unknown;\n }[];\n const classified = classifyProbeRows(rows);\n return classified.status === 'dirty'\n ? { ...classified, reason: 'duplicate_keys' }\n : classified;\n } catch (error) {\n // #3041 review finding: the cast already proved the column castable, so\n // a walk that ran and gave up (typically the timeout below) is not the\n // \"could not probe\" state `unavailable` means -- returning it would\n // silently withdraw the conversion. Fail closed, visibly.\n return {\n status: 'dirty',\n count: 0,\n reason: 'preservation_unverified',\n detail: error instanceof Error ? error.message : String(error),\n };\n }\n}\n\nfunction renderDuplicateKeyQuerySql(\n tableName: string,\n columnName: string,\n): string {\n const column = quoteIdentifier(columnName);\n const table = quoteIdentifier(tableName);\n // Only containers recurse (scalars are pruned at the edge), and each\n // object node is checked from its own key list -- a duplicate is a key\n // that appears more than once -- so no subtree is re-parsed as jsonb and\n // the walk stays linear in the number of container nodes.\n const containers = \"json_typeof(value) IN ('object', 'array')\";\n return (\n 'WITH RECURSIVE nodes(v) AS (' +\n `SELECT (${column}::text)::json FROM ${table} ` +\n `WHERE ${column} IS NOT NULL AND json_typeof((${column}::text)::json) IN ('object', 'array') ` +\n 'UNION ALL ' +\n 'SELECT child.value FROM nodes CROSS JOIN LATERAL (' +\n \"SELECT value FROM json_each(CASE WHEN json_typeof(nodes.v) = 'object' THEN nodes.v ELSE '{}'::json END) \" +\n `WHERE ${containers} ` +\n 'UNION ALL ' +\n \"SELECT value FROM json_array_elements(CASE WHEN json_typeof(nodes.v) = 'array' THEN nodes.v ELSE '[]'::json END) \" +\n `WHERE ${containers}` +\n ') AS child) ' +\n 'SELECT count(*) AS invalid_count, min(v::text) AS sample_value FROM nodes ' +\n \"WHERE json_typeof(v) = 'object' AND EXISTS (\" +\n 'SELECT 1 FROM json_object_keys(v) AS k(key) GROUP BY k.key HAVING count(*) > 1)'\n );\n}\n\n/**\n * Upper bound for the preservation walk. A timeout resolves to\n * `unavailable` -- the conversion is then not proposed (never a silent\n * collapse), and `db:status` live parity still reports the json/jsonb drift\n * without probing, so the column does not disappear from view.\n */\nconst PRESERVATION_PROBE_TIMEOUT = '60s';\n\nasync function probeCastOnly(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n targetType: 'timestamptz' | 'jsonb',\n): Promise<ShapeProbeResult> {\n if (!db.transaction) {\n return {\n status: 'unavailable',\n reason: 'adapter does not support transactions',\n };\n }\n if (targetType === 'jsonb') {\n // #3041 review finding: the per-row PL/pgSQL probe opens a\n // subtransaction per value, and native `json` columns are the large\n // legacy `_meta_data`-style ones. A single set-based cast that succeeds\n // proves every value castable (preservation is checked separately, see\n // `probeJsonbKeyPreservation`); only a failed cast falls through to the\n // per-row probe for the count and sample.\n try {\n const castCount = await db.transaction(async (tx) => {\n const result = await tx.query(\n renderSetCastQuerySql(tableName, columnName),\n );\n const rows = (\n Array.isArray(result) ? result : (result?.rows ?? [])\n ) as {\n cast_count?: unknown;\n }[];\n return rows[0]?.cast_count;\n });\n // Only a realistic answer counts as proof; anything else (an adapter\n // or test double that does not return the count) takes the per-row\n // probe below instead.\n if (\n castCount !== undefined &&\n castCount !== null &&\n Number.isFinite(Number(castCount))\n ) {\n return { status: 'clean' };\n }\n } catch {\n // Fall through: the per-row probe classifies the failure (dirty\n // values, or unavailable for a missing table / unrealistic adapter).\n }\n }\n try {\n return await db.transaction(async (tx) => {\n await tx.query(renderCreateProbeFunctionSql());\n const result = await tx.query(\n renderProbeQuerySql(tableName, columnName, targetType),\n );\n const rows = (Array.isArray(result) ? result : (result?.rows ?? [])) as {\n invalid_count?: unknown;\n sample_value?: unknown;\n }[];\n return classifyProbeRows(rows);\n });\n } catch (error) {\n return {\n status: 'unavailable',\n reason: error instanceof Error ? error.message : String(error),\n };\n }\n}\n\n/**\n * Column-conversion options shared by {@link renderTimestamptzColumnConversion}\n * and {@link renderJsonbColumnConversion}.\n *\n * `hasLiveDefault` and `manifestDefaultValue` are independent: PostgreSQL\n * rejects `ALTER COLUMN ... TYPE` outright whenever the column has *any*\n * existing default that cannot be auto-cast to the target type — this is\n * true whether or not the manifest itself declares a default (a legacy\n * `text` column with `DEFAULT ''` blocks the ALTER exactly the same as one\n * the manifest also wants a default on), so `DROP DEFAULT` must be gated on\n * the *live* default. `SET DEFAULT` afterward must be gated on the\n * *manifest* default instead: re-establishing a live-only default the\n * manifest no longer declares would silently resurrect drift\n * `compareColumnConstraints` is supposed to report and let an operator\n * choose to drop (see #2771/#2772 review findings: DROP/TYPE/SET DEFAULT\n * mirrors the pre-existing `generateTypeUpgradeSQL` path, and the DROP gate\n * must key off live state PostgreSQL itself enforces, not manifest intent).\n */\ninterface ColumnConversionOptions {\n hasLiveDefault?: boolean;\n manifestDefaultValue?: unknown;\n}\n\n/**\n * Render the one-time PostgreSQL conversion of a legacy `text` column to\n * native `timestamptz`, once {@link probeCastSafety} has confirmed every\n * non-null value casts safely.\n */\nexport function renderTimestamptzColumnConversion(\n tableName: string,\n columnName: string,\n options: ColumnConversionOptions = {},\n): string[] {\n const table = quoteIdentifier(tableName);\n const column = quoteIdentifier(columnName);\n const hasManifestDefault = options.manifestDefaultValue !== undefined;\n const statements: string[] = [];\n if (options.hasLiveDefault || hasManifestDefault) {\n statements.push(`ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT`);\n }\n statements.push(\n `ALTER TABLE ${table} ALTER COLUMN ${column} TYPE timestamptz USING ${column}::timestamptz`,\n );\n if (hasManifestDefault) {\n const formattedDefault = formatDefaultValue(\n options.manifestDefaultValue,\n 'TIMESTAMP',\n );\n statements.push(\n `ALTER TABLE ${table} ALTER COLUMN ${column} SET DEFAULT ${formattedDefault}`,\n );\n }\n return statements;\n}\n\n/**\n * Render the one-time PostgreSQL conversion of a legacy `text` column to\n * native `jsonb`, once {@link probeCastSafety} has confirmed every non-null\n * value casts safely.\n */\nexport function renderJsonbColumnConversion(\n tableName: string,\n columnName: string,\n options: ColumnConversionOptions = {},\n): string[] {\n const table = quoteIdentifier(tableName);\n const column = quoteIdentifier(columnName);\n const hasManifestDefault = options.manifestDefaultValue !== undefined;\n const statements: string[] = [];\n if (options.hasLiveDefault || hasManifestDefault) {\n statements.push(`ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT`);\n }\n statements.push(\n `ALTER TABLE ${table} ALTER COLUMN ${column} TYPE jsonb USING ${column}::jsonb`,\n );\n if (hasManifestDefault) {\n const formattedDefault = formatDefaultValue(\n options.manifestDefaultValue,\n 'JSON',\n );\n statements.push(\n `ALTER TABLE ${table} ALTER COLUMN ${column} SET DEFAULT ${formattedDefault}::jsonb`,\n );\n }\n return statements;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"text-cast-probe.js","names":[],"sources":["../../src/schema/text-cast-probe.ts"],"sourcesContent":["/**\n * Shared \"probe, then cast\" helpers for text-column type convergence (#2771,\n * #2772).\n *\n * Two drift shapes on a table SMRT itself creates share one problem: a live\n * `text` column holds values that would parse losslessly into the manifest's\n * declared PostgreSQL type (`timestamptz` or `jsonb`), but the differ cannot\n * know that without looking at the data. `probeCastSafety()` answers that with\n * a genuine, exception-safe cast attempt — not a shape heuristic — over every\n * non-null value, and returns one sample so a fail-closed diagnostic can name\n * the offending value without dumping the whole column into JS.\n *\n * PostgreSQL has no `TRY_CAST`/safe-cast builtin before 17 (`pg_input_is_valid`),\n * and this fleet targets 16 (see `.github/workflows/postgres-tests.yml`), so a\n * real per-row safe cast needs PL/pgSQL's exception handling. That handler is\n * created as a session-scoped (`pg_temp`) function, which is why the create\n * statement and the probe query run inside one `db.transaction()` — the two\n * must land on the same physical connection, and a pooled adapter only\n * guarantees that within one transaction. `target_type` is never derived from\n * column data (always the literal `'jsonb'`/`'timestamptz'` this module\n * passes), so the dynamic `EXECUTE format(...)` it builds cannot be steered by\n * a row value; `%L` quotes the probed value as a literal.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { formatDefaultValue, quoteIdentifier } from './sql-identifiers.js';\n\n/** Outcome of a server-side cast-safety probe over one column's non-null values. */\nexport type ShapeProbeResult =\n | { status: 'clean' }\n | {\n status: 'dirty';\n count: number;\n sample?: string;\n /**\n * `duplicate_keys`: every value casts, but `count` JSON objects carry\n * duplicate keys that `jsonb` would silently collapse (#3041).\n * `preservation_unverified`: every value casts, but the duplicate-key\n * walk did not finish (its bound, a lock wait); `count` is 0 and no\n * conversion may be offered, but the decision must stay visible.\n */\n reason?: 'duplicate_keys' | 'preservation_unverified';\n /** Why the preservation walk did not finish (`preservation_unverified`). */\n detail?: string;\n }\n | { status: 'unavailable'; reason: string };\n\n/**\n * Mask a probed sample value for display in a diagnostic message: keep just\n * enough of the value to recognize it without echoing a full record verbatim\n * into logs/CI output (`created_at`/`_meta_data` values are user data).\n */\nexport function maskSampleValue(value: string): string {\n if (value.length === 0) return '(empty)';\n if (value.length <= 6)\n return `${'•'.repeat(value.length)} (length ${value.length})`;\n return `${value.slice(0, 3)}…${value.slice(-3)} (length ${value.length})`;\n}\n\n/** Session-scoped helper function name; `pg_temp` keeps it off the real schema. */\nconst PROBE_FUNCTION = 'pg_temp.smrt_probe_cast_ok';\n\n/**\n * PostgreSQL's \"special\" date/time input values (see the \"Special Values\"\n * table in the PostgreSQL docs). Every one of these parses successfully to\n * `timestamptz` — `probeCastSafety`'s real-cast test alone would call a text\n * column literally holding the string `now` or `infinity` \"clean\" — but\n * casting them evaluates to the *migration's* execution time or an infinite\n * sentinel, silently destroying the original value with no error and no\n * advisory. They must never be treated as safe, regardless of what a bare\n * cast attempt reports.\n */\nconst TIMESTAMPTZ_SPECIAL_VALUES = new Set([\n 'epoch',\n 'infinity',\n '-infinity',\n 'now',\n 'today',\n 'tomorrow',\n 'yesterday',\n 'allballs',\n]);\n\n/**\n * A `timestamptz` cast is only safe to apply unattended when the source text\n * already carries an explicit UTC offset (`Z` or `+HH[:MM]`/`-HH[:MM]`) —\n * without one, PostgreSQL interprets the value under the *migrating\n * session's* `TimeZone` setting, so the exact same text can cast to a\n * different instant on a different run/server. That ambiguity is exactly\n * what `--legacy-timezone=UTC` exists to resolve explicitly; the probe must\n * fail closed (not silently guess UTC) so naive values route to that opt-in\n * path instead of being auto-converged.\n */\n// PostgreSQL's ARE (\"advanced regular expression\") dialect, matched with the\n// case-insensitive `~*` operator server-side — not a JS RegExp, and not\n// derived from one, since the two dialects diverge on escaping/anchoring.\nconst TIMESTAMPTZ_EXPLICIT_OFFSET_SQL_PATTERN =\n '^[0-9]{4}-[0-9]{2}-[0-9]{2}([ T][0-9]{2}:[0-9]{2}(:[0-9]{2}(\\\\.[0-9]+)?)?)?[[:space:]]*(Z|[+-][0-9]{2}(:?[0-9]{2})?)$';\n\n/**\n * `target` is one of this module's own literal type names — never data —\n * so building the DDL by interpolation is safe; there is no column, table,\n * or row value in this string.\n */\nfunction renderCreateProbeFunctionSql(): string {\n const specialValuesArray = [...TIMESTAMPTZ_SPECIAL_VALUES]\n .map((value) => `'${value}'`)\n .join(',');\n return (\n `CREATE OR REPLACE FUNCTION ${PROBE_FUNCTION}(value text, target_type text) ` +\n 'RETURNS boolean LANGUAGE plpgsql AS $$ ' +\n 'BEGIN ' +\n 'IF value IS NULL THEN RETURN true; END IF; ' +\n \"IF target_type = 'timestamptz' THEN \" +\n `IF lower(trim(value)) = ANY (ARRAY[${specialValuesArray}]) THEN RETURN false; END IF; ` +\n `IF trim(value) !~* '${TIMESTAMPTZ_EXPLICIT_OFFSET_SQL_PATTERN}' THEN RETURN false; END IF; ` +\n 'END IF; ' +\n \"EXECUTE format('SELECT %L::%s', value, target_type); \" +\n 'RETURN true; ' +\n 'EXCEPTION WHEN OTHERS THEN RETURN false; ' +\n 'END; $$'\n );\n}\n\nfunction renderProbeQuerySql(\n tableName: string,\n columnName: string,\n targetType: 'timestamptz' | 'jsonb',\n): string {\n const column = quoteIdentifier(columnName);\n const table = quoteIdentifier(tableName);\n const isValid = `${PROBE_FUNCTION}(${column}::text, '${targetType}')`;\n return (\n `SELECT count(*) AS invalid_count, ` +\n `min(CASE WHEN NOT ${isValid} THEN ${column}::text END) AS sample_value ` +\n `FROM ${table} WHERE ${column} IS NOT NULL AND NOT ${isValid}`\n );\n}\n\n/**\n * One set-based jsonb cast over every non-null value: `count(expr)` forces\n * the cast for each row, and any value jsonb rejects raises instead of\n * returning, so success proves every value castable (not that the\n * conversion preserves it -- see {@link probeJsonbKeyPreservation}).\n */\nfunction renderSetCastQuerySql(tableName: string, columnName: string): string {\n const column = quoteIdentifier(columnName);\n const table = quoteIdentifier(tableName);\n return `SELECT count((${column}::text)::jsonb) AS cast_count FROM ${table}`;\n}\n\nfunction classifyProbeRows(\n rows: { invalid_count?: unknown; sample_value?: unknown }[],\n): ShapeProbeResult {\n const count = Number(rows[0]?.invalid_count);\n if (!Number.isFinite(count)) {\n return {\n status: 'unavailable',\n reason: 'probe returned a non-numeric count',\n };\n }\n if (count === 0) return { status: 'clean' };\n const sample = rows[0]?.sample_value;\n return {\n status: 'dirty',\n count,\n ...(typeof sample === 'string' ? { sample } : {}),\n };\n}\n\n/**\n * Probe every non-null value of a candidate `text` (or, for `jsonb`, native\n * `json`) column for whether it can cast losslessly to `timestamptz` or\n * `jsonb`, in two stages:\n *\n * 1. Cast: a real, exception-safe cast attempt (not a shape heuristic) so an\n * invalid-calendar timestamp or a structurally-malformed-but-bracket-\n * balanced JSON document is caught the same as an obviously wrong value.\n * Any failure to run it (adapter without transaction support, a missing\n * table mid-run, a test double without a realistic response) resolves to\n * `unavailable` -- callers must not treat that as \"clean\".\n * 2. Preservation (`jsonb` only, after a clean cast): duplicate object keys\n * are `dirty` with reason `duplicate_keys`; a walk that does not finish is\n * `dirty` with reason `preservation_unverified` and `count: 0`.\n *\n * SMRT never coerces or discards data on an unproven assumption.\n */\nexport async function probeCastSafety(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n targetType: 'timestamptz' | 'jsonb',\n): Promise<ShapeProbeResult> {\n const castResult = await probeCastOnly(db, tableName, columnName, targetType);\n if (targetType !== 'jsonb' || castResult.status !== 'clean') {\n return castResult;\n }\n return probeJsonbKeyPreservation(db, tableName, columnName);\n}\n\n/**\n * #3041 review finding: a successful jsonb cast proves castability, not\n * preservation. `jsonb` keeps only the last of duplicate object keys (it\n * also drops key order and insignificant whitespace, which SMRT never\n * treats as data). Walk every container node, nested ones included, and\n * look for a key that appears more than once in an object's own `json` key\n * list (no jsonb re-parse); any such object makes the column dirty so the conversion stays a fail-closed\n * advisory instead of a silent, irreversible rewrite. The result columns\n * reuse the cast probe's names so one classifier reads both.\n */\nasync function probeJsonbKeyPreservation(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n): Promise<ShapeProbeResult> {\n if (!db.transaction) {\n return {\n status: 'unavailable',\n reason: 'adapter does not support transactions',\n };\n }\n try {\n const result = await db.transaction(async (tx) => {\n await tx.query(\n `SET LOCAL statement_timeout = '${PRESERVATION_PROBE_TIMEOUT}'`,\n );\n return tx.query(renderDuplicateKeyQuerySql(tableName, columnName));\n });\n const rows = (Array.isArray(result) ? result : (result?.rows ?? [])) as {\n invalid_count?: unknown;\n sample_value?: unknown;\n }[];\n const classified = classifyProbeRows(rows);\n return classified.status === 'dirty'\n ? { ...classified, reason: 'duplicate_keys' }\n : classified;\n } catch (error) {\n // #3041 review finding: the cast already proved the column castable, so\n // a walk that ran and gave up (typically the timeout below) is not the\n // \"could not probe\" state `unavailable` means -- returning it would\n // silently withdraw the conversion. Fail closed, visibly.\n return {\n status: 'dirty',\n count: 0,\n reason: 'preservation_unverified',\n detail: error instanceof Error ? error.message : String(error),\n };\n }\n}\n\nfunction renderDuplicateKeyQuerySql(\n tableName: string,\n columnName: string,\n): string {\n const column = quoteIdentifier(columnName);\n const table = quoteIdentifier(tableName);\n // Only containers recurse (scalars are pruned at the edge), and each\n // object node is checked from its own key list -- a duplicate is a key\n // that appears more than once -- so no subtree is re-parsed as jsonb and\n // the walk stays linear in the number of container nodes.\n const containers = \"json_typeof(value) IN ('object', 'array')\";\n return (\n 'WITH RECURSIVE nodes(v) AS (' +\n `SELECT (${column}::text)::json FROM ${table} ` +\n `WHERE ${column} IS NOT NULL AND json_typeof((${column}::text)::json) IN ('object', 'array') ` +\n 'UNION ALL ' +\n 'SELECT child.value FROM nodes CROSS JOIN LATERAL (' +\n \"SELECT value FROM json_each(CASE WHEN json_typeof(nodes.v) = 'object' THEN nodes.v ELSE '{}'::json END) \" +\n `WHERE ${containers} ` +\n 'UNION ALL ' +\n \"SELECT value FROM json_array_elements(CASE WHEN json_typeof(nodes.v) = 'array' THEN nodes.v ELSE '[]'::json END) \" +\n `WHERE ${containers}` +\n ') AS child) ' +\n 'SELECT count(*) AS invalid_count, min(v::text) AS sample_value FROM nodes ' +\n \"WHERE json_typeof(v) = 'object' AND EXISTS (\" +\n 'SELECT 1 FROM json_object_keys(v) AS k(key) GROUP BY k.key HAVING count(*) > 1)'\n );\n}\n\n/**\n * Upper bound for the preservation walk. A walk that does not finish (this\n * timeout, a lock wait) resolves to `dirty` with reason\n * `preservation_unverified`: the differ then emits a visible warning\n * advisory with no conversion SQL (never a silent collapse, and never the\n * silent withdrawal `unavailable` would mean).\n */\nconst PRESERVATION_PROBE_TIMEOUT = '60s';\n\nasync function probeCastOnly(\n db: DatabaseInterface,\n tableName: string,\n columnName: string,\n targetType: 'timestamptz' | 'jsonb',\n): Promise<ShapeProbeResult> {\n if (!db.transaction) {\n return {\n status: 'unavailable',\n reason: 'adapter does not support transactions',\n };\n }\n if (targetType === 'jsonb') {\n // #3041 review finding: the per-row PL/pgSQL probe opens a\n // subtransaction per value, and native `json` columns are the large\n // legacy `_meta_data`-style ones. A single set-based cast that succeeds\n // proves every value castable (preservation is checked separately, see\n // `probeJsonbKeyPreservation`); only a failed cast falls through to the\n // per-row probe for the count and sample.\n try {\n const castCount = await db.transaction(async (tx) => {\n const result = await tx.query(\n renderSetCastQuerySql(tableName, columnName),\n );\n const rows = (\n Array.isArray(result) ? result : (result?.rows ?? [])\n ) as {\n cast_count?: unknown;\n }[];\n return rows[0]?.cast_count;\n });\n // Only a realistic answer counts as proof; anything else (an adapter\n // or test double that does not return the count) takes the per-row\n // probe below instead.\n if (\n castCount !== undefined &&\n castCount !== null &&\n Number.isFinite(Number(castCount))\n ) {\n return { status: 'clean' };\n }\n } catch {\n // Fall through: the per-row probe classifies the failure (dirty\n // values, or unavailable for a missing table / unrealistic adapter).\n }\n }\n try {\n return await db.transaction(async (tx) => {\n await tx.query(renderCreateProbeFunctionSql());\n const result = await tx.query(\n renderProbeQuerySql(tableName, columnName, targetType),\n );\n const rows = (Array.isArray(result) ? result : (result?.rows ?? [])) as {\n invalid_count?: unknown;\n sample_value?: unknown;\n }[];\n return classifyProbeRows(rows);\n });\n } catch (error) {\n return {\n status: 'unavailable',\n reason: error instanceof Error ? error.message : String(error),\n };\n }\n}\n\n/**\n * Column-conversion options shared by {@link renderTimestamptzColumnConversion}\n * and {@link renderJsonbColumnConversion}.\n *\n * `hasLiveDefault` and `manifestDefaultValue` are independent: PostgreSQL\n * rejects `ALTER COLUMN ... TYPE` outright whenever the column has *any*\n * existing default that cannot be auto-cast to the target type — this is\n * true whether or not the manifest itself declares a default (a legacy\n * `text` column with `DEFAULT ''` blocks the ALTER exactly the same as one\n * the manifest also wants a default on), so `DROP DEFAULT` must be gated on\n * the *live* default. `SET DEFAULT` afterward must be gated on the\n * *manifest* default instead: re-establishing a live-only default the\n * manifest no longer declares would silently resurrect drift\n * `compareColumnConstraints` is supposed to report and let an operator\n * choose to drop (see #2771/#2772 review findings: DROP/TYPE/SET DEFAULT\n * mirrors the pre-existing `generateTypeUpgradeSQL` path, and the DROP gate\n * must key off live state PostgreSQL itself enforces, not manifest intent).\n */\ninterface ColumnConversionOptions {\n hasLiveDefault?: boolean;\n manifestDefaultValue?: unknown;\n}\n\n/**\n * Render the one-time PostgreSQL conversion of a legacy `text` column to\n * native `timestamptz`, once {@link probeCastSafety} has confirmed every\n * non-null value casts safely.\n */\nexport function renderTimestamptzColumnConversion(\n tableName: string,\n columnName: string,\n options: ColumnConversionOptions = {},\n): string[] {\n const table = quoteIdentifier(tableName);\n const column = quoteIdentifier(columnName);\n const hasManifestDefault = options.manifestDefaultValue !== undefined;\n const statements: string[] = [];\n if (options.hasLiveDefault || hasManifestDefault) {\n statements.push(`ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT`);\n }\n statements.push(\n `ALTER TABLE ${table} ALTER COLUMN ${column} TYPE timestamptz USING ${column}::timestamptz`,\n );\n if (hasManifestDefault) {\n const formattedDefault = formatDefaultValue(\n options.manifestDefaultValue,\n 'TIMESTAMP',\n );\n statements.push(\n `ALTER TABLE ${table} ALTER COLUMN ${column} SET DEFAULT ${formattedDefault}`,\n );\n }\n return statements;\n}\n\n/**\n * Render the one-time PostgreSQL conversion of a legacy `text` column to\n * native `jsonb`, once {@link probeCastSafety} has confirmed every non-null\n * value casts safely.\n */\nexport function renderJsonbColumnConversion(\n tableName: string,\n columnName: string,\n options: ColumnConversionOptions = {},\n): string[] {\n const table = quoteIdentifier(tableName);\n const column = quoteIdentifier(columnName);\n const hasManifestDefault = options.manifestDefaultValue !== undefined;\n const statements: string[] = [];\n if (options.hasLiveDefault || hasManifestDefault) {\n statements.push(`ALTER TABLE ${table} ALTER COLUMN ${column} DROP DEFAULT`);\n }\n statements.push(\n `ALTER TABLE ${table} ALTER COLUMN ${column} TYPE jsonb USING ${column}::jsonb`,\n );\n if (hasManifestDefault) {\n const formattedDefault = formatDefaultValue(\n options.manifestDefaultValue,\n 'JSON',\n );\n statements.push(\n `ALTER TABLE ${table} ALTER COLUMN ${column} SET DEFAULT ${formattedDefault}::jsonb`,\n );\n }\n return statements;\n}\n"],"mappings":"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"}
@@ -3,13 +3,13 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.51.17",
6
+ "packageVersion": "0.51.19",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "4f680cca361cf1226a7846d806de2417e3483c82fc1f3c8d0b510b6eb2e94ec4",
11
- "packageJson": "a1085f6f5ba439f7501ae4392c9fac3a221d144842bd2896624ee90f66e1e16e",
12
- "agents": "7e27a7c52729c302d80e9c36bf068fe1811a7ae51eccf66c6a5f9498aaec3318",
10
+ "manifest": "3d9e46b370fef6284ed8804c9a57835e9e6e92647b5707a689eccf1f11fd2606",
11
+ "packageJson": "d38a731745c8305743983f0dcdd8b6cade9466aa33c3ca0c0797e3f4dcdcc21d",
12
+ "agents": "3c6673d5e85bd43f1b445d446f043b6044726df113e73fb17c20a6705aa6503c",
13
13
  "moduleDoc:agents/object-runtime.md": "34a4e740c109467cd5486c4af0e3bf4b63915697721ab584df0e5506ea9290ca",
14
14
  "moduleDoc:agents/revision-guard.md": "aa6b1ddb5b6b49fa27ebbe575ec7fcd6d5ceb35ee25acc702f877a226eb9a99a",
15
15
  "moduleDoc:agents/collection-reads.md": "e5bb79b59c17e79a85a71580a019e6b0b641e498ac4d850073fd5ad634b57cb7",
@@ -1047,7 +1047,7 @@
1047
1047
  "polymorphicAssociations": 1,
1048
1048
  "uuidColumns": 3
1049
1049
  },
1050
- "agentDoc": "# @happyvertical/smrt-core\n\nFoundation ORM, registry, schema/code generation, AI integration, and DispatchBus.\nRead the module for the subsystem being edited; root AGENTS covers shared model\nand repository rules.\n\n## Modules\n\n| Source | Scope | Module doc |\n|---|---|---|\n| `src/object.ts`, `src/collection.ts`, `src/child-accessors.ts` | Lifecycle, hydration, operators, STI, child accessors, dispatch | [agents/object-runtime.md](agents/object-runtime.md) |\n| `src/revision-guard.ts` | Guarded writes and PostgreSQL revision precision | [agents/revision-guard.md](agents/revision-guard.md) |\n| `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n| `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |\n| `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |\n| `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |\n| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |\n| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |\n| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |\n| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n\n- Production DDL uses manifest generators; `getTestDatabase()` uses registry\n generators. Keep columns, indexes, FK actions, and runtime conflict targets in\n parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate\n needs its index or an explicit reason none is needed.\n- Tenant scoping covers every read and every unique/conflict key. Explicit\n conflict columns are not rewritten; their author must include tenant scope.\n- Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.\n Preserve revision compare-and-swap ordering through public `save()`,\n `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete\n `withTransaction()` callbacks share a process-local write queue.\n- Collection model hydration is serial in result order: initialization may query\n the same transaction-bound PostgreSQL client. Use projections for plain rows.\n- Native DuckDB UUIDs must be cast coherently on read before identity reuse;\n custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay\n a mutation to discover result types.\n- `withDatabase(db, callback)` restores only database bindings (including public\n `options.db`); `withTransaction(callback)` also restores identity/revision\n metadata after rollback. Do not use a bound instance concurrently.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses an advisory lock. Application tables still require migrations;\n runtime table verification checks existence only.\n- Manifest generation fails closed on scanner errors, including unresolved\n decorator spreads. Never emit partial, default-open registration.\n- Generated registration repairs bundled class identity using the exact imported\n constructor, explicit package, and isolated one-object manifest. Never infer\n ownership from paths, simple names, or table names; packages can share names.\n Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.\n- API custom-action eligibility has ONE resolver, `resolveApiMethodExposure()`\n in `generators/custom-action.ts`: both SvelteKit route emitters,\n `resolveApiActionSet`, and `knowledge.ts` read it, and a new consumer must too\n — a local mirror is how a method gets reported unavailable while its route\n file is still written. A public method routes by default only when every\n parameter is JSON-shaped; `@method({ expose })` overrides in both directions,\n `expose: true` bypasses the heuristic ALONE, and an explicit `api.include` or\n `api.routes` entry keeps its pre-#2686 route. Fail closed on scanner\n uncertainty: read `parameters[].typeUnresolved`, never the `'any'` it\n substitutes. Accepting `Date` as wire-able and hydrating it\n (`toCustomActionDate`) are one decision — changing either breaks the other.\n The runtime `APIGenerator` transport stays declaration-gated: `rest.ts`\n dispatch and `preflight-route.ts` prediction both read\n `declaresRuntimeRestRoute()`, which must accept every `ApiCustomRouteConfig`\n option so a sweep moving one onto its method cannot delete the endpoint. Its\n twin `declaresRuntimeRestRouteShape()` deliberately ignores `expose: false`:\n the dispatcher must still SEE a withheld declaration to answer 404, because\n `POST /<collection>/<segment>` resolves to `create` when nothing claims the\n segment. Split unions and type arguments with `splitTopLevel()` — a naive\n `split('|')` truncates `Record<string, Asset | null>` into fragments that\n match no rule and are then accepted, widening the gate. `extractTypeName`\n returns `null` for a generic with an unresolvable ARGUMENT or a union with an\n unresolvable BRANCH so those reach that fail-closed path instead of arriving\n as a bare `'Array'`. Runtime transports read\n `ObjectRegistry.resolveRuntimeMethod()`, which tries the item class's manifest\n entry, then the COLLECTION class's (where a collection-hosted action's\n parameters live), then the live `@method()` store — keyed by CONSTRUCTOR,\n never by simple name (`Account` exists in two packages), and recording\n `isStatic` because an unscanned runtime has no manifest to recover the\n receiver from. `isRestActionRoutable` ANDs \"declared\" with that receiver, so\n it never predicts `allow` for an action dispatch refuses. A declared action\n with no receiver is refused (501 collection-scoped, 404 item-scoped), never\n allowed to fall through into `create`. All eight consumers read the resolver,\n including `packages/smrt-workbench/src/discovery.ts`; it imports the\n `./generators/custom-action` LEAF subpath, not `./generators`, because that\n barrel value-re-exports `MCPGenerator` and so drags `@happyvertical/ai` and\n `@happyvertical/sql` into a build-time helper (2 modules vs 109). Keep\n `custom-action.ts` free of value imports outside `tools/tool-generator`.\n\n## Gotchas\n\n- Optional filesystem support stays lazy: use `createFilesystemAdapter()` in\n `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps\n import `@happyvertical/smrt-core/filesystem` at startup. Use\n `importOptionalDependency()` for similarly heavy optional dependencies.\n- Database retries are transient-only, four attempts total for `get`/`upsert`.\n Use `src/db-errors.ts` classifiers through the cause chain, never message\n matching (SDK driver text can live in `context.originalError`). Constraints,\n bad input, missing tables, and aborted PostgreSQL transactions fail immediately.\n Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes\n `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.\n- Property initializers precede option values; options win. Arrays/objects are\n shallow-cloned. Collection creation caches fields; table verification caches\n by DB URL and table. Preserve these scopes when changing initialization.\n- The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild\n core after editing those sources before testing consumer manifest generation.\n Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.\n\n## Validation\n\nRun focused tests first, then applicable package checks:\n\n```bash\npnpm --filter @happyvertical/smrt-core test\npnpm --filter @happyvertical/smrt-core typecheck\npnpm --filter @happyvertical/smrt-core build\npnpm --filter @happyvertical/smrt-core test:postgres\npnpm check:agents-chain\npnpm smrt dev:knowledge-check\n```\n\nThe PostgreSQL lane is required for numeric types, UUID casts, conflict targets,\ntimestamps, and migrations. Tests generate their manifest before Vitest; restart\nwatch mode after adding decorated classes. Documentation-only changes need\ninstruction-chain and knowledge freshness checks, not the runtime test suite.\n",
1050
+ "agentDoc": "# @happyvertical/smrt-core\n\nFoundation ORM, registry, schema/code generation, AI integration, and DispatchBus.\nRead the module for the subsystem being edited; root AGENTS covers shared model\nand repository rules.\n\n## Modules\n\n| Source | Scope | Module doc |\n|---|---|---|\n| `src/object.ts`, `src/collection.ts`, `src/child-accessors.ts` | Lifecycle, hydration, operators, STI, child accessors, dispatch | [agents/object-runtime.md](agents/object-runtime.md) |\n| `src/revision-guard.ts` | Guarded writes and PostgreSQL revision precision | [agents/revision-guard.md](agents/revision-guard.md) |\n| `src/collection.ts` | Projections, latest-related, facets, counts, read plans | [agents/collection-reads.md](agents/collection-reads.md) |\n| `src/collection.ts` | Limits, sort whitelist, generated list order | [agents/query-bounds.md](agents/query-bounds.md) |\n| `src/data-query.ts` | Transport-neutral bounded query normalization | [agents/data-query.md](agents/data-query.md) |\n| `src/schema/`, `src/migrations/`, `src/cascade.ts`, `src/system/` | DDL parity, indexes, migrations, delete integrity, retention | [agents/schema-paths.md](agents/schema-paths.md) |\n| `src/postgres-permissions.ts` | Explicit PostgreSQL ACL plans and atomic reconciliation | [deployment permission contract](../../docs/content/postgres-permissions.md) |\n| `src/change-feed.ts` | Durable changes, cursors, table versions, retention | [agents/change-feed.md](agents/change-feed.md) |\n| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |\n| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |\n| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |\n| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |\n| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |\n| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |\n| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |\n\n## Cross-module invariants\n\n- `ObjectRegistry` is a `globalThis` singleton so registration survives HMR.\n- Per-class registry work on the hydration path (manifest reconciliation in\n `ensureManifestLoaded()`, the simple-name index behind `findClass()`) is\n memoized per registry generation (`src/registry/generation.ts`, #3047). Any\n new writer of registered classes, their field/method metadata, or loaded\n manifests must go through the tracked maps or call\n `bumpRegistryGeneration()`; `issue-3047-hydration-registry-work.test.ts`\n guards that hydration does not repeat this work per row.\n\n- Production DDL uses manifest generators; `getTestDatabase()` uses registry\n generators. Keep columns, indexes, FK actions, and runtime conflict targets in\n parity (`src/schema/schema-path-parity.test.ts`). Every new query predicate\n needs its index or an explicit reason none is needed.\n- Tenant scoping covers every read and every unique/conflict key. Explicit\n conflict columns are not rewritten; their author must include tenant scope.\n- Persisted saves use `id` and loaded `updated_at`; new saves use natural keys.\n Preserve revision compare-and-swap ordering through public `save()`,\n `claimRevision()`, and transaction APIs. Embedded saves, deletes, and complete\n `withTransaction()` callbacks share a process-local write queue.\n- Collection model hydration is serial in result order: initialization may query\n the same transaction-bound PostgreSQL client. Use projections for plain rows.\n- Native DuckDB UUIDs must be cast coherently on read before identity reuse;\n custom embedded revision paths use `getCanonicalPersistedRow()`. Never replay\n a mutation to discover result types.\n- `withDatabase(db, callback)` restores only database bindings (including public\n `options.db`); `withTransaction(callback)` also restores identity/revision\n metadata after rollback. Do not use a bound instance concurrently.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses an advisory lock. Application tables still require migrations;\n runtime table verification checks existence only.\n- Manifest generation fails closed on scanner errors, including unresolved\n decorator spreads. Never emit partial, default-open registration.\n- Generated registration repairs bundled class identity using the exact imported\n constructor, explicit package, and isolated one-object manifest. Never infer\n ownership from paths, simple names, or table names; packages can share names.\n Consumer regression gate: `packages/bundle-gate/src/__tests__/registry-identity.spec.ts`.\n- API custom-action eligibility has ONE resolver, `resolveApiMethodExposure()`\n in `generators/custom-action.ts`: both SvelteKit route emitters,\n `resolveApiActionSet`, and `knowledge.ts` read it, and a new consumer must too\n — a local mirror is how a method gets reported unavailable while its route\n file is still written. A public method routes by default only when every\n parameter is JSON-shaped; `@method({ expose })` overrides in both directions,\n `expose: true` bypasses the heuristic ALONE, and an explicit `api.include` or\n `api.routes` entry keeps its pre-#2686 route. Fail closed on scanner\n uncertainty: read `parameters[].typeUnresolved`, never the `'any'` it\n substitutes. Accepting `Date` as wire-able and hydrating it\n (`toCustomActionDate`) are one decision — changing either breaks the other.\n The runtime `APIGenerator` transport stays declaration-gated: `rest.ts`\n dispatch and `preflight-route.ts` prediction both read\n `declaresRuntimeRestRoute()`, which must accept every `ApiCustomRouteConfig`\n option so a sweep moving one onto its method cannot delete the endpoint. Its\n twin `declaresRuntimeRestRouteShape()` deliberately ignores `expose: false`:\n the dispatcher must still SEE a withheld declaration to answer 404, because\n `POST /<collection>/<segment>` resolves to `create` when nothing claims the\n segment. Split unions and type arguments with `splitTopLevel()` — a naive\n `split('|')` truncates `Record<string, Asset | null>` into fragments that\n match no rule and are then accepted, widening the gate. `extractTypeName`\n returns `null` for a generic with an unresolvable ARGUMENT or a union with an\n unresolvable BRANCH so those reach that fail-closed path instead of arriving\n as a bare `'Array'`. Runtime transports read\n `ObjectRegistry.resolveRuntimeMethod()`, which tries the item class's manifest\n entry, then the COLLECTION class's (where a collection-hosted action's\n parameters live), then the live `@method()` store — keyed by CONSTRUCTOR,\n never by simple name (`Account` exists in two packages), and recording\n `isStatic` because an unscanned runtime has no manifest to recover the\n receiver from. `isRestActionRoutable` ANDs \"declared\" with that receiver, so\n it never predicts `allow` for an action dispatch refuses. A declared action\n with no receiver is refused (501 collection-scoped, 404 item-scoped), never\n allowed to fall through into `create`. All eight consumers read the resolver,\n including `packages/smrt-workbench/src/discovery.ts`; it imports the\n `./generators/custom-action` LEAF subpath, not `./generators`, because that\n barrel value-re-exports `MCPGenerator` and so drags `@happyvertical/ai` and\n `@happyvertical/sql` into a build-time helper (2 modules vs 109). Keep\n `custom-action.ts` free of value imports outside `tools/tool-generator`.\n\n## Gotchas\n\n- Optional filesystem support stays lazy: use `createFilesystemAdapter()` in\n `src/filesystem-loader.ts`, not a static files-SDK import. Fully bundled apps\n import `@happyvertical/smrt-core/filesystem` at startup. Use\n `importOptionalDependency()` for similarly heavy optional dependencies.\n- Database retries are transient-only, four attempts total for `get`/`upsert`.\n Use `src/db-errors.ts` classifiers through the cause chain, never message\n matching (SDK driver text can live in `context.originalError`). Constraints,\n bad input, missing tables, and aborted PostgreSQL transactions fail immediately.\n Unique/PK violations become `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL becomes\n `VALIDATION_REQUIRED_FIELD`; other failures keep the driver error as `cause`.\n- Property initializers precede option values; options win. Arrays/objects are\n shallow-cloned. Collection creation caches fields; table verification caches\n by DB URL and table. Preserve these scopes when changing initialization.\n- The Vite plugin loads scanner/schema code from `dist/` when present. Rebuild\n core after editing those sources before testing consumer manifest generation.\n Vite 8 requires `oxc.decorator: { legacy: true, emitDecoratorMetadata: true }`.\n\n## Validation\n\nRun focused tests first, then applicable package checks:\n\n```bash\npnpm --filter @happyvertical/smrt-core test\npnpm --filter @happyvertical/smrt-core typecheck\npnpm --filter @happyvertical/smrt-core build\npnpm --filter @happyvertical/smrt-core test:postgres\npnpm check:agents-chain\npnpm smrt dev:knowledge-check\n```\n\nThe PostgreSQL lane is required for numeric types, UUID casts, conflict targets,\ntimestamps, and migrations. Tests generate their manifest before Vitest; restart\nwatch mode after adding decorated classes. Documentation-only changes need\ninstruction-chain and knowledge freshness checks, not the runtime test suite.\n",
1051
1051
  "moduleDocs": [
1052
1052
  {
1053
1053
  "path": "agents/object-runtime.md",
@@ -1,5 +1,27 @@
1
1
  //#region src/utils/chunk.ts
2
2
  /**
3
+ * Shared helpers for capping `IN (?, ?, ...)` placeholder lists so a single
4
+ * query never exceeds the backend's bind-variable limit.
5
+ *
6
+ * Used by the relationship/junction loaders in `collection.ts` and by the
7
+ * BFS frontier in `SmrtHierarchical.getDescendants()` — any code path that
8
+ * expands an unbounded array of ids into one `column IN (...)` clause.
9
+ */
10
+ /**
11
+ * Maximum size of an `IN (?, ?, ...)` placeholder list per query.
12
+ *
13
+ * `SQLITE_MAX_VARIABLE_NUMBER` is 999 on SQLite builds older than 3.32 and
14
+ * 32766 on newer ones; Postgres allows up to 65535 bind parameters. Capping
15
+ * at a conservative 900 stays under the legacy SQLite limit AND keeps planner
16
+ * time predictable across every backend, so callers don't have to know which
17
+ * build they're talking to. Exceeding the cap throws
18
+ * `SQLITE_RANGE: bind or column index out of range` (or
19
+ * `SQLITE_ERROR: too many SQL variables`) before the query runs, which is why
20
+ * every loader that builds an IN-list from caller- or data-sized arrays must
21
+ * chunk through this value.
22
+ */
23
+ var IN_LIST_CHUNK_SIZE = 900;
24
+ /**
3
25
  * Split `items` into contiguous batches of at most `size` elements.
4
26
  *
5
27
  * - Empty input yields `[]` (no empty batch).
@@ -15,6 +37,6 @@ function chunkArray(items, size) {
15
37
  return chunks;
16
38
  }
17
39
  //#endregion
18
- export { chunkArray };
40
+ export { IN_LIST_CHUNK_SIZE, chunkArray };
19
41
 
20
42
  //# sourceMappingURL=chunk.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chunk.js","names":[],"sources":["../../src/utils/chunk.ts"],"sourcesContent":["/**\n * Shared helpers for capping `IN (?, ?, ...)` placeholder lists so a single\n * query never exceeds the backend's bind-variable limit.\n *\n * Used by the relationship/junction loaders in `collection.ts` and by the\n * BFS frontier in `SmrtHierarchical.getDescendants()` — any code path that\n * expands an unbounded array of ids into one `column IN (...)` clause.\n */\n\n/**\n * Maximum size of an `IN (?, ?, ...)` placeholder list per query.\n *\n * `SQLITE_MAX_VARIABLE_NUMBER` is 999 on SQLite builds older than 3.32 and\n * 32766 on newer ones; Postgres allows up to 65535 bind parameters. Capping\n * at a conservative 900 stays under the legacy SQLite limit AND keeps planner\n * time predictable across every backend, so callers don't have to know which\n * build they're talking to. Exceeding the cap throws\n * `SQLITE_RANGE: bind or column index out of range` (or\n * `SQLITE_ERROR: too many SQL variables`) before the query runs, which is why\n * every loader that builds an IN-list from caller- or data-sized arrays must\n * chunk through this value.\n */\nexport const IN_LIST_CHUNK_SIZE = 900;\n\n/**\n * Split `items` into contiguous batches of at most `size` elements.\n *\n * - Empty input yields `[]` (no empty batch).\n * - A non-positive `size` is treated as \"no chunking\" and returns a single\n * batch containing every item, matching the previous inline behavior in\n * `collection.ts`.\n */\nexport function chunkArray<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n if (size <= 0) return [items];\n const chunks: T[][] = [];\n for (let i = 0; i < items.length; i += size) {\n chunks.push(items.slice(i, i + size));\n }\n return chunks;\n}\n"],"mappings":";;;;;;;;;AAgCA,SAAgB,WAAc,OAAY,MAAqB;CAC7D,IAAI,MAAM,WAAW,GAAG,OAAO,CAAC;CAChC,IAAI,QAAQ,GAAG,OAAO,CAAC,KAAK;CAC5B,MAAM,SAAgB,CAAC;CACvB,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,MACrC,OAAO,KAAK,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC;CAEtC,OAAO;AACT"}
1
+ {"version":3,"file":"chunk.js","names":[],"sources":["../../src/utils/chunk.ts"],"sourcesContent":["/**\n * Shared helpers for capping `IN (?, ?, ...)` placeholder lists so a single\n * query never exceeds the backend's bind-variable limit.\n *\n * Used by the relationship/junction loaders in `collection.ts` and by the\n * BFS frontier in `SmrtHierarchical.getDescendants()` — any code path that\n * expands an unbounded array of ids into one `column IN (...)` clause.\n */\n\n/**\n * Maximum size of an `IN (?, ?, ...)` placeholder list per query.\n *\n * `SQLITE_MAX_VARIABLE_NUMBER` is 999 on SQLite builds older than 3.32 and\n * 32766 on newer ones; Postgres allows up to 65535 bind parameters. Capping\n * at a conservative 900 stays under the legacy SQLite limit AND keeps planner\n * time predictable across every backend, so callers don't have to know which\n * build they're talking to. Exceeding the cap throws\n * `SQLITE_RANGE: bind or column index out of range` (or\n * `SQLITE_ERROR: too many SQL variables`) before the query runs, which is why\n * every loader that builds an IN-list from caller- or data-sized arrays must\n * chunk through this value.\n */\nexport const IN_LIST_CHUNK_SIZE = 900;\n\n/**\n * Split `items` into contiguous batches of at most `size` elements.\n *\n * - Empty input yields `[]` (no empty batch).\n * - A non-positive `size` is treated as \"no chunking\" and returns a single\n * batch containing every item, matching the previous inline behavior in\n * `collection.ts`.\n */\nexport function chunkArray<T>(items: T[], size: number): T[][] {\n if (items.length === 0) return [];\n if (size <= 0) return [items];\n const chunks: T[][] = [];\n for (let i = 0; i < items.length; i += size) {\n chunks.push(items.slice(i, i + size));\n }\n return chunks;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAsBA,IAAa,qBAAqB;;;;;;;;;AAUlC,SAAgB,WAAc,OAAY,MAAqB;CAC7D,IAAI,MAAM,WAAW,GAAG,OAAO,CAAC;CAChC,IAAI,QAAQ,GAAG,OAAO,CAAC,KAAK;CAC5B,MAAM,SAAgB,CAAC;CACvB,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK,MACrC,OAAO,KAAK,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC;CAEtC,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-core",
3
- "version": "0.51.17",
3
+ "version": "0.51.19",
4
4
  "description": "Core AI agent framework with standardized collections, object-relational mapping, and code generators",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -154,9 +154,9 @@
154
154
  "@happyvertical/files": "^0.89.12",
155
155
  "@happyvertical/json": "^0.89.12",
156
156
  "@happyvertical/logger": "^0.89.12",
157
- "@happyvertical/smrt-config": "0.51.17",
158
- "@happyvertical/smrt-scanner": "0.51.17",
159
- "@happyvertical/smrt-types": "0.51.17",
157
+ "@happyvertical/smrt-config": "0.51.19",
158
+ "@happyvertical/smrt-scanner": "0.51.19",
159
+ "@happyvertical/smrt-types": "0.51.19",
160
160
  "@happyvertical/sql": "^0.89.12",
161
161
  "@happyvertical/utils": "^0.89.12",
162
162
  "@oxc-project/runtime": "^0.150.0",