@happyvertical/smrt-core 0.52.0 → 0.53.0

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 (63) hide show
  1. package/AGENTS.md +1 -0
  2. package/README.md +8 -2
  3. package/agents/model-moves.md +114 -0
  4. package/agents/object-runtime.md +3 -1
  5. package/agents/run-once.md +16 -7
  6. package/dist/cascade.d.ts.map +1 -1
  7. package/dist/cascade.js +4 -2
  8. package/dist/cascade.js.map +1 -1
  9. package/dist/generators/preflight-route.d.ts.map +1 -1
  10. package/dist/generators/preflight-route.js +1 -1
  11. package/dist/generators/preflight-route.js.map +1 -1
  12. package/dist/knowledge.d.ts.map +1 -1
  13. package/dist/knowledge.js +3 -0
  14. package/dist/knowledge.js.map +1 -1
  15. package/dist/manifest/static-manifest.js +1 -1
  16. package/dist/manifest/static-manifest.js.map +1 -1
  17. package/dist/manifest/store.js +1 -1
  18. package/dist/manifest.json +1 -1
  19. package/dist/migrations/index.d.ts +1 -0
  20. package/dist/migrations/index.d.ts.map +1 -1
  21. package/dist/migrations/index.js +2 -1
  22. package/dist/migrations/qualified-name-aliases.d.ts +134 -0
  23. package/dist/migrations/qualified-name-aliases.d.ts.map +1 -0
  24. package/dist/migrations/qualified-name-aliases.js +294 -0
  25. package/dist/migrations/qualified-name-aliases.js.map +1 -0
  26. package/dist/migrations.js +2 -1
  27. package/dist/polymorphic-association.d.ts +18 -0
  28. package/dist/polymorphic-association.d.ts.map +1 -1
  29. package/dist/polymorphic-association.js +27 -2
  30. package/dist/polymorphic-association.js.map +1 -1
  31. package/dist/registry/class-registration.d.ts.map +1 -1
  32. package/dist/registry/class-registration.js +27 -4
  33. package/dist/registry/class-registration.js.map +1 -1
  34. package/dist/registry/name-resolver.d.ts +8 -2
  35. package/dist/registry/name-resolver.d.ts.map +1 -1
  36. package/dist/registry/name-resolver.js +17 -8
  37. package/dist/registry/name-resolver.js.map +1 -1
  38. package/dist/registry/qualified-name-aliases.d.ts +109 -0
  39. package/dist/registry/qualified-name-aliases.d.ts.map +1 -0
  40. package/dist/registry/qualified-name-aliases.js +286 -0
  41. package/dist/registry/qualified-name-aliases.js.map +1 -0
  42. package/dist/registry/relationship-graph.d.ts.map +1 -1
  43. package/dist/registry/relationship-graph.js +1 -1
  44. package/dist/registry/relationship-graph.js.map +1 -1
  45. package/dist/registry/types.d.ts +26 -0
  46. package/dist/registry/types.d.ts.map +1 -1
  47. package/dist/registry.d.ts +59 -2
  48. package/dist/registry.d.ts.map +1 -1
  49. package/dist/registry.js +115 -6
  50. package/dist/registry.js.map +1 -1
  51. package/dist/scanner/manifest-generator.d.ts +11 -0
  52. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  53. package/dist/scanner/manifest-generator.js +36 -1
  54. package/dist/scanner/manifest-generator.js.map +1 -1
  55. package/dist/schema/conflict-target.js +8 -1
  56. package/dist/schema/conflict-target.js.map +1 -1
  57. package/dist/scripts/browser-plain-json-regression.mjs +4 -1
  58. package/dist/smrt-knowledge.json +15 -9
  59. package/dist/utils/qualified-names.d.ts +11 -0
  60. package/dist/utils/qualified-names.d.ts.map +1 -1
  61. package/dist/utils/qualified-names.js +21 -1
  62. package/dist/utils/qualified-names.js.map +1 -1
  63. package/package.json +11 -11
@@ -154,6 +154,13 @@ function resolveTenantColumn(tenantField, hasField, toColumn) {
154
154
  if (!tenantField || !hasField(tenantField)) return void 0;
155
155
  return toColumn(tenantField);
156
156
  }
157
+ /**
158
+ * The field whose mere presence marks a table's rows as tenant-owned when the
159
+ * class declares no tenancy (`tenantScoped` / `@TenantScoped()` / `@tenantId`).
160
+ * It is the tenancy default field name, so it is also the field a consumer's
161
+ * `registerTenantScopedClass(name)` filters and populates at runtime.
162
+ */
163
+ var IMPLICIT_TENANT_FIELD = "tenantId";
157
164
  /** Column of {@link IMPLICIT_TENANT_FIELD}. */
158
165
  var IMPLICIT_TENANT_COLUMN = "tenant_id";
159
166
  /**
@@ -188,6 +195,6 @@ function nullableConflictIdentity(conflictColumns, columns) {
188
195
  });
189
196
  }
190
197
  //#endregion
191
- export { DEFAULT_CTI_CONFLICT_COLUMNS, DEFAULT_STI_CONFLICT_COLUMNS, IMPLICIT_TENANT_COLUMN, conflictIndexName, defaultConflictColumns, isDefaultNaturalKey, isLegacyNaturalKeyIndex, nullableConflictIdentity, resolveConflictColumns, resolveOwnershipTenantColumn, resolveTenantColumn, servesSlugLookup };
198
+ export { DEFAULT_CTI_CONFLICT_COLUMNS, DEFAULT_STI_CONFLICT_COLUMNS, IMPLICIT_TENANT_COLUMN, IMPLICIT_TENANT_FIELD, conflictIndexName, defaultConflictColumns, isDefaultNaturalKey, isLegacyNaturalKeyIndex, nullableConflictIdentity, resolveConflictColumns, resolveOwnershipTenantColumn, resolveTenantColumn, servesSlugLookup };
192
199
 
193
200
  //# sourceMappingURL=conflict-target.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"conflict-target.js","names":[],"sources":["../../src/schema/conflict-target.ts"],"sourcesContent":["/**\n * Conflict target (upsert natural key) resolution shared by every schema path\n * and by the runtime (#2360, epic #2382 finding B1/A4).\n *\n * `SmrtObject.save()` upserts a NEW object on\n * `ObjectRegistry.getConflictColumns()`; the schema generator emits ONE unique\n * index over the same columns so `INSERT … ON CONFLICT (…)` binds on every\n * engine. Before #2360 the tenancy interceptor populated `tenant_id` on save\n * but neither the conflict target nor the unique index carried it, so a\n * second tenant creating the same natural key (`slug`, `context`) matched the\n * first tenant's row and `DO UPDATE SET` rewrote its `id` and `tenant_id`\n * (probed: tenant A's row was silently adopted by tenant B).\n *\n * The rule now: **a tenant-scoped class's default conflict target is\n * `[tenantColumn, ...naturalKey]`** on the build path (manifest\n * `decoratorConfig.conflictColumns`, materialized by\n * `ManifestGenerator.normalizeConflictColumns()`) and on the runtime path\n * (`ObjectRegistry.getConflictColumns()`), so schema and upsert agree by\n * construction. Explicit `@smrt({ conflictColumns })` always wins and is\n * never rewritten — a class that declares its own natural key owns it.\n *\n * NULL-tenant rows (`mode: 'optional'` outside a tenant context) keep the\n * SDK's null-aware upsert semantics: `@happyvertical/sql` matches conflict\n * columns with `IS NOT DISTINCT FROM`, so `(NULL, 'widget', '')` updates the\n * existing `(NULL, 'widget', '')` row through the model layer. PostgreSQL 15+\n * framework indexes additionally enforce this with NULLS NOT DISTINCT (#2834);\n * older engines and unmigrated indexes retain the fallback. Global rows dedup among\n * themselves exactly as every row did before, and per-tenant rows dedup per\n * tenant.\n *\n * Index names:\n *\n * - A DECLARED tenant-scoped class (#2360) keeps `<table>_slug_context_idx`\n * (CTI) / `<table>_slug_context_meta_type_idx` (STI) for its tenant-led key.\n * That swap shipped long ago; live databases carry the tenant-led columns\n * under that name, and `db:migrate-null-equal-indexes` targets it.\n * - A class that is tenant-owned only by INFERENCE (an undeclared `tenantId`\n * field, {@link resolveOwnershipTenantColumn}) gets its tenant-led key under\n * a NEW name, `<table>_tenant_id_slug_idx`, created beside the legacy global\n * `<table>_slug_context_idx` (expand, then contract). Old code upserts\n * `ON CONFLICT (slug, context)` and new code `ON CONFLICT (tenant_id, slug,\n * context)`; PostgreSQL binds each only to a unique index over exactly those\n * columns, so during a rolling deploy both indexes must exist. The differ\n * keeps the legacy index ({@link isLegacyNaturalKeyIndex}) until the operator\n * runs `smrt db:migrate --drop-legacy-natural-key` once no old code is left.\n * The new key is a superset of the old one, so creating it cannot fail on\n * existing data.\n */\n\n/** Default natural key of a class-per-table (CTI) object. */\nexport const DEFAULT_CTI_CONFLICT_COLUMNS: readonly string[] = [\n 'slug',\n 'context',\n];\n\n/**\n * Default natural key of a single-table-inheritance (STI) hierarchy: the\n * discriminator participates in identity so two subtypes may share a slug.\n */\nexport const DEFAULT_STI_CONFLICT_COLUMNS: readonly string[] = [\n 'slug',\n 'context',\n '_meta_type',\n];\n\nexport type ConflictTableStrategy = 'cti' | 'sti';\n\n/**\n * The default conflict target for a table with the given strategy, led by the\n * tenant column when the schema-owning class is tenant-scoped.\n */\nexport function defaultConflictColumns(\n strategy: ConflictTableStrategy,\n tenantColumn?: string | null,\n): string[] {\n const naturalKey =\n strategy === 'sti'\n ? [...DEFAULT_STI_CONFLICT_COLUMNS]\n : [...DEFAULT_CTI_CONFLICT_COLUMNS];\n return tenantColumn ? [tenantColumn, ...naturalKey] : naturalKey;\n}\n\n/**\n * Resolve the conflict target of a table.\n *\n * `explicit` (a declared `@smrt({ conflictColumns })`) always wins; otherwise\n * the strategy default, tenant-led when `tenantColumn` is given.\n */\nexport function resolveConflictColumns(options: {\n explicit?: readonly string[] | null;\n strategy: ConflictTableStrategy;\n tenantColumn?: string | null;\n}): string[] {\n if (options.explicit && options.explicit.length > 0) {\n return [...options.explicit];\n }\n return defaultConflictColumns(options.strategy, options.tenantColumn);\n}\n\nfunction sameColumns(a: readonly string[], b: readonly string[]): boolean {\n return a.length === b.length && a.every((column, i) => column === b[i]);\n}\n\n/**\n * Whether `conflictColumns` is exactly a default natural key (CTI or STI),\n * optionally led by `tenantColumn`. Used to keep the default index names\n * stable when the tenant column is prepended.\n */\nexport function isDefaultNaturalKey(\n conflictColumns: readonly string[],\n tenantColumn?: string | null,\n): boolean {\n const key =\n tenantColumn && conflictColumns[0] === tenantColumn\n ? conflictColumns.slice(1)\n : conflictColumns;\n return (\n sameColumns(key, DEFAULT_CTI_CONFLICT_COLUMNS) ||\n sameColumns(key, DEFAULT_STI_CONFLICT_COLUMNS)\n );\n}\n\n/**\n * The stable name of the unique conflict index for `conflictColumns`.\n *\n * - default CTI key `(slug, context)` → `<table>_slug_context_idx`\n * - default STI key `(slug, context, _meta_type)` →\n * `<table>_slug_context_meta_type_idx`\n * - either default led by the DECLARED tenant column → the SAME name as\n * without it (#2360: the differ swapped the columns in place by name)\n * - either default led by an INFERRED ownership column (pass no\n * `tenantColumn`) → the custom-key rule, `<table>_tenant_id_slug_idx`, so it\n * is created beside the legacy global index instead of replacing it\n * - any custom key → `<table>_<first two columns>_idx` (the historical rule;\n * a longer key keeps the two-column name so existing databases are not\n * renamed)\n *\n * @param tenantColumn - the table's DECLARED tenant column, when the schema\n * owner is tenant-scoped; only consulted to recognize a tenant-led default\n * key. Never pass an inferred ownership column here.\n */\nexport function conflictIndexName(\n tableName: string,\n conflictColumns: readonly string[],\n tenantColumn?: string | null,\n): string {\n const key =\n tenantColumn &&\n conflictColumns[0] === tenantColumn &&\n isDefaultNaturalKey(conflictColumns, tenantColumn)\n ? conflictColumns.slice(1)\n : conflictColumns;\n if (sameColumns(key, DEFAULT_STI_CONFLICT_COLUMNS)) {\n return `${tableName}_slug_context_meta_type_idx`;\n }\n const nameColumns = key.length > 2 ? key.slice(0, 2) : key;\n return `${tableName}_${nameColumns.join('_')}_idx`;\n}\n\n/**\n * Whether a live unique index over `indexColumns` is the LEGACY global natural\n * key that a tenant-led default key superseded: its columns are exactly a\n * default natural key (CTI `(slug, context)` / STI `(slug, context,\n * _meta_type)`) and `widerColumns` is that same key led by one ownership\n * column.\n *\n * The differ keeps such an index (expand, then contract) so code still\n * upserting `ON CONFLICT (slug, context)` keeps working while a release that\n * upserts on the tenant-led key rolls out; it is dropped only on\n * `smrt db:migrate --drop-legacy-natural-key`.\n */\nexport function isLegacyNaturalKeyIndex(\n indexColumns: readonly string[],\n widerColumns: readonly string[],\n): boolean {\n if (\n !sameColumns(indexColumns, DEFAULT_CTI_CONFLICT_COLUMNS) &&\n !sameColumns(indexColumns, DEFAULT_STI_CONFLICT_COLUMNS)\n ) {\n return false;\n }\n return (\n widerColumns.length === indexColumns.length + 1 &&\n sameColumns(widerColumns.slice(1), indexColumns)\n );\n}\n\n/**\n * Whether an unqualified index over `indexColumns` serves slug lookups\n * (`loadFromSlug()` / `getId()` / `getSavedId()` filter on `slug`, `context`):\n * it leads with `slug`, or it is the tenant-led default key — a tenant-scoped\n * lookup carries the tenant predicate (#2365) and is served by the prefix.\n */\nexport function servesSlugLookup(\n indexColumns: readonly string[],\n tenantColumn?: string | null,\n): boolean {\n if (indexColumns[0] === 'slug') return true;\n return Boolean(\n tenantColumn &&\n indexColumns[0] === tenantColumn &&\n indexColumns[1] === 'slug' &&\n indexColumns[2] === 'context',\n );\n}\n\n/**\n * The tenant column of a table, from the schema-owning class's normalized\n * tenant-scoped configuration. Returns `undefined` when the class is not\n * tenant-scoped or the configured field is not among its fields (the field is\n * always injected for tenant-scoped classes; the guard keeps the runtime and\n * build paths honest about the column actually existing).\n *\n * @param tenantField - the configured tenant field name (`tenantScoped.field`,\n * default `tenantId`), or `undefined` when the class is not tenant-scoped\n * @param hasField - whether the schema owner declares that field\n * @param toColumn - field-name → column-name conversion (`toSnakeCase`)\n */\nexport function resolveTenantColumn(\n tenantField: string | undefined | null,\n hasField: (fieldName: string) => boolean,\n toColumn: (fieldName: string) => string,\n): string | undefined {\n if (!tenantField || !hasField(tenantField)) return undefined;\n return toColumn(tenantField);\n}\n\n/**\n * The field whose mere presence marks a table's rows as tenant-owned when the\n * class declares no tenancy (`tenantScoped` / `@TenantScoped()` / `@tenantId`).\n * It is the tenancy default field name, so it is also the field a consumer's\n * `registerTenantScopedClass(name)` filters and populates at runtime.\n */\nexport const IMPLICIT_TENANT_FIELD = 'tenantId';\n\n/** Column of {@link IMPLICIT_TENANT_FIELD}. */\nexport const IMPLICIT_TENANT_COLUMN = 'tenant_id';\n\n/**\n * The column that owns a table's rows for natural-key identity: the declared\n * tenant column, otherwise the implicit `tenant_id` column when the schema\n * owner has a `tenantId` field.\n *\n * Before this rule a class that carried `tenantId` but did not DECLARE tenancy\n * (the consumer registered it with the tenancy interceptor at runtime instead)\n * kept the global `(slug, context)` key. The interceptor filtered its lookups\n * by tenant, so tenant B's lookup missed tenant A's same-slug row and B's\n * `save()` upserted onto it: `DO UPDATE SET` rewrote A's `id` and `tenant_id`\n * and `ON UPDATE CASCADE` re-pointed A's children at B's row. Rows that carry\n * a tenant are unique per tenant whether or not reads are filtered, so the\n * default natural key follows ownership, not the read policy. Only the\n * DEFAULT key uses this; an explicit `@smrt({ conflictColumns })` is the\n * author's contract and is never rewritten.\n *\n * @param declaredTenantColumn - the column from the class's tenancy\n * declaration ({@link resolveTenantColumn}), when it has one\n * @param hasColumn - whether the schema owner has the given column\n */\nexport function resolveOwnershipTenantColumn(\n declaredTenantColumn: string | undefined | null,\n hasColumn: (columnName: string) => boolean,\n): string | undefined {\n if (declaredTenantColumn) return declaredTenantColumn;\n return hasColumn(IMPLICIT_TENANT_COLUMN) ? IMPLICIT_TENANT_COLUMN : undefined;\n}\n\n/** Only the framework conflict identity opts into NULL-equal uniqueness. */\nexport function nullableConflictIdentity(\n conflictColumns: readonly string[],\n columns: Record<\n string,\n { notNull?: boolean; primaryKey?: boolean } | undefined\n >,\n): boolean {\n return conflictColumns.some((name) => {\n const column = columns[name];\n return column !== undefined && !column.notNull && !column.primaryKey;\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkDA,IAAa,+BAAkD,CAC7D,QACA,SACF;;;;;AAMA,IAAa,+BAAkD;CAC7D;CACA;CACA;AACF;;;;;AAQA,SAAgB,uBACd,UACA,cACU;CACV,MAAM,aACJ,aAAa,QACT,CAAC,GAAG,4BAA4B,IAChC,CAAC,GAAG,4BAA4B;CACtC,OAAO,eAAe,CAAC,cAAc,GAAG,UAAU,IAAI;AACxD;;;;;;;AAQA,SAAgB,uBAAuB,SAI1B;CACX,IAAI,QAAQ,YAAY,QAAQ,SAAS,SAAS,GAChD,OAAO,CAAC,GAAG,QAAQ,QAAQ;CAE7B,OAAO,uBAAuB,QAAQ,UAAU,QAAQ,YAAY;AACtE;AAEA,SAAS,YAAY,GAAsB,GAA+B;CACxE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,QAAQ,MAAM,WAAW,EAAE,EAAE;AACxE;;;;;;AAOA,SAAgB,oBACd,iBACA,cACS;CACT,MAAM,MACJ,gBAAgB,gBAAgB,OAAO,eACnC,gBAAgB,MAAM,CAAC,IACvB;CACN,OACE,YAAY,KAAK,4BAA4B,KAC7C,YAAY,KAAK,4BAA4B;AAEjD;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,kBACd,WACA,iBACA,cACQ;CACR,MAAM,MACJ,gBACA,gBAAgB,OAAO,gBACvB,oBAAoB,iBAAiB,YAAY,IAC7C,gBAAgB,MAAM,CAAC,IACvB;CACN,IAAI,YAAY,KAAK,4BAA4B,GAC/C,OAAO,GAAG,UAAU;CAGtB,OAAO,GAAG,UAAU,IADA,IAAI,SAAS,IAAI,IAAI,MAAM,GAAG,CAAC,IAAI,IAAA,CACpB,KAAK,GAAG,EAAE;AAC/C;;;;;;;;;;;;;AAcA,SAAgB,wBACd,cACA,cACS;CACT,IACE,CAAC,YAAY,cAAc,4BAA4B,KACvD,CAAC,YAAY,cAAc,4BAA4B,GAEvD,OAAO;CAET,OACE,aAAa,WAAW,aAAa,SAAS,KAC9C,YAAY,aAAa,MAAM,CAAC,GAAG,YAAY;AAEnD;;;;;;;AAQA,SAAgB,iBACd,cACA,cACS;CACT,IAAI,aAAa,OAAO,QAAQ,OAAO;CACvC,OAAO,QACL,gBACE,aAAa,OAAO,gBACpB,aAAa,OAAO,UACpB,aAAa,OAAO,SACxB;AACF;;;;;;;;;;;;;AAcA,SAAgB,oBACd,aACA,UACA,UACoB;CACpB,IAAI,CAAC,eAAe,CAAC,SAAS,WAAW,GAAG,OAAO,KAAA;CACnD,OAAO,SAAS,WAAW;AAC7B;;AAWA,IAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;AAsBtC,SAAgB,6BACd,sBACA,WACoB;CACpB,IAAI,sBAAsB,OAAO;CACjC,OAAO,UAAA,WAAgC,IAAI,yBAAyB,KAAA;AACtE;;AAGA,SAAgB,yBACd,iBACA,SAIS;CACT,OAAO,gBAAgB,MAAM,SAAS;EACpC,MAAM,SAAS,QAAQ;EACvB,OAAO,WAAW,KAAA,KAAa,CAAC,OAAO,WAAW,CAAC,OAAO;CAC5D,CAAC;AACH"}
1
+ {"version":3,"file":"conflict-target.js","names":[],"sources":["../../src/schema/conflict-target.ts"],"sourcesContent":["/**\n * Conflict target (upsert natural key) resolution shared by every schema path\n * and by the runtime (#2360, epic #2382 finding B1/A4).\n *\n * `SmrtObject.save()` upserts a NEW object on\n * `ObjectRegistry.getConflictColumns()`; the schema generator emits ONE unique\n * index over the same columns so `INSERT … ON CONFLICT (…)` binds on every\n * engine. Before #2360 the tenancy interceptor populated `tenant_id` on save\n * but neither the conflict target nor the unique index carried it, so a\n * second tenant creating the same natural key (`slug`, `context`) matched the\n * first tenant's row and `DO UPDATE SET` rewrote its `id` and `tenant_id`\n * (probed: tenant A's row was silently adopted by tenant B).\n *\n * The rule now: **a tenant-scoped class's default conflict target is\n * `[tenantColumn, ...naturalKey]`** on the build path (manifest\n * `decoratorConfig.conflictColumns`, materialized by\n * `ManifestGenerator.normalizeConflictColumns()`) and on the runtime path\n * (`ObjectRegistry.getConflictColumns()`), so schema and upsert agree by\n * construction. Explicit `@smrt({ conflictColumns })` always wins and is\n * never rewritten — a class that declares its own natural key owns it.\n *\n * NULL-tenant rows (`mode: 'optional'` outside a tenant context) keep the\n * SDK's null-aware upsert semantics: `@happyvertical/sql` matches conflict\n * columns with `IS NOT DISTINCT FROM`, so `(NULL, 'widget', '')` updates the\n * existing `(NULL, 'widget', '')` row through the model layer. PostgreSQL 15+\n * framework indexes additionally enforce this with NULLS NOT DISTINCT (#2834);\n * older engines and unmigrated indexes retain the fallback. Global rows dedup among\n * themselves exactly as every row did before, and per-tenant rows dedup per\n * tenant.\n *\n * Index names:\n *\n * - A DECLARED tenant-scoped class (#2360) keeps `<table>_slug_context_idx`\n * (CTI) / `<table>_slug_context_meta_type_idx` (STI) for its tenant-led key.\n * That swap shipped long ago; live databases carry the tenant-led columns\n * under that name, and `db:migrate-null-equal-indexes` targets it.\n * - A class that is tenant-owned only by INFERENCE (an undeclared `tenantId`\n * field, {@link resolveOwnershipTenantColumn}) gets its tenant-led key under\n * a NEW name, `<table>_tenant_id_slug_idx`, created beside the legacy global\n * `<table>_slug_context_idx` (expand, then contract). Old code upserts\n * `ON CONFLICT (slug, context)` and new code `ON CONFLICT (tenant_id, slug,\n * context)`; PostgreSQL binds each only to a unique index over exactly those\n * columns, so during a rolling deploy both indexes must exist. The differ\n * keeps the legacy index ({@link isLegacyNaturalKeyIndex}) until the operator\n * runs `smrt db:migrate --drop-legacy-natural-key` once no old code is left.\n * The new key is a superset of the old one, so creating it cannot fail on\n * existing data.\n */\n\n/** Default natural key of a class-per-table (CTI) object. */\nexport const DEFAULT_CTI_CONFLICT_COLUMNS: readonly string[] = [\n 'slug',\n 'context',\n];\n\n/**\n * Default natural key of a single-table-inheritance (STI) hierarchy: the\n * discriminator participates in identity so two subtypes may share a slug.\n */\nexport const DEFAULT_STI_CONFLICT_COLUMNS: readonly string[] = [\n 'slug',\n 'context',\n '_meta_type',\n];\n\nexport type ConflictTableStrategy = 'cti' | 'sti';\n\n/**\n * The default conflict target for a table with the given strategy, led by the\n * tenant column when the schema-owning class is tenant-scoped.\n */\nexport function defaultConflictColumns(\n strategy: ConflictTableStrategy,\n tenantColumn?: string | null,\n): string[] {\n const naturalKey =\n strategy === 'sti'\n ? [...DEFAULT_STI_CONFLICT_COLUMNS]\n : [...DEFAULT_CTI_CONFLICT_COLUMNS];\n return tenantColumn ? [tenantColumn, ...naturalKey] : naturalKey;\n}\n\n/**\n * Resolve the conflict target of a table.\n *\n * `explicit` (a declared `@smrt({ conflictColumns })`) always wins; otherwise\n * the strategy default, tenant-led when `tenantColumn` is given.\n */\nexport function resolveConflictColumns(options: {\n explicit?: readonly string[] | null;\n strategy: ConflictTableStrategy;\n tenantColumn?: string | null;\n}): string[] {\n if (options.explicit && options.explicit.length > 0) {\n return [...options.explicit];\n }\n return defaultConflictColumns(options.strategy, options.tenantColumn);\n}\n\nfunction sameColumns(a: readonly string[], b: readonly string[]): boolean {\n return a.length === b.length && a.every((column, i) => column === b[i]);\n}\n\n/**\n * Whether `conflictColumns` is exactly a default natural key (CTI or STI),\n * optionally led by `tenantColumn`. Used to keep the default index names\n * stable when the tenant column is prepended.\n */\nexport function isDefaultNaturalKey(\n conflictColumns: readonly string[],\n tenantColumn?: string | null,\n): boolean {\n const key =\n tenantColumn && conflictColumns[0] === tenantColumn\n ? conflictColumns.slice(1)\n : conflictColumns;\n return (\n sameColumns(key, DEFAULT_CTI_CONFLICT_COLUMNS) ||\n sameColumns(key, DEFAULT_STI_CONFLICT_COLUMNS)\n );\n}\n\n/**\n * The stable name of the unique conflict index for `conflictColumns`.\n *\n * - default CTI key `(slug, context)` → `<table>_slug_context_idx`\n * - default STI key `(slug, context, _meta_type)` →\n * `<table>_slug_context_meta_type_idx`\n * - either default led by the DECLARED tenant column → the SAME name as\n * without it (#2360: the differ swapped the columns in place by name)\n * - either default led by an INFERRED ownership column (pass no\n * `tenantColumn`) → the custom-key rule, `<table>_tenant_id_slug_idx`, so it\n * is created beside the legacy global index instead of replacing it\n * - any custom key → `<table>_<first two columns>_idx` (the historical rule;\n * a longer key keeps the two-column name so existing databases are not\n * renamed)\n *\n * @param tenantColumn - the table's DECLARED tenant column, when the schema\n * owner is tenant-scoped; only consulted to recognize a tenant-led default\n * key. Never pass an inferred ownership column here.\n */\nexport function conflictIndexName(\n tableName: string,\n conflictColumns: readonly string[],\n tenantColumn?: string | null,\n): string {\n const key =\n tenantColumn &&\n conflictColumns[0] === tenantColumn &&\n isDefaultNaturalKey(conflictColumns, tenantColumn)\n ? conflictColumns.slice(1)\n : conflictColumns;\n if (sameColumns(key, DEFAULT_STI_CONFLICT_COLUMNS)) {\n return `${tableName}_slug_context_meta_type_idx`;\n }\n const nameColumns = key.length > 2 ? key.slice(0, 2) : key;\n return `${tableName}_${nameColumns.join('_')}_idx`;\n}\n\n/**\n * Whether a live unique index over `indexColumns` is the LEGACY global natural\n * key that a tenant-led default key superseded: its columns are exactly a\n * default natural key (CTI `(slug, context)` / STI `(slug, context,\n * _meta_type)`) and `widerColumns` is that same key led by one ownership\n * column.\n *\n * The differ keeps such an index (expand, then contract) so code still\n * upserting `ON CONFLICT (slug, context)` keeps working while a release that\n * upserts on the tenant-led key rolls out; it is dropped only on\n * `smrt db:migrate --drop-legacy-natural-key`.\n */\nexport function isLegacyNaturalKeyIndex(\n indexColumns: readonly string[],\n widerColumns: readonly string[],\n): boolean {\n if (\n !sameColumns(indexColumns, DEFAULT_CTI_CONFLICT_COLUMNS) &&\n !sameColumns(indexColumns, DEFAULT_STI_CONFLICT_COLUMNS)\n ) {\n return false;\n }\n return (\n widerColumns.length === indexColumns.length + 1 &&\n sameColumns(widerColumns.slice(1), indexColumns)\n );\n}\n\n/**\n * Whether an unqualified index over `indexColumns` serves slug lookups\n * (`loadFromSlug()` / `getId()` / `getSavedId()` filter on `slug`, `context`):\n * it leads with `slug`, or it is the tenant-led default key — a tenant-scoped\n * lookup carries the tenant predicate (#2365) and is served by the prefix.\n */\nexport function servesSlugLookup(\n indexColumns: readonly string[],\n tenantColumn?: string | null,\n): boolean {\n if (indexColumns[0] === 'slug') return true;\n return Boolean(\n tenantColumn &&\n indexColumns[0] === tenantColumn &&\n indexColumns[1] === 'slug' &&\n indexColumns[2] === 'context',\n );\n}\n\n/**\n * The tenant column of a table, from the schema-owning class's normalized\n * tenant-scoped configuration. Returns `undefined` when the class is not\n * tenant-scoped or the configured field is not among its fields (the field is\n * always injected for tenant-scoped classes; the guard keeps the runtime and\n * build paths honest about the column actually existing).\n *\n * @param tenantField - the configured tenant field name (`tenantScoped.field`,\n * default `tenantId`), or `undefined` when the class is not tenant-scoped\n * @param hasField - whether the schema owner declares that field\n * @param toColumn - field-name → column-name conversion (`toSnakeCase`)\n */\nexport function resolveTenantColumn(\n tenantField: string | undefined | null,\n hasField: (fieldName: string) => boolean,\n toColumn: (fieldName: string) => string,\n): string | undefined {\n if (!tenantField || !hasField(tenantField)) return undefined;\n return toColumn(tenantField);\n}\n\n/**\n * The field whose mere presence marks a table's rows as tenant-owned when the\n * class declares no tenancy (`tenantScoped` / `@TenantScoped()` / `@tenantId`).\n * It is the tenancy default field name, so it is also the field a consumer's\n * `registerTenantScopedClass(name)` filters and populates at runtime.\n */\nexport const IMPLICIT_TENANT_FIELD = 'tenantId';\n\n/** Column of {@link IMPLICIT_TENANT_FIELD}. */\nexport const IMPLICIT_TENANT_COLUMN = 'tenant_id';\n\n/**\n * The column that owns a table's rows for natural-key identity: the declared\n * tenant column, otherwise the implicit `tenant_id` column when the schema\n * owner has a `tenantId` field.\n *\n * Before this rule a class that carried `tenantId` but did not DECLARE tenancy\n * (the consumer registered it with the tenancy interceptor at runtime instead)\n * kept the global `(slug, context)` key. The interceptor filtered its lookups\n * by tenant, so tenant B's lookup missed tenant A's same-slug row and B's\n * `save()` upserted onto it: `DO UPDATE SET` rewrote A's `id` and `tenant_id`\n * and `ON UPDATE CASCADE` re-pointed A's children at B's row. Rows that carry\n * a tenant are unique per tenant whether or not reads are filtered, so the\n * default natural key follows ownership, not the read policy. Only the\n * DEFAULT key uses this; an explicit `@smrt({ conflictColumns })` is the\n * author's contract and is never rewritten.\n *\n * @param declaredTenantColumn - the column from the class's tenancy\n * declaration ({@link resolveTenantColumn}), when it has one\n * @param hasColumn - whether the schema owner has the given column\n */\nexport function resolveOwnershipTenantColumn(\n declaredTenantColumn: string | undefined | null,\n hasColumn: (columnName: string) => boolean,\n): string | undefined {\n if (declaredTenantColumn) return declaredTenantColumn;\n return hasColumn(IMPLICIT_TENANT_COLUMN) ? IMPLICIT_TENANT_COLUMN : undefined;\n}\n\n/** Only the framework conflict identity opts into NULL-equal uniqueness. */\nexport function nullableConflictIdentity(\n conflictColumns: readonly string[],\n columns: Record<\n string,\n { notNull?: boolean; primaryKey?: boolean } | undefined\n >,\n): boolean {\n return conflictColumns.some((name) => {\n const column = columns[name];\n return column !== undefined && !column.notNull && !column.primaryKey;\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkDA,IAAa,+BAAkD,CAC7D,QACA,SACF;;;;;AAMA,IAAa,+BAAkD;CAC7D;CACA;CACA;AACF;;;;;AAQA,SAAgB,uBACd,UACA,cACU;CACV,MAAM,aACJ,aAAa,QACT,CAAC,GAAG,4BAA4B,IAChC,CAAC,GAAG,4BAA4B;CACtC,OAAO,eAAe,CAAC,cAAc,GAAG,UAAU,IAAI;AACxD;;;;;;;AAQA,SAAgB,uBAAuB,SAI1B;CACX,IAAI,QAAQ,YAAY,QAAQ,SAAS,SAAS,GAChD,OAAO,CAAC,GAAG,QAAQ,QAAQ;CAE7B,OAAO,uBAAuB,QAAQ,UAAU,QAAQ,YAAY;AACtE;AAEA,SAAS,YAAY,GAAsB,GAA+B;CACxE,OAAO,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,QAAQ,MAAM,WAAW,EAAE,EAAE;AACxE;;;;;;AAOA,SAAgB,oBACd,iBACA,cACS;CACT,MAAM,MACJ,gBAAgB,gBAAgB,OAAO,eACnC,gBAAgB,MAAM,CAAC,IACvB;CACN,OACE,YAAY,KAAK,4BAA4B,KAC7C,YAAY,KAAK,4BAA4B;AAEjD;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,kBACd,WACA,iBACA,cACQ;CACR,MAAM,MACJ,gBACA,gBAAgB,OAAO,gBACvB,oBAAoB,iBAAiB,YAAY,IAC7C,gBAAgB,MAAM,CAAC,IACvB;CACN,IAAI,YAAY,KAAK,4BAA4B,GAC/C,OAAO,GAAG,UAAU;CAGtB,OAAO,GAAG,UAAU,IADA,IAAI,SAAS,IAAI,IAAI,MAAM,GAAG,CAAC,IAAI,IAAA,CACpB,KAAK,GAAG,EAAE;AAC/C;;;;;;;;;;;;;AAcA,SAAgB,wBACd,cACA,cACS;CACT,IACE,CAAC,YAAY,cAAc,4BAA4B,KACvD,CAAC,YAAY,cAAc,4BAA4B,GAEvD,OAAO;CAET,OACE,aAAa,WAAW,aAAa,SAAS,KAC9C,YAAY,aAAa,MAAM,CAAC,GAAG,YAAY;AAEnD;;;;;;;AAQA,SAAgB,iBACd,cACA,cACS;CACT,IAAI,aAAa,OAAO,QAAQ,OAAO;CACvC,OAAO,QACL,gBACE,aAAa,OAAO,gBACpB,aAAa,OAAO,UACpB,aAAa,OAAO,SACxB;AACF;;;;;;;;;;;;;AAcA,SAAgB,oBACd,aACA,UACA,UACoB;CACpB,IAAI,CAAC,eAAe,CAAC,SAAS,WAAW,GAAG,OAAO,KAAA;CACnD,OAAO,SAAS,WAAW;AAC7B;;;;;;;AAQA,IAAa,wBAAwB;;AAGrC,IAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;AAsBtC,SAAgB,6BACd,sBACA,WACoB;CACpB,IAAI,sBAAsB,OAAO;CACjC,OAAO,UAAA,WAAgC,IAAI,yBAAyB,KAAA;AACtE;;AAGA,SAAgB,yBACd,iBACA,SAIS;CACT,OAAO,gBAAgB,MAAM,SAAS;EACpC,MAAM,SAAS,QAAQ;EACvB,OAAO,WAAW,KAAA,KAAa,CAAC,OAAO,WAAW,CAAC,OAAO;CAC5D,CAAC;AACH"}
@@ -65,7 +65,10 @@ document.body.textContent = 'browser plain-json regression passed';
65
65
  const { port } = server.address();
66
66
  let browser;
67
67
  try {
68
- browser = await chromium.launch({ executablePath: chromium.executablePath() });
68
+ browser = await chromium.launch({
69
+ executablePath:
70
+ process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || chromium.executablePath(),
71
+ });
69
72
  const page = await browser.newPage();
70
73
  const errors = [];
71
74
  page.on('pageerror', (error) => errors.push(error.stack ?? error.message));
@@ -3,14 +3,14 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-core",
6
- "packageVersion": "0.52.0",
6
+ "packageVersion": "0.53.0",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "cd386d94669cdf46a054b50f9cc3c3238ad6f646cca1a07aa67fd401006ee559",
11
- "packageJson": "0b82075da10a00ea6b71f611aabab39c0bd4815da65f10f941a1a95835b9ce33",
12
- "agents": "25791661c384c99455eebb1f561c5167ead8dcb4a85d4a049b4a64ca7fdfd3de",
13
- "moduleDoc:agents/object-runtime.md": "34a4e740c109467cd5486c4af0e3bf4b63915697721ab584df0e5506ea9290ca",
10
+ "manifest": "2793aafe92b83909677958999d8d6f44a427a4464c6ab6fe47c7c82d61d89396",
11
+ "packageJson": "e5a06269c847168321965dd33ce749df3f5652afa13ece5247ed073cb1b34a5f",
12
+ "agents": "dcf3e194fb7fb4c358063938a9e155e8d97c178d4ad418f011621dda3d8ac3a4",
13
+ "moduleDoc:agents/object-runtime.md": "39a68186cdb57bc9d9dfacb805b19a00133f189f6e287e6ebe2fb347418b878b",
14
14
  "moduleDoc:agents/revision-guard.md": "aa6b1ddb5b6b49fa27ebbe575ec7fcd6d5ceb35ee25acc702f877a226eb9a99a",
15
15
  "moduleDoc:agents/collection-reads.md": "e5bb79b59c17e79a85a71580a019e6b0b641e498ac4d850073fd5ad634b57cb7",
16
16
  "moduleDoc:agents/query-bounds.md": "3a0601ddaf2bea4e90e3f22e16a2eb3508a6fb8c019e3fb2c1f930c90a377cd5",
@@ -23,7 +23,8 @@
23
23
  "moduleDoc:agents/memory.md": "a86bdf370403d08ecad3be86e341eb322dfe6e6b7a4e8806a6f8be9bf3421da5",
24
24
  "moduleDoc:agents/system-diagnostics.md": "b7ccae18ee312bf788d5470f95fa78d3535f1789247d5372e40ca86538290772",
25
25
  "moduleDoc:agents/registry-snapshot.md": "6234aa540427396d1dda1825a4641b1072e247187d5ddf413ae46c691344ee30",
26
- "moduleDoc:agents/run-once.md": "fd6324e474cea820d9d51ac8d96fe55833e899e38178e6423a7b87ac3f772d3f"
26
+ "moduleDoc:agents/model-moves.md": "c64147fee1b7ec242fd1c178e7aea7cc90cafc704dee2738141c7a6d61403cd8",
27
+ "moduleDoc:agents/run-once.md": "0b858c18c78eca5050257a2af06f16de99327e05e0bf9fee9e6277242e7707ef"
27
28
  },
28
29
  "exports": [
29
30
  ".",
@@ -1062,12 +1063,12 @@
1062
1063
  "polymorphicAssociations": 1,
1063
1064
  "uuidColumns": 3
1064
1065
  },
1065
- "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-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [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| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.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 A `tenantId` field makes rows tenant-OWNED without any tenancy declaration:\n the default key leads with `tenant_id`. A new object's natural-key save reads\n the row its upsert would hit and refuses a different owner\n (`TENANT_ISOLATION_VIOLATION`) or adopts a same-owner row's id, so\n `DO UPDATE` never rewrites a primary key or owner. Only an explicit natural\n key adopts: a slug `getSlug()` derived from `name`/`title`/`label` moves to\n a free `-2`, `-3`, … slug and INSERTs. See `agents/schema-paths.md`\n (\"Tenant OWNERSHIP\").\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- `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,\n #3080) claims a key derived from all four inputs — never the token alone,\n which collapses distinct submissions — via `INSERT ... ON CONFLICT\n (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL\n `_smrt_run_once_claims` system table, and runs `work` in the SAME\n transaction; a completed claim replays its stored result instead of\n re-running, a failed `work` rolls the claim back with the transaction, and\n an unresolved claim raises typed `RunOnceClaimError`\n (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.\n The claim insert must never raise on conflict: PostgreSQL aborts the whole\n transaction on any raised error, which would take the recovery `SELECT`\n down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`\n caught for `isUniqueViolationError`. Completed claims are bounded by\n `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,\n `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on\n `completed_at`; `in_progress` rows are never matched) — the window is a\n replay deadline, not just disk hygiene: a token replayed after its claim\n is swept runs `work()` again. See `agents/run-once.md`.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses a transaction-scoped advisory lock, taken only when the\n PostgreSQL schema version is not yet stamped: inside a caller's transaction it\n is held until that transaction ends (#2868). 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- Decorator registration trusts a class's stack-derived package only when that\n package declares the class (stub, own manifest entry, or explicit\n `packageName`); it then matches the qualified key and never adopts another\n package's same-named registration. Otherwise a loaded (or the cwd project's)\n manifest entry describing the declaring file supplies the identity — apps\n scan workspace-package sources under the app's name (#3110). A class of\n another known package coexists under its qualified key\n (`decorator-different-packages-qualified-coexist`); bundled output needs\n a resolved table that differs first (#3106). Stack\n attribution skips smrt-core (including source-mapped installed\n `smrt-core/src` frames, #3109), decorator-helper, and module-runner frames.\n Instance and collection registry lookups pass the qualified name. The schema\n planner rejects a table claimed by two unrelated classes\n (`CONFIG_TABLE_NAME_COLLISION`): only one STI family shares a table (#3098);\n collections (including `SmrtReportCollection`) never claim one. Consumer\n gates: `registry/__tests__/issue-3110-*.test.ts`, `issue-3109-*.test.ts`,\n `packages/bundle-gate/src/__tests__/consumer-same-name.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",
1066
+ "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-feed-authz.ts` | Consumer table/row change-feed authorization hooks (#3020) | [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| `src/registry/qualified-name-aliases.ts`, `src/migrations/qualified-name-aliases.ts` | Model moves: `previousQualifiedNames` aliases, deprecation, doctor counts, opt-in backfill (#3338) | [agents/model-moves.md](agents/model-moves.md) |\n| `src/run-once.ts` | Shared idempotency seam: insert-only claim + caller work in one transaction, replay on retry, typed in-flight/unknown-outcome answers (#3080) | [agents/run-once.md](agents/run-once.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 A `tenantId` field makes rows tenant-OWNED without any tenancy declaration:\n the default key leads with `tenant_id`. A new object's natural-key save reads\n the row its upsert would hit and refuses a different owner\n (`TENANT_ISOLATION_VIOLATION`) or adopts a same-owner row's id, so\n `DO UPDATE` never rewrites a primary key or owner. Only an explicit natural\n key adopts: a slug `getSlug()` derived from `name`/`title`/`label` moves to\n a free `-2`, `-3`, … slug and INSERTs. See `agents/schema-paths.md`\n (\"Tenant OWNERSHIP\").\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- `runOnce({ db, tenantId, actor, token, content }, work)` (`src/run-once.ts`,\n #3080) claims a key derived from all four inputs — never the token alone,\n which collapses distinct submissions — via `INSERT ... ON CONFLICT\n (claim_key) DO NOTHING RETURNING claim_key` into the hand-DDL\n `_smrt_run_once_claims` system table, and runs `work` in the SAME\n transaction; a completed claim replays its stored result instead of\n re-running, a failed `work` rolls the claim back with the transaction, and\n an unresolved claim raises typed `RunOnceClaimError`\n (`RUN_ONCE_IN_FLIGHT` / `RUN_ONCE_OUTCOME_UNKNOWN`) rather than guessing.\n The claim insert must never raise on conflict: PostgreSQL aborts the whole\n transaction on any raised error, which would take the recovery `SELECT`\n down with it too — this is why it is `DO NOTHING`, not a plain `INSERT`\n caught for `isUniqueViolationError`. Completed claims are bounded by\n `runRetentionSweep()`'s `run-once-claims` task (`system/retention.ts`,\n `DEFAULT_RETENTION_POLICY.runOnceClaims`, default 30 days on\n `completed_at`; `in_progress` rows are never matched) — the window is a\n replay deadline, not just disk hygiene: a token replayed after its claim\n is swept runs `work()` again. See `agents/run-once.md`.\n- `ensureSystemTables(db, typeHint?)` provisions framework tables idempotently;\n call it on a base PostgreSQL connection before caller-owned transactions.\n Bootstrap uses a transaction-scoped advisory lock, taken only when the\n PostgreSQL schema version is not yet stamped: inside a caller's transaction it\n is held until that transaction ends (#2868). 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- Decorator registration trusts a class's stack-derived package only when that\n package declares the class (stub, own manifest entry, or explicit\n `packageName`); it then matches the qualified key and never adopts another\n package's same-named registration. Otherwise a loaded (or the cwd project's)\n manifest entry describing the declaring file supplies the identity — apps\n scan workspace-package sources under the app's name (#3110). A class of\n another known package coexists under its qualified key\n (`decorator-different-packages-qualified-coexist`); bundled output needs\n a resolved table that differs first (#3106). Stack\n attribution skips smrt-core (including source-mapped installed\n `smrt-core/src` frames, #3109), decorator-helper, and module-runner frames.\n Instance and collection registry lookups pass the qualified name. The schema\n planner rejects a table claimed by two unrelated classes\n (`CONFIG_TABLE_NAME_COLLISION`): only one STI family shares a table (#3098);\n collections (including `SmrtReportCollection`) never claim one. Consumer\n gates: `registry/__tests__/issue-3110-*.test.ts`, `issue-3109-*.test.ts`,\n `packages/bundle-gate/src/__tests__/consumer-same-name.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",
1066
1067
  "moduleDocs": [
1067
1068
  {
1068
1069
  "path": "agents/object-runtime.md",
1069
1070
  "module": "object-runtime",
1070
- "content": "# Object and collection runtime\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [revision-guard.md](revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `normalizePersistenceData(data)` is the synchronous final derived-column hook:\n shared save preparation passes read-only snake-case row data after the complete polymorphic\n `toJSON()` / `transformJSON()` chain and UUID coercion, then merges the returned\n columns before every insert/update/upsert branch. Declare permitted snake-case\n columns with `getPersistenceDerivedColumns()`, preserving super declarations.\n Declarations must name registered schema fields; framework identity, tenant,\n revision, STI metadata and natural conflict columns are always rejected.\n Undeclared return keys reject before persistence. The hook receives a frozen\n shallow row copy. Derive only the declared columns; do not perform I/O.\n Preserve `super` results. It does not alter plain/public serialization;\n use `transformJSON()` for that existing contract. Ordinary saves and eligible\n junction batches share this preparation. A custom normalization override makes\n a junction ineligible for batching, preserving its virtual per-row save path.\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\nRelationship I/O resolves canonical source and target identities, not the public\ndisplay names. `relationship-loader.ts` owns target manifest hydration and inverse\nselection for lazy, eager, junction and latest-related reads. An unresolved exact\nconstructor or ambiguous target fails before querying or caching a foreign peer;\nlegacy external string targets retain manifest discovery. Junction column naming\nconventions still use display names. Cached reads retain their tenant rechecks.\n\n\n## Plain-object serialization\n\n`toPlainObject()` materializes the `toJSON()` / `transformJSON()` result directly,\nwithout an intermediate JSON string for ordinary payloads. Native `JSON.rawJSON()`\nliterals are decoded once with `JSON.parse()`. Nested hooks and JSON omission/coercion\nrules still apply. The active ancestor stack rejects cycles while allowing\nshared siblings, which become independent plain copies. Arrays skip boxed-value\nbrand checks; other objects use intrinsic brand checks without reading user\n`Symbol.toStringTag` getters. Customize the payload through `transformJSON()`.\nThe focused `to-plain-object.test.ts` suite compares legacy output and reports\nwarmed, interleaved per-row timings without a flaky timing assertion.\n\n`pnpm --filter @happyvertical/smrt-core test:browser` bundles the production\n`src/plain-json.ts` helper and executes it in Chromium with `process` and\n`JSON.isRawJSON` unavailable. It is an opt-in local/browser release gate rather\nthan part of the default core suite: it requires the repository's\n`playwright-core` dependency and an installed Chromium binary. The normal test\nsuite covers the same helper through `toPlainObject()` on Node.\n\n## SmrtCollection Query\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [collection-reads.md](collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\n`convertWhereKeys` must accept only operators executable by the SQL builder.\n`contains` and dot-notation JSON paths reject at the boundary; use `like` with\nexplicit wildcards. Adding operators requires SQL support first.\n`src/__tests__/issue-2276-where-contract.test.ts` executes the accepted set.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [query-bounds.md](query-bounds.md).\n\n\n## Junction reads and compatible writes\n\n`byLeft(id, { relationship: 'attachment', limit: 20, offset: 40 })` and\n`byRight()` forward `limit`/`offset` to the collection's existing bounds parser;\nother options remain field filters. Pagination is read-only: `detach()` still\nrequires field filters and never deletes a page. Cursor pagination is not part\nof this API; callers can use deterministic limit/offset pages.\n\n`setLinks()` preserves replacement semantics: even retained right IDs get new\njunction IDs/timestamps and delete/create change entries. `detach()` and\n`setLinks()` batch compatible models automatically. Within 100 removed + added\nrows and 900 inserted bind values, a warm SQLite/DuckDB/PostgreSQL operation uses\nconstant framework SQL: one snapshot, grouped owned-memory cleanup and delete,\none multi-row natural-key upsert, and one feed append per nonempty mutation\nphase. Bootstrap, retries, and caller-defined work are excluded. The read\nsnapshot and in-memory work remain proportional to link count.\n\nEligibility is deliberately conservative and rechecked each call: base runtime\nmethods/accessors and collection create/attach behavior, one non-STI table,\ncompiler-owned declarative validators, no validated cross-package references,\nno embedding generation, no incoming typed references, non-NULL unique\nconflict values with no duplicate input conflict keys, and explicit consent\nfrom every mutation interceptor. Otherwise the original virtual per-row path\nruns. Larger sets retain that fallback; there is no truncated replacement.\nPolymorphic association cleanup remains grouped through the owning cascade API\nand invalidates every affected table. JSON export adapters retain their ordinary\nlifecycle; unrecognized transaction-only DuckDB handles also fall back.\nThe tenancy interceptor consents only without directory dispatch/custom error\ncallbacks. Tenant checks and auto-population still run per row. The change-feed\ninterceptor appends every row and preserves tenant IDs and tombstones.\n\nBulk lifecycle preparation and completion are owned by `SmrtObject`; collection\ninitialization shares `createUnsaved()` with `create()`. Additional interceptors\nmay supply `bulkMutation.compatible()` only if grouped before/persist/after\nphases preserve their behavior and before hooks have no side effects beyond\ninstance/context mutation. Preparation can discover an unsupported shape and\nfall back; before hooks must tolerate that rehearsal. Unknown interceptors never\nopt in implicitly.\nThe compiler marks its own callback-free validators by function identity.\n\nThe whole replacement remains nontransactional unless the caller supplies a\ntransaction. A delete group is atomic with its owned-memory cleanup and each\nmulti-row insert is atomic, so database failure can leave the deletion phase\ncommitted without new links. No per-row prefix is promised for a failed batch.\nThe batch delete reuses an existing DuckDB transaction when the SDK explicitly\nrefuses savepoint nesting before running any work; it never replays a callback.\nThe legacy fallback retains its existing detectable nested-transaction refusal\nfor ordinary deletes inside caller-owned DuckDB transactions\n([#2824](https://github.com/happyvertical/smrt/issues/2824)).\nPublic ordinary `save()` still owns revision CAS; batches only create new\ninstances and never use this path to overwrite a loaded revision.\n\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n"
1071
+ "content": "# Object and collection runtime\n\n`constructor(options)` → `initialize()` → ready for `save()`/`delete()`/`loadFromId()`\n\n- `initialize()`: loads field initializers, applies option values (options override initializers), loads from DB if id/slug provided\n- `save()`: upsert with STI validation, interceptor execution, auto-embeddings. Persisted objects (`isPersisted` — set by DB hydration and successful saves) upsert on `['id']` so natural-key edits (e.g. slug renames) update in place; new objects upsert on the natural-key conflict columns for ingestion-style dedup (#1472)\n- Persisted `save()` uses loaded `updated_at` in its `UPDATE`; zero rows throws\n `RUNTIME_REVISION_CONFLICT`. Explicit `expectedUpdatedAt` binds a save or\n delete to an earlier snapshot. Remote guarded deletes bind the same predicate\n into the final `DELETE`; embedded adapters compare inside the shared write queue\n before cascading. That queue serializes same-process saves, deletes, and full\n `SmrtObject.withTransaction()` callbacks. Custom writes must preserve this\n public CAS ordering contract. PostgreSQL predicate:\n [revision-guard.md](revision-guard.md).\n- Native DuckDB UUID columns are hydrated as canonical strings before model\n initialization, natural-key lookup, and embedded revision claims. Exact\n natural-key probes retain the interceptor-authorized filter when\n canonicalizing a wrapped identity. Custom embedded-CAS paths that consume\n persisted rows must use `getCanonicalPersistedRow()` so UUID identities are\n cast in the same coherent read before reuse.\n- `is(criteria)` / `do(instructions)` / `describe()`: AI operations via function calling. They inject the object's own `toPublicJSON()` (sensitive fields stripped) as a \"content body\" so the model reasons over the instance. Options: `includeData: false` skips injection (for callers that already curate the relevant fields into the instruction); `maxDataLength` overrides the truncation budget. Neither key is forwarded to `ai.message()`. (#1567)\n- `normalizePersistenceData(data)` is the synchronous final derived-column hook:\n shared save preparation passes read-only snake-case row data after the complete polymorphic\n `toJSON()` / `transformJSON()` chain and UUID coercion, then merges the returned\n columns before every insert/update/upsert branch. Declare permitted snake-case\n columns with `getPersistenceDerivedColumns()`, preserving super declarations.\n Declarations must name registered schema fields; framework identity, tenant,\n revision, STI metadata and natural conflict columns are always rejected.\n Undeclared return keys reject before persistence. The hook receives a frozen\n shallow row copy. Derive only the declared columns; do not perform I/O.\n Preserve `super` results. It does not alter plain/public serialization;\n use `transformJSON()` for that existing contract. Ordinary saves and eligible\n junction batches share this preparation. A custom normalization override makes\n a junction ineligible for batching, preserving its virtual per-row save path.\n- `save()` error contract (#2366): unique/PK violation → `ValidationError` `VALIDATION_UNIQUE_CONSTRAINT`, NOT NULL → `VALIDATION_REQUIRED_FIELD`, both on the first attempt on every adapter; any other database failure → `DatabaseError` with the driver error on `cause`\n- `getSlug()`: auto-generates from name → title → label → id\n- `loadRelated(fieldName)`: lazy-loads relationships (cached in `_loadedRelationships` Map)\n\nRelationship I/O resolves canonical source and target identities, not the public\ndisplay names. `relationship-loader.ts` owns target manifest hydration and inverse\nselection for lazy, eager, junction and latest-related reads. An unresolved exact\nconstructor or ambiguous target fails before querying or caching a foreign peer;\nlegacy external string targets retain manifest discovery. Junction column naming\nconventions still use display names. Cached reads retain their tenant rechecks.\n\n\n## Plain-object serialization\n\n`toPlainObject()` materializes the `toJSON()` / `transformJSON()` result directly,\nwithout an intermediate JSON string for ordinary payloads. Native `JSON.rawJSON()`\nliterals are decoded once with `JSON.parse()`. Nested hooks and JSON omission/coercion\nrules still apply. The active ancestor stack rejects cycles while allowing\nshared siblings, which become independent plain copies. Arrays skip boxed-value\nbrand checks; other objects use intrinsic brand checks without reading user\n`Symbol.toStringTag` getters. Customize the payload through `transformJSON()`.\nThe focused `to-plain-object.test.ts` suite compares legacy output and reports\nwarmed, interleaved per-row timings without a flaky timing assertion.\n\n`pnpm --filter @happyvertical/smrt-core test:browser` bundles the production\n`src/plain-json.ts` helper and executes it in Chromium with `process` and\n`JSON.isRawJSON` unavailable. It is an opt-in local/browser release gate rather\nthan part of the default core suite: it requires the repository's\n`playwright-core` dependency and an installed Chromium binary. Set\n`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chromium` to use an installed\nexecutable; otherwise Playwright's managed Chromium is used. The normal test\nsuite covers the same helper through `toPlainObject()` on Node.\n\n## SmrtCollection Query\n\nProjection, latest-related, facets, counts, and bounded read plans are\ndocumented in [collection-reads.md](collection-reads.md).\n\n`list()` and `query()` hydrate model instances serially in result order because\nan `initialize()` hook may query through the same transaction-bound PostgreSQL\nclient. Keep this serialization invariant; use `select` when callers need plain\nrows without model hydration.\n\nNative DuckDB model hydration casts declared UUID columns to `VARCHAR` in the\nread query because its JavaScript binding otherwise returns lossy HUGEINT\nwrapper objects. Explicit projections apply the same cast for selected UUID\nfields so bounded query envelopes preserve canonical row and relationship ids.\nFor STI child columns, raw `query()` SELECTs, and latest-related projections,\nthe read path describes the output types without evaluating the query, then\nperforms one data-bearing SELECT with UUID result columns cast to `VARCHAR`;\nmutation statements are never reinterpreted or replayed.\n\n**WHERE operators**: `=`, `>`, `<`, `>=`, `<=`, `!=`, `in`, `not in`, `like`.\nArrays auto-detect `IN`. NULL is a value, not an operator: `{ deletedAt: null }`\nrenders `IS NULL` and `{ 'deletedAt !=': null }` renders `IS NOT NULL`.\n\n`convertWhereKeys` must accept only operators executable by the SQL builder.\n`contains` and dot-notation JSON paths reject at the boundary; use `like` with\nexplicit wildcards. Adding operators requires SQL support first.\n`src/__tests__/issue-2276-where-contract.test.ts` executes the accepted set.\n\nSTI child collections auto-filter by `_meta_type`. Query bounds — `LIMIT 1` on `get()`, the `limit`/`offset` parser, the `orderBy` whitelist and sensitive/permission refusals, and the deterministic generated-list ordering (#2367) — are in [query-bounds.md](query-bounds.md).\n\n\n## Junction reads and compatible writes\n\n`byLeft(id, { relationship: 'attachment', limit: 20, offset: 40 })` and\n`byRight()` forward `limit`/`offset` to the collection's existing bounds parser;\nother options remain field filters. Pagination is read-only: `detach()` still\nrequires field filters and never deletes a page. Cursor pagination is not part\nof this API; callers can use deterministic limit/offset pages.\n\n`setLinks()` preserves replacement semantics: even retained right IDs get new\njunction IDs/timestamps and delete/create change entries. `detach()` and\n`setLinks()` batch compatible models automatically. Within 100 removed + added\nrows and 900 inserted bind values, a warm SQLite/DuckDB/PostgreSQL operation uses\nconstant framework SQL: one snapshot, grouped owned-memory cleanup and delete,\none multi-row natural-key upsert, and one feed append per nonempty mutation\nphase. Bootstrap, retries, and caller-defined work are excluded. The read\nsnapshot and in-memory work remain proportional to link count.\n\nEligibility is deliberately conservative and rechecked each call: base runtime\nmethods/accessors and collection create/attach behavior, one non-STI table,\ncompiler-owned declarative validators, no validated cross-package references,\nno embedding generation, no incoming typed references, non-NULL unique\nconflict values with no duplicate input conflict keys, and explicit consent\nfrom every mutation interceptor. Otherwise the original virtual per-row path\nruns. Larger sets retain that fallback; there is no truncated replacement.\nPolymorphic association cleanup remains grouped through the owning cascade API\nand invalidates every affected table. JSON export adapters retain their ordinary\nlifecycle; unrecognized transaction-only DuckDB handles also fall back.\nThe tenancy interceptor consents only without directory dispatch/custom error\ncallbacks. Tenant checks and auto-population still run per row. The change-feed\ninterceptor appends every row and preserves tenant IDs and tombstones.\n\nBulk lifecycle preparation and completion are owned by `SmrtObject`; collection\ninitialization shares `createUnsaved()` with `create()`. Additional interceptors\nmay supply `bulkMutation.compatible()` only if grouped before/persist/after\nphases preserve their behavior and before hooks have no side effects beyond\ninstance/context mutation. Preparation can discover an unsupported shape and\nfall back; before hooks must tolerate that rehearsal. Unknown interceptors never\nopt in implicitly.\nThe compiler marks its own callback-free validators by function identity.\n\nThe whole replacement remains nontransactional unless the caller supplies a\ntransaction. A delete group is atomic with its owned-memory cleanup and each\nmulti-row insert is atomic, so database failure can leave the deletion phase\ncommitted without new links. No per-row prefix is promised for a failed batch.\nThe batch delete reuses an existing DuckDB transaction when the SDK explicitly\nrefuses savepoint nesting before running any work; it never replays a callback.\nThe legacy fallback retains its existing detectable nested-transaction refusal\nfor ordinary deletes inside caller-owned DuckDB transactions\n([#2824](https://github.com/happyvertical/smrt/issues/2824)).\nPublic ordinary `save()` still owns revision CAS; batches only create new\ninstances and never use this path to overwrite a loaded revision.\n\n\n## DispatchBus\n\n- `emit(signalType, payload, metadata)` → creates persistent Dispatch record\n- `on(pattern, handler)` → in-memory handler (immediate)\n- `subscribe({ signalType, subscriber })` → persistent subscription (survives restarts)\n- `process(subscriberName, handler)` → process pending dispatches\n- Wildcards: `campaign.*` matches `campaign.completed` (single segment only)\n- Tables: `_smrt_dispatch`, `_smrt_dispatch_subscriptions`\n- Status: `pending → processing → completed` (or `failed`)\n\n## Single Table Inheritance (STI)\n\n- Base: `@smrt({ tableStrategy: 'sti' })` — children inherit, share one table\n- Discriminator: `_meta_type` column with qualified names (`@happyvertical/smrt-content:Article`)\n- Child fields: `@meta()` decorator → stored in `_meta_data` JSONB (not as columns)\n- Polymorphic queries: collection loads `_meta_type`, creates correct subclass dynamically\n- Validation: fail-fast on save if `_meta_type` missing or mismatched\n\n## Child Accessors (R10)\n\n`src/child-accessors.ts` installs a consistent `get<FieldName>()` instance method for every `@oneToMany` field at `@smrt()` registration time (e.g. `@oneToMany('OrderItem') items` → `order.getItems()`), delegating to `loadRelatedMany`. Two invariants:\n\n- **Additive** — never overwrites a hand-rolled method of the same name (checks the whole prototype chain). `Profile.getMetadata()` (key-value) and `ProfileRelationship.getTerms()` are preserved.\n- **Runtime-only** — attached to the prototype, invisible to the build-time manifest, so it never leaks into the REST/CLI/MCP surface.\n\nWhen the target declares multiple FKs back to the parent, annotate `@oneToMany(Target, { foreignKey: '<inverseField>' })`; `loadRelatedMany` and the eager `include:` loader both honor it (else first-match).\n"
1071
1072
  },
1072
1073
  {
1073
1074
  "path": "agents/revision-guard.md",
@@ -1129,10 +1130,15 @@
1129
1130
  "module": "registry-snapshot",
1130
1131
  "content": "<!-- Module doc for packages/core/AGENTS.md. Linked from the Modules table there. -->\n\n# Registry snapshot (#1831)\n\n`src/system/registry-snapshot.ts` projects the booted, process-global\n`ObjectRegistry` into `RuntimeRegistrySnapshot`, the only shape in which\nregistry state may cross a process or wire boundary. It is a pure read of\nin-process state: no database, no project code, no registry mutation.\n\nWhat leaves: per object the simple/qualified name, package, table, collection,\n`extends`/type argument/inheritance chain, visibility, tenancy `{mode, field}`,\ncounts, and (with `detail`) fields — name, type, required, `related`,\ntransient, inherited, `sqlType` — and methods — name, async/static/public,\nparameter names and types, return type, inherited. Registry diagnostics keep\nonly severity, code, and message.\n\nWhat never leaves: constructors, collection constructors, validators,\nvalidation rules, `tools` payloads, decorator config, field `value`s or\ndescriptions, diagnostic `context`, and absolute paths. `sourceFilePath` is\nrelativized to `projectRoot`; a path outside the root (installed package,\nsymlinked workspace) is reduced to its basename so `..` walking cannot reveal\nlayout. `assertPlainJson()` is exported so callers and tests prove a snapshot\nis plain JSON (no functions, class instances, symbols, or cycles) rather than\ntrusting an allow-list.\n\nConsumers: `smrt-dev-mcp` `tools/runtime/observation.ts` (`runtime-registry`,\n`runtime-object`) and its Level 2 HTTP host. A new consumer must take the DTO,\nnever a `RegisteredClass`.\n"
1131
1132
  },
1133
+ {
1134
+ "path": "agents/model-moves.md",
1135
+ "module": "model-moves",
1136
+ "content": "# Moving a model between packages (#3338)\n\nA class's registry identity is its qualified name, `@package/name:ClassName`.\nMoving a class to another package (or renaming it) changes that name, and every\nreference that stored or declared the old one stops resolving silently:\npolymorphic `metaType` rows (including smrt-assets `AssetAssociation`),\n`ObjectRegistry.getClassByQualifiedName()`, `@crossPackageRef` and relationship\ntargets, and playbook step `model:` names.\n\n## Declare the old name\n\n```ts\n@smrt({\n tableName: 'service_time_entries', // keep the table: a move is not a rename\n previousQualifiedNames: ['@happyvertical/smrt-projects:ServiceTimeEntry'],\n})\nexport class ServiceTimeEntry extends SmrtObject {}\n```\n\n`previousQualifiedNames` (`registry/types.ts`) is a list of deprecated\nqualified names. Keep `tableName` explicit so the moved class keeps its table.\n\n## What the alias does\n\n- **Resolution.** `registry/qualified-name-aliases.ts` derives an alias index\n from registered classes once per registry generation. `findClass()`,\n `findClassStrict()`, `getClassByQualifiedName()`, `getClassInPackage()` and\n `resolveType()` consult it after a direct miss, for qualified input only;\n simple-name lookups are unchanged. `resolveType(old)` returns the current\n name.\n- **Lazy path.** `tryLoadFromExternalPackage(old)` falls back to the alias\n index the NEW owner's manifest carries: the scanner keeps\n `decoratorConfig.previousQualifiedNames` verbatim, so the old identity\n resolves even when the old package no longer lists the class or is not\n installed. Loaded and discoverable manifests are all searched; two distinct\n claimants of one old name throw `CONFIG_QUALIFIED_NAME_ALIAS_COLLISION`.\n `ensureManifestLoaded(old)` reconciles under the current name.\n- **Covered call sites.** `SmrtPolymorphicAssociation.hydrate()`, relationship\n targets (`relationship-graph.ts` resolves `@crossPackageRef('<old>')` to the\n current name, so loads, inverses and delete planning key on it), delete\n cascades (`cascade.ts` also removes association rows stored under an old\n name), and playbook preflight (`resolveRegisteredObjectName`).\n- **Deprecation warning.** The first resolution of each old name per process\n logs a warning naming the old name, the current name, and the resolution\n site, and records a `QUALIFIED_NAME_ALIAS_DEPRECATED` registry diagnostic.\n `ObjectRegistry.clear()` resets it.\n- **New writes store the current name.** `SmrtPolymorphicAssociation.save()`\n rewrites an aliased `metaType`; a legacy row that is loaded and saved again\n is upgraded. Readers that filter on stored names use\n `ObjectRegistry.getEquivalentQualifiedNamesAsync(name)`, which returns the\n current name followed by the aliases, as `AssetAssociationCollection`'s\n `byLeft()`, `detach()` and `setLinks()` do.\n- **Writes and filters cover unregistered classes.** Both of those call sites\n use the async helpers. When a qualified name misses, they lazily load the\n class from its manifest before normalizing or filtering. The sync variants\n only see registered classes, so a write or filter could otherwise miss a\n class known only from its installed manifest. A genuinely unknown name is\n stored or matched as given.\n- **Public helpers.** `ObjectRegistry.getQualifiedNameAliases()` returns the\n alias-to-current map. `resolveQualifiedName(name)` and\n `resolveQualifiedNameAsync(name)` return the current name.\n\n## An alias is never a second class\n\nAlias keys never enter the `classes` map. `getAllClasses()`, schema/DDL,\nMCP/CLI/REST generation, knowledge surfaces and every other iteration see one\nentry per class. Registration refuses ambiguity with\n`CONFIG_QUALIFIED_NAME_ALIAS_COLLISION` when an alias equals a live class's\nqualified name (in either registration order) or when two classes claim one old\nname. That includes any loaded manifest: registration refuses an alias that\nanother installed package also declares, or that an installed manifest still\ndefines as a class, for example a stale old package version. A re-exported\nconstructor is not a definition. A lazy load of a\nmanifest that declares aliases first loads every discoverable manifest. That\ninventory is cached per registry generation, so the check applies whether the\nclass is requested by its current name or by an old one. It refuses `CONFIG_QUALIFIED_NAME_ALIAS_INVALID` when an entry is not\na scoped `@scope/package:ClassName` with exactly one colon (unscoped names never resolve as qualified), repeats, or names the class itself. Manifest generation\n(`ManifestGenerator.assertQualifiedNameAliases`) fails closed on the same\nmistakes. The knowledge artifact lists the names as\n`objects[].previousQualifiedNames`, sorted and omitted when empty.\n\n## Stored references: doctor and backfill\n\n`migrations/qualified-name-aliases.ts`, exported from\n`@happyvertical/smrt-core/migrations`, covers two kinds of stored reference:\n`meta_type` on every registered polymorphic-association table, and `_meta_type`\non the STI table of an aliased STI class.\n\n- `countLegacyQualifiedNameReferences(db, { tenantId? })` is read-only and\n returns counts by table, column and old name. `smrt doctor --db` prints them\n under \"Deprecated Qualified Names\" and warns while the count is non-zero.\n- `backfillLegacyQualifiedNames(db, { dryRun?, tenantId?, force? })`, run as\n `smrt db:migrate-qualified-names`, is opt-in and never automatic. It rewrites\n only exact old-name values, in one transaction where available, and never\n touches ids or tenant columns. A `tenantId` run only touches that tenant's\n rows in tenant-owned tables. It skips and reports any row whose rewrite would\n duplicate a current-name row on the table's conflict columns. Once nothing in\n scope remains, it records a `_smrt_backfills` marker named\n `@happyvertical/smrt-core:qualified-name-aliases:v1:<digest>[:tenant:<id>]`.\n The digest covers the alias set, so a later move runs again.\n\nKnown gap: STI child-collection reads filter `_meta_type` by the current name\nonly. Run the backfill before relying on subclass collections of a moved STI\nclass.\n\n## Lifecycle\n\n1. Move the class, keep its table, and declare `previousQualifiedNames`. This\n needs no data migration.\n2. Consumers upgrade. Old names keep resolving and warn once per process.\n3. Optionally run `smrt db:migrate-qualified-names` to clean up early.\n4. Remove the alias in a later **breaking** release, only once\n `smrt doctor --db` reports zero stored references for every consumer and no\n consumer source still declares the old name.\n"
1137
+ },
1132
1138
  {
1133
1139
  "path": "agents/run-once.md",
1134
1140
  "module": "run-once",
1135
- "content": "# runOnce: shared idempotency seam (#3080)\n\n`src/run-once.ts`. A DB/transaction-level primitive — it takes a\n`DatabaseInterface`, not a `SmrtObject`/`SmrtCollection` — so it works outside\nSvelteKit and outside the ORM. Any caller with a database handle and a\nJSON-serializable work result can use it.\n\n## Why this exists, and why it is not the token alone\n\nEvery consumer with a form, a retried request, or a double-tapped button\nneeds the same guard, and a first hand-rolled attempt at it (reported against\nthis issue) keyed the claim on the per-form token alone. That silently\ncollapsed two genuinely different submissions that happened to reuse one\nform's token — the exact bug this primitive exists to prevent everyone from\nre-introducing.\n\n## Key derivation\n\n```\nnormalized = normalizeRunOnceContent(content) // one pass; FormData captured as a node\ncontentDigest = containsFormData(normalized)\n ? sha256(\"\\u0001run-once-formdata-v1\\n\" + stableStringify(toFormDataDomain(normalized)))\n : sha256(stableStringify(normalized)) // plain JSON: unchanged since #3080\nclaimKey = sha256(stableStringify([tenantId, actor, token, contentDigest]))\n```\n\n`stableStringify` (`../src/knowledge-graph.ts`) is sorted-key JSON: property\ninsertion order never changes the digest. Content is normalized first, following\nJSON serialization exactly: `toJSON(key)` is called with the property name or\narray index (a `Date` digests as its ISO string, not `{}`), and `undefined`,\nfunction and symbol values are omitted from objects and written as `null` in\narrays. A `FormData` has no enumerable properties, so\nJSON would reduce every form to `{}`; it digests as its ordered entry list\ninstead (repeated fields are significant; a `File` digests by name, type and\nsize), hashed in a separate domain (the input is prefixed with a byte sorted-key\nJSON never starts with, and user keys starting with NUL are escaped) so no plain\nobject can forge it and plain-JSON digests are unchanged. `Map`, `Set`,\n`RegExp`, any other non-plain object without `toJSON()`, and `bigint` throw a\n`TypeError` rather than silently hashing as `{}` (#3136). Folding in `tenantId`\nand `actor`\nmeans two tenants or two actors can never collide on the same claim even if a\ntoken were somehow shared between them; folding in `contentDigest` means a\nretry of the SAME submission (same token, same content) replays, while a\nDIFFERENT submission through the same token (edited content before a\nresubmit) runs as its own claim.\n\nNeither the raw token nor the raw content is ever persisted — only their\ndigests — so `_smrt_run_once_claims` cannot leak submitted business data.\n\n## Placement: `smrt-core`, not `smrt-svelte`\n\nThe issue posed this as a judgement call. `runOnce()` lives in `smrt-core`\nbeside the other write primitives (`object.ts`, `collection.ts`) because:\n\n- It is DB/transaction-level, not SvelteKit-specific — any caller with a\n `DatabaseInterface` can use it, including CLI jobs, MCP tool handlers, and\n non-web callers.\n- The claim table must exist for every `smrt-core` consumer, not only ones\n that also depend on `smrt-svelte`.\n\nNo SvelteKit form-token helper shipped alongside it. The token contract\n(mint a random, unguessable value once per rendered form; carry it as a\nhidden field or equivalent; pass it straight through to `runOnce()`) is a few\nlines any consumer already owns as part of rendering its own form, and there\nwas no existing svelte-facing package this fit into without adding one. If a\nsecond consumer needs the exact same minting/reading helper, add it to\n`smrt-svelte`'s forms module then — do not add a package for one caller.\n\n## Schema: a hand-DDL system table, not an `@smrt()` model\n\n`_smrt_run_once_claims` is declared in `src/system/schema.ts`\n(`CREATE_SMRT_RUN_ONCE_CLAIMS_TABLE`), added to `ALL_SYSTEM_TABLES`, and\ncreated by `ensureSystemTables()` / `bootstrapSystemTables()`\n(`src/system/bootstrap.ts`) — the same mechanism that creates\n`_smrt_migrations` and `_smrt_contexts`. This is \"Mechanism A\" in the\n`_smrt_` prefix classification ([schema-paths.md](schema-paths.md)), not\n\"Mechanism B\" (an `@smrt()`-decorated model migrated via `db:migrate`, like\n`_smrt_jobs`).\n\nThis was a deliberate choice, not the default: `runOnce()` must work for any\n`smrt-core` consumer without that consumer registering a class and running a\nmigration for a table they've never heard of. A Mechanism-B table only exists\nonce an app imports and registers its owning class; a Mechanism-A table\nexists for every `smrt-core` database unconditionally, which is what a\nframework-wide primitive needs.\n\nAdding the table required:\n\n- A new DDL constant in `ALL_SYSTEM_TABLES`.\n- Bumping `SMRT_SCHEMA_VERSION` (1.10.1 → 1.11.0) — `CREATE TABLE IF NOT\n EXISTS` is a no-op on an existing database, so nothing upgrades without the\n bump.\n- A new `SMRT_SCHEMA_DDL_CHECKSUMS` entry — `system-schema-evolution.test.ts`\n recomputes the DDL hash and fails the build if it does not match the\n recorded value for the current version.\n\nNo new column was added to an existing system table, so no\n`addColumnIfMissing()` entry in `system/compatibility.ts` was needed. Live\nschema parity (`checkLiveSchemaParity`, `smrt doctor --db` /\n`db:status --parity`) picks the table up automatically: system-table shape\nparsing (`schema/system-table-shapes.ts`) derives its expectation from the\nsame DDL rather than a hand-maintained list.\n\n## Table shape\n\n```sql\nCREATE TABLE IF NOT EXISTS _smrt_run_once_claims (\n claim_key TEXT PRIMARY KEY, -- the derived key above\n tenant_id TEXT NOT NULL, -- redundant with claim_key; kept for admin queries and a defense-in-depth tenant filter on lookup\n actor TEXT NOT NULL, -- same\n content_digest TEXT NOT NULL, -- same, plus lets an operator distinguish \"same token, different content\" incidents\n status TEXT NOT NULL DEFAULT 'in_progress', -- 'in_progress' | 'completed'\n result TEXT, -- JSON-serialized work() return value, set only when status = 'completed'\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n completed_at TIMESTAMP\n);\n```\n\n`claim_key` is the PRIMARY KEY — the \"insert-only against a UNIQUE column\"\nthe issue asked for. A second `INSERT` for the same key never adopts the\nexisting row.\n\n**Why the claim insert is raw SQL (`INSERT ... ON CONFLICT (claim_key) DO\nNOTHING RETURNING claim_key`), not a plain `INSERT` caught for a classified\n`unique_violation`:** on PostgreSQL, a statement that raises an error aborts\nthe WHOLE transaction (\"current transaction is aborted, commands ignored\nuntil end of transaction block\") — every later statement in that same\ntransaction fails too, including the `SELECT` `runOnce()` needs to read the\nwinner's stored result back. A first version of this primitive did exactly\nthat (plain `INSERT` + catch classified via `isUniqueViolationError`,\n`../src/db-errors.ts`) and every replay/double-submit on PostgreSQL broke:\nreview caught it before merge. `DO NOTHING` never raises — it reports zero\nrows via `RETURNING` instead — so the transaction's error state is never\ntouched. SQLite and DuckDB support the identical `ON CONFLICT ... DO\nNOTHING` / `RETURNING` syntax (already used elsewhere in this codebase for\n`_smrt_migrations`, `system/bootstrap.ts`), so one query is portable across\nevery adapter without engine branching.\n\n## Atomicity and rollback\n\n`runOnce()` opens `db.transaction()` (or nests under a caller-supplied\ntransaction, per `@happyvertical/sql`'s own SAVEPOINT /\n`NestedTransactionError` rules) and, inside it: inserts the claim\n(`in_progress`), runs `work(tx)` with that SAME transaction-bound handle, then\nupdates the claim to `completed` with the JSON-serialized result — all one\ntransaction.\n\nIf `work()` throws, the callback throws, and `db.transaction()`'s own\ncommit-on-success/rollback-on-error contract undoes the claim insert along\nwith whatever `work()` partially wrote. A claim is never stranded\n`in_progress` by a failed attempt; a retry with the same key can always run\nagain. No compensating delete is needed — this falls out of using one\ntransaction for the whole claim+work+completion sequence, not a special case\nin `runOnce()`'s own code.\n\n## Concurrency\n\n`@happyvertical/sql`'s single-connection adapters (SQLite, DuckDB, JSON)\nserialize `transaction()` calls on one connection: a second, concurrent\n`runOnce()` call does not begin its own transaction until the first ends.\nPostgreSQL pools, but the claim insert's arbiter still has to check the\nunique index against another transaction's uncommitted row of the same\nPRIMARY KEY, and blocks until that transaction resolves, then either no-ops\n(committed — `DO NOTHING` fires, zero rows) or inserts (rolled back). On\nevery adapter, two concurrent callers with the same key never both run\n`work()`, and the loser never sees a PostgreSQL error for it — only zero\nrows back, at which point it reads the now-committed, now-`completed` claim\nrow and replays its stored result.\n\nThis is also why the \"in flight\" and \"unknown outcome\" branches below are\nnormally unreachable through `runOnce()`-to-`runOnce()` races alone on a\nsingle connection — the losing transaction can only ever observe a\nCOMMITTED row, and this module only ever commits a row once it is\n`completed`. They are kept as defined answers (not assumptions) for a claim\nrow that reached that state some other way, and are exercised in\n`src/__tests__/issue-3080-run-once.test.ts` by seeding the table directly and\nby unit-testing the pure resolver (`resolveExistingRunOnceClaim`).\n\n## Typed answers: refused vs. unknown outcome (mirrors #2990)\n\nA conflict on insert is a *definite* decision — the row exists — but whether\nit is safe to hand back a result depends on `status`:\n\n| Row state after conflict | Result |\n|---|---|\n| `status: 'completed'` | Replay the stored `result`. No error. |\n| exists, not completed | `RunOnceClaimError.inFlight()` — `code: 'RUN_ONCE_IN_FLIGHT'`. Some execution holds this key right now; retry later with the SAME token and content. |\n| conflict reported, no row readable | `RunOnceClaimError.outcomeUnknown()` — `code: 'RUN_ONCE_OUTCOME_UNKNOWN'`. Genuinely indeterminate; retrying with the SAME token and content is still safe. |\n\nBoth error codes live on one `RunOnceClaimError` class (`src/errors.ts`),\nfollowing this codebase's existing error-hierarchy convention (one class,\nmultiple codes via static factories — see `TenantIsolationError`,\n`DatabaseError`) rather than a bespoke class hierarchy. Neither is retried\nautomatically by `runOnce()` itself: only the caller knows whether retrying\ninline is appropriate for its own request.\n\n## Retention: completed claims are pruned; the window is a replay deadline\n\n`_smrt_run_once_claims` is append-only in normal operation — every submission\nadds a row and nothing else ever deletes one — so, like `_smrt_changes`,\n`_smrt_ai_usage`, and `_smrt_dispatch`, it is wired into\n`system/retention.ts`'s `runRetentionSweep()` as a built-in task\n(`'run-once-claims'`), covered by `DEFAULT_RETENTION_POLICY.runOnceClaims`\n(default `{ maxAgeDays: 30 }`, overridable per policy or disabled with\n`runOnceClaims: false`). `pruneRunOnceClaims()` deletes only rows with\n`status = 'completed'` AND `completed_at` older than the window; an\n`'in_progress'` row has no `completed_at` and can never match this predicate,\nso a claim held by a live (or abandoned) transaction is never pruned by age\nalone. The predicate is covered by\n`idx_smrt_run_once_claims_status_completed` (`status, completed_at`), added\nin `SMRT_SCHEMA_VERSION` 1.11.1.\n\n**The window is a replay deadline, not just disk hygiene.** `runOnce()`'s\nentire contract — a retry or double-submit with the SAME token and content\nreplays the first attempt's stored result instead of running `work()` again\n— depends on the claim row still existing. Once retention sweeps a completed\nclaim, a client that replays that exact request (same `tenantId`, `actor`,\n`token`, and content) no longer collides with anything: `runOnce()` sees no\nexisting row, wins the insert, and runs `work()` a second time. This is\nsilent — there is no error, just a second execution of whatever `work()`\ndoes. The default 30-day window is chosen to be comfortably longer than any\nrealistic client retry window (a browser tab left open, a queued mobile\nrequest, a webhook redelivery schedule) for the same reason the other\ndefault windows in `DEFAULT_RETENTION_POLICY` are generous relative to their\nconsumers (see `system/retention.ts`'s module doc). A consumer with a\nlonger-lived retry/replay window (e.g. an at-least-once delivery queue with a\nmulti-week redelivery tail) must raise `runOnceClaims.maxAgeDays` to exceed\nit, not rely on the default.\n\nTests: `src/__tests__/issue-2375-retention.test.ts` covers\n`pruneRunOnceClaims()` end-to-end through `runRetentionSweep()` — an old\ncompleted claim is removed, a recent completed claim and an old\n`in_progress` claim both survive.\n\n## Tests\n\n`src/__tests__/issue-3080-run-once.test.ts` (real in-memory SQLite via\n`getTestDatabase({ classes: [] })` — no `@smrt()` model is needed, only the\nsystem table):\n\n- Runs work once; returns its result.\n- Retry after completion replays the stored result without re-running.\n- Concurrent double submit (`Promise.all`) produces one claim row and the\n same result for both callers.\n- Same token, different content → two separate claims (guards the\n token-alone regression this issue was filed against).\n- Same token/content, different actor → separate claim.\n- Same token/content, different tenant → separate claim.\n- Work throws → claim rolls back; a subsequent retry with the same key runs.\n- A claim seeded directly as `in_progress` → `RunOnceClaimError.inFlight()`,\n `work()` never called.\n- Content-digest stability (key order) and sensitivity (value change) are\n unit-tested directly.\n- `resolveExistingRunOnceClaim` (pure resolver) is unit-tested for all three\n branches, including the otherwise-unreachable `outcomeUnknown` case.\n\n`src/__tests__/issue-3080-run-once-postgres.optional.test.ts` (real\nPostgreSQL, `SMRT_TEST_POSTGRES_URL`, `describe.skipIf`, following\n`change-feed-concurrency.optional.test.ts`'s established pattern: distinct\n`dbid`s for two genuinely concurrent connections, `getTestDatabase({ db,\nclasses: [] })` to bootstrap system tables): replay after completion, and\nconcurrent double submit across the two real connections — both would\nreject with a PostgreSQL `DatabaseError` (\"Failed to retrieve record from\ntable\", the driver's wrapping of 25P02 \"current transaction is aborted\")\nagainst the pre-fix plain-`INSERT`-and-catch version; verified locally\nagainst a disposable `postgres:18-alpine` container (revert via patch file,\nconfirmed both tests red, restored, confirmed both green) since the SQLite\nsuite cannot reach this PostgreSQL-specific failure mode at all.\n"
1141
+ "content": "# runOnce: shared idempotency seam (#3080)\n\n`src/run-once.ts`. A DB/transaction-level primitive — it takes a\n`DatabaseInterface`, not a `SmrtObject`/`SmrtCollection` — so it works outside\nSvelteKit and outside the ORM. Any caller with a database handle and a\nJSON-serializable work result can use it.\n\n## Why this exists, and why it is not the token alone\n\nEvery consumer with a form, a retried request, or a double-tapped button\nneeds the same guard, and a first hand-rolled attempt at it (reported against\nthis issue) keyed the claim on the per-form token alone. That silently\ncollapsed two genuinely different submissions that happened to reuse one\nform's token — the exact bug this primitive exists to prevent everyone from\nre-introducing.\n\n## Key derivation\n\n```\nnormalized = normalizeRunOnceContent(content) // one pass; FormData captured as a node\ncontentDigest = containsFormData(normalized)\n ? sha256(\"\\u0001run-once-formdata-v1\\n\" + stableStringify(toFormDataDomain(normalized)))\n : sha256(stableStringify(normalized)) // plain JSON: unchanged since #3080\nclaimKey = sha256(stableStringify([tenantId, actor, token, contentDigest]))\n```\n\n`stableStringify` (`../src/knowledge-graph.ts`) is sorted-key JSON: property\ninsertion order never changes the digest. Content is normalized first, following\nJSON serialization exactly: `toJSON(key)` is called with the property name or\narray index (a `Date` digests as its ISO string, not `{}`), and `undefined`,\nfunction and symbol values are omitted from objects and written as `null` in\narrays. A `FormData` has no enumerable properties, so\nJSON would reduce every form to `{}`; it digests as its ordered entry list\ninstead (repeated fields are significant; a `File` digests by name, type and\nsize), hashed in a separate domain (the input is prefixed with a byte sorted-key\nJSON never starts with, and user keys starting with NUL are escaped) so no plain\nobject can forge it and plain-JSON digests are unchanged. `Map`, `Set`,\n`RegExp`, any other non-plain object without `toJSON()`, and `bigint` throw a\n`TypeError` rather than silently hashing as `{}` (#3136). Folding in `tenantId`\nand `actor`\nmeans two tenants or two actors can never collide on the same claim even if a\ntoken were somehow shared between them; folding in `contentDigest` means a\nretry of the SAME submission (same token, same content) replays, while a\nDIFFERENT submission through the same token (edited content before a\nresubmit) runs as its own claim.\n\nNeither the raw token nor the raw content is ever persisted — only their\ndigests — so `_smrt_run_once_claims` cannot leak submitted business data.\n\n## Placement: `smrt-core`, not `smrt-svelte`\n\nThe issue posed this as a judgement call. `runOnce()` lives in `smrt-core`\nbeside the other write primitives (`object.ts`, `collection.ts`) because:\n\n- It is DB/transaction-level, not SvelteKit-specific — any caller with a\n `DatabaseInterface` can use it, including CLI jobs, MCP tool handlers, and\n non-web callers.\n- The claim table must exist for every `smrt-core` consumer, not only ones\n that also depend on `smrt-svelte`.\n\nThe browser half did not ship with it; it now lives in\n`@happyvertical/smrt-ui/form-retry` (`createFormRetry()`, #3291): a per-tab,\nper-form submission key in `sessionStorage` with an in-memory fallback,\nrotated only on a confirmed write that cleared the form, refused while a\nsubmit is in flight, kept with the typed values across a transport error, and\noptional restore-after-reload so the retry carries byte-identical content. It\nis in `smrt-ui` rather than `smrt-svelte` because smrt-ui is the dependency\nleaf that both `smrt-svelte` and plain SvelteKit apps can use, and its `Form`\ntakes `enhance` and the retry attachment directly (smrt#3265: attach\n`fromAction(enhance, () => retry.enhance())` and `retry.attach`). It is typed\nstructurally against SvelteKit's `SubmitFunction`, so smrt-ui takes no\n`@sveltejs/kit` dependency. Its `digestSubmissionContent()` is a Web Crypto port of\n`digestRunOnceContent()`; `src/__tests__/issue-3291-digest-vectors.test.ts`\npins vectors shared with the port, so change both together (and remember a\ndigest change re-keys every stored claim). Guide:\n[form retry](../../../docs/content/form-retry.md).\n\n## Schema: a hand-DDL system table, not an `@smrt()` model\n\n`_smrt_run_once_claims` is declared in `src/system/schema.ts`\n(`CREATE_SMRT_RUN_ONCE_CLAIMS_TABLE`), added to `ALL_SYSTEM_TABLES`, and\ncreated by `ensureSystemTables()` / `bootstrapSystemTables()`\n(`src/system/bootstrap.ts`) — the same mechanism that creates\n`_smrt_migrations` and `_smrt_contexts`. This is \"Mechanism A\" in the\n`_smrt_` prefix classification ([schema-paths.md](schema-paths.md)), not\n\"Mechanism B\" (an `@smrt()`-decorated model migrated via `db:migrate`, like\n`_smrt_jobs`).\n\nThis was a deliberate choice, not the default: `runOnce()` must work for any\n`smrt-core` consumer without that consumer registering a class and running a\nmigration for a table they've never heard of. A Mechanism-B table only exists\nonce an app imports and registers its owning class; a Mechanism-A table\nexists for every `smrt-core` database unconditionally, which is what a\nframework-wide primitive needs.\n\nAdding the table required:\n\n- A new DDL constant in `ALL_SYSTEM_TABLES`.\n- Bumping `SMRT_SCHEMA_VERSION` (1.10.1 → 1.11.0) — `CREATE TABLE IF NOT\n EXISTS` is a no-op on an existing database, so nothing upgrades without the\n bump.\n- A new `SMRT_SCHEMA_DDL_CHECKSUMS` entry — `system-schema-evolution.test.ts`\n recomputes the DDL hash and fails the build if it does not match the\n recorded value for the current version.\n\nNo new column was added to an existing system table, so no\n`addColumnIfMissing()` entry in `system/compatibility.ts` was needed. Live\nschema parity (`checkLiveSchemaParity`, `smrt doctor --db` /\n`db:status --parity`) picks the table up automatically: system-table shape\nparsing (`schema/system-table-shapes.ts`) derives its expectation from the\nsame DDL rather than a hand-maintained list.\n\n## Table shape\n\n```sql\nCREATE TABLE IF NOT EXISTS _smrt_run_once_claims (\n claim_key TEXT PRIMARY KEY, -- the derived key above\n tenant_id TEXT NOT NULL, -- redundant with claim_key; kept for admin queries and a defense-in-depth tenant filter on lookup\n actor TEXT NOT NULL, -- same\n content_digest TEXT NOT NULL, -- same, plus lets an operator distinguish \"same token, different content\" incidents\n status TEXT NOT NULL DEFAULT 'in_progress', -- 'in_progress' | 'completed'\n result TEXT, -- JSON-serialized work() return value, set only when status = 'completed'\n created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,\n completed_at TIMESTAMP\n);\n```\n\n`claim_key` is the PRIMARY KEY — the \"insert-only against a UNIQUE column\"\nthe issue asked for. A second `INSERT` for the same key never adopts the\nexisting row.\n\n**Why the claim insert is raw SQL (`INSERT ... ON CONFLICT (claim_key) DO\nNOTHING RETURNING claim_key`), not a plain `INSERT` caught for a classified\n`unique_violation`:** on PostgreSQL, a statement that raises an error aborts\nthe WHOLE transaction (\"current transaction is aborted, commands ignored\nuntil end of transaction block\") — every later statement in that same\ntransaction fails too, including the `SELECT` `runOnce()` needs to read the\nwinner's stored result back. A first version of this primitive did exactly\nthat (plain `INSERT` + catch classified via `isUniqueViolationError`,\n`../src/db-errors.ts`) and every replay/double-submit on PostgreSQL broke:\nreview caught it before merge. `DO NOTHING` never raises — it reports zero\nrows via `RETURNING` instead — so the transaction's error state is never\ntouched. SQLite and DuckDB support the identical `ON CONFLICT ... DO\nNOTHING` / `RETURNING` syntax (already used elsewhere in this codebase for\n`_smrt_migrations`, `system/bootstrap.ts`), so one query is portable across\nevery adapter without engine branching.\n\n## Atomicity and rollback\n\n`runOnce()` opens `db.transaction()` (or nests under a caller-supplied\ntransaction, per `@happyvertical/sql`'s own SAVEPOINT /\n`NestedTransactionError` rules) and, inside it: inserts the claim\n(`in_progress`), runs `work(tx)` with that SAME transaction-bound handle, then\nupdates the claim to `completed` with the JSON-serialized result — all one\ntransaction.\n\nIf `work()` throws, the callback throws, and `db.transaction()`'s own\ncommit-on-success/rollback-on-error contract undoes the claim insert along\nwith whatever `work()` partially wrote. A claim is never stranded\n`in_progress` by a failed attempt; a retry with the same key can always run\nagain. No compensating delete is needed — this falls out of using one\ntransaction for the whole claim+work+completion sequence, not a special case\nin `runOnce()`'s own code.\n\n## Concurrency\n\n`@happyvertical/sql`'s single-connection adapters (SQLite, DuckDB, JSON)\nserialize `transaction()` calls on one connection: a second, concurrent\n`runOnce()` call does not begin its own transaction until the first ends.\nPostgreSQL pools, but the claim insert's arbiter still has to check the\nunique index against another transaction's uncommitted row of the same\nPRIMARY KEY, and blocks until that transaction resolves, then either no-ops\n(committed — `DO NOTHING` fires, zero rows) or inserts (rolled back). On\nevery adapter, two concurrent callers with the same key never both run\n`work()`, and the loser never sees a PostgreSQL error for it — only zero\nrows back, at which point it reads the now-committed, now-`completed` claim\nrow and replays its stored result.\n\nThis is also why the \"in flight\" and \"unknown outcome\" branches below are\nnormally unreachable through `runOnce()`-to-`runOnce()` races alone on a\nsingle connection — the losing transaction can only ever observe a\nCOMMITTED row, and this module only ever commits a row once it is\n`completed`. They are kept as defined answers (not assumptions) for a claim\nrow that reached that state some other way, and are exercised in\n`src/__tests__/issue-3080-run-once.test.ts` by seeding the table directly and\nby unit-testing the pure resolver (`resolveExistingRunOnceClaim`).\n\n## Typed answers: refused vs. unknown outcome (mirrors #2990)\n\nA conflict on insert is a *definite* decision — the row exists — but whether\nit is safe to hand back a result depends on `status`:\n\n| Row state after conflict | Result |\n|---|---|\n| `status: 'completed'` | Replay the stored `result`. No error. |\n| exists, not completed | `RunOnceClaimError.inFlight()` — `code: 'RUN_ONCE_IN_FLIGHT'`. Some execution holds this key right now; retry later with the SAME token and content. |\n| conflict reported, no row readable | `RunOnceClaimError.outcomeUnknown()` — `code: 'RUN_ONCE_OUTCOME_UNKNOWN'`. Genuinely indeterminate; retrying with the SAME token and content is still safe. |\n\nBoth error codes live on one `RunOnceClaimError` class (`src/errors.ts`),\nfollowing this codebase's existing error-hierarchy convention (one class,\nmultiple codes via static factories — see `TenantIsolationError`,\n`DatabaseError`) rather than a bespoke class hierarchy. Neither is retried\nautomatically by `runOnce()` itself: only the caller knows whether retrying\ninline is appropriate for its own request.\n\n## Retention: completed claims are pruned; the window is a replay deadline\n\n`_smrt_run_once_claims` is append-only in normal operation — every submission\nadds a row and nothing else ever deletes one — so, like `_smrt_changes`,\n`_smrt_ai_usage`, and `_smrt_dispatch`, it is wired into\n`system/retention.ts`'s `runRetentionSweep()` as a built-in task\n(`'run-once-claims'`), covered by `DEFAULT_RETENTION_POLICY.runOnceClaims`\n(default `{ maxAgeDays: 30 }`, overridable per policy or disabled with\n`runOnceClaims: false`). `pruneRunOnceClaims()` deletes only rows with\n`status = 'completed'` AND `completed_at` older than the window; an\n`'in_progress'` row has no `completed_at` and can never match this predicate,\nso a claim held by a live (or abandoned) transaction is never pruned by age\nalone. The predicate is covered by\n`idx_smrt_run_once_claims_status_completed` (`status, completed_at`), added\nin `SMRT_SCHEMA_VERSION` 1.11.1.\n\n**The window is a replay deadline, not just disk hygiene.** `runOnce()`'s\nentire contract — a retry or double-submit with the SAME token and content\nreplays the first attempt's stored result instead of running `work()` again\n— depends on the claim row still existing. Once retention sweeps a completed\nclaim, a client that replays that exact request (same `tenantId`, `actor`,\n`token`, and content) no longer collides with anything: `runOnce()` sees no\nexisting row, wins the insert, and runs `work()` a second time. This is\nsilent — there is no error, just a second execution of whatever `work()`\ndoes. The default 30-day window is chosen to be comfortably longer than any\nrealistic client retry window (a browser tab left open, a queued mobile\nrequest, a webhook redelivery schedule) for the same reason the other\ndefault windows in `DEFAULT_RETENTION_POLICY` are generous relative to their\nconsumers (see `system/retention.ts`'s module doc). A consumer with a\nlonger-lived retry/replay window (e.g. an at-least-once delivery queue with a\nmulti-week redelivery tail) must raise `runOnceClaims.maxAgeDays` to exceed\nit, not rely on the default.\n\nTests: `src/__tests__/issue-2375-retention.test.ts` covers\n`pruneRunOnceClaims()` end-to-end through `runRetentionSweep()` — an old\ncompleted claim is removed, a recent completed claim and an old\n`in_progress` claim both survive.\n\n## Tests\n\n`src/__tests__/issue-3080-run-once.test.ts` (real in-memory SQLite via\n`getTestDatabase({ classes: [] })` — no `@smrt()` model is needed, only the\nsystem table):\n\n- Runs work once; returns its result.\n- Retry after completion replays the stored result without re-running.\n- Concurrent double submit (`Promise.all`) produces one claim row and the\n same result for both callers.\n- Same token, different content → two separate claims (guards the\n token-alone regression this issue was filed against).\n- Same token/content, different actor → separate claim.\n- Same token/content, different tenant → separate claim.\n- Work throws → claim rolls back; a subsequent retry with the same key runs.\n- A claim seeded directly as `in_progress` → `RunOnceClaimError.inFlight()`,\n `work()` never called.\n- Content-digest stability (key order) and sensitivity (value change) are\n unit-tested directly.\n- `resolveExistingRunOnceClaim` (pure resolver) is unit-tested for all three\n branches, including the otherwise-unreachable `outcomeUnknown` case.\n\n`src/__tests__/issue-3080-run-once-postgres.optional.test.ts` (real\nPostgreSQL, `SMRT_TEST_POSTGRES_URL`, `describe.skipIf`, following\n`change-feed-concurrency.optional.test.ts`'s established pattern: distinct\n`dbid`s for two genuinely concurrent connections, `getTestDatabase({ db,\nclasses: [] })` to bootstrap system tables): replay after completion, and\nconcurrent double submit across the two real connections — both would\nreject with a PostgreSQL `DatabaseError` (\"Failed to retrieve record from\ntable\", the driver's wrapping of 25P02 \"current transaction is aborted\")\nagainst the pre-fix plain-`INSERT`-and-catch version; verified locally\nagainst a disposable `postgres:18-alpine` container (revert via patch file,\nconfirmed both tests red, restored, confirmed both green) since the SQLite\nsuite cannot reach this PostgreSQL-specific failure mode at all.\n"
1136
1142
  }
1137
1143
  ]
1138
1144
  }
@@ -128,4 +128,15 @@ export declare function isFromPackage(qualifiedName: string, packageName: string
128
128
  * ```
129
129
  */
130
130
  export declare function isType(qualifiedName: string | null | undefined, shortName: string): boolean;
131
+ /**
132
+ * A valid `previousQualifiedNames` entry: exactly what {@link isQualifiedName}
133
+ * accepts (`@scope/package:ClassName` — the predicate eager lookups,
134
+ * `resolveType()` and the lazy manifest loader all use), with an identifier
135
+ * class name and exactly one colon. An unscoped `package:ClassName` is
136
+ * refused: no qualified lookup treats it as qualified, so it could never
137
+ * resolve (#3338).
138
+ */
139
+ export declare function isQualifiedNameAliasFormat(name: unknown): name is string;
140
+ /** The declared aliases of one decorator/manifest config, as strings. */
141
+ export declare function readPreviousQualifiedNames(config: unknown): string[];
131
142
  //# sourceMappingURL=qualified-names.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"qualified-names.d.ts","sourceRoot":"","sources":["../../src/utils/qualified-names.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,kBAAkB,CAcpB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,mBAAmB,CAgB7E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,kBAAkB,CAyBxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKjD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK5E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAEjE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,GAClB,OAAO,CAMT;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,MAAM,CACpB,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,SAAS,EAAE,MAAM,GAChB,OAAO,CAKT"}
1
+ {"version":3,"file":"qualified-names.d.ts","sourceRoot":"","sources":["../../src/utils/qualified-names.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAE9D;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,kBAAkB,CAcpB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,WAAW,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,aAAa,EAAE,MAAM,GAAG,mBAAmB,CAgB7E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,IAAI,kBAAkB,CAyBxE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKjD;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK5E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAEjE;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAC3B,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,GAClB,OAAO,CAMT;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,MAAM,CACpB,aAAa,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACxC,SAAS,EAAE,MAAM,GAChB,OAAO,CAKT;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,IAAI,MAAM,CAWxE;AAED,yEAAyE;AACzE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,EAAE,CAMpE"}
@@ -143,7 +143,27 @@ function isType(qualifiedName, shortName) {
143
143
  if (!qualifiedName) return false;
144
144
  return getClassName(qualifiedName) === shortName;
145
145
  }
146
+ /**
147
+ * A valid `previousQualifiedNames` entry: exactly what {@link isQualifiedName}
148
+ * accepts (`@scope/package:ClassName` — the predicate eager lookups,
149
+ * `resolveType()` and the lazy manifest loader all use), with an identifier
150
+ * class name and exactly one colon. An unscoped `package:ClassName` is
151
+ * refused: no qualified lookup treats it as qualified, so it could never
152
+ * resolve (#3338).
153
+ */
154
+ function isQualifiedNameAliasFormat(name) {
155
+ if (typeof name !== "string" || !isQualifiedName(name)) return false;
156
+ const colon = name.lastIndexOf(":");
157
+ return name.indexOf(":") === colon && !/\s/u.test(name.slice(0, colon)) && /^[A-Za-z_$][\w$]*$/u.test(name.slice(colon + 1));
158
+ }
159
+ /** The declared aliases of one decorator/manifest config, as strings. */
160
+ function readPreviousQualifiedNames(config) {
161
+ if (!config || typeof config !== "object") return [];
162
+ const declared = config.previousQualifiedNames;
163
+ if (!Array.isArray(declared)) return [];
164
+ return declared.filter((name) => typeof name === "string");
165
+ }
146
166
  //#endregion
147
- export { createQualifiedName, getClassName, getPackageFromQualifiedName, isFromPackage, isQualifiedName, isType, parseQualifiedName, qualifiedNamesEqual };
167
+ export { createQualifiedName, getClassName, getPackageFromQualifiedName, isFromPackage, isQualifiedName, isQualifiedNameAliasFormat, isType, parseQualifiedName, qualifiedNamesEqual, readPreviousQualifiedNames };
148
168
 
149
169
  //# sourceMappingURL=qualified-names.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"qualified-names.js","names":[],"sources":["../../src/utils/qualified-names.ts"],"sourcesContent":["/**\n * Utilities for working with qualified class names in the SMRT framework.\n *\n * Qualified names follow the format: \"@package/name:ClassName\"\n * Example: \"@happyvertical/smrt-core:Product\"\n *\n * This format uniquely identifies classes across packages, eliminating\n * name collisions when multiple packages define classes with the same name.\n */\n\nimport type { QualifiedClassName } from '../scanner/types.js';\n\n/**\n * Creates a qualified class name from package name and class name.\n *\n * @param packageName - The npm package name (e.g., \"@happyvertical/smrt-core\")\n * @param className - The class name (e.g., \"Product\")\n * @returns Qualified name in format \"@package/name:ClassName\"\n *\n * @example\n * createQualifiedName('@happyvertical/smrt-core', 'Product')\n * // Returns: \"@happyvertical/smrt-core:Product\"\n */\nexport function createQualifiedName(\n packageName: string,\n className: string,\n): QualifiedClassName {\n if (!packageName) {\n throw new Error(\n `Cannot create qualified name: packageName is required. ` +\n `Got className=\"${className}\" but no packageName.`,\n );\n }\n if (!className) {\n throw new Error(\n `Cannot create qualified name: className is required. ` +\n `Got packageName=\"${packageName}\" but no className.`,\n );\n }\n return `${packageName}:${className}` as QualifiedClassName;\n}\n\n/**\n * Result of parsing a qualified class name.\n */\nexport interface ParsedQualifiedName {\n /** The package name (e.g., \"@happyvertical/smrt-core\") */\n packageName: string;\n /** The class name (e.g., \"Product\") */\n className: string;\n}\n\n/**\n * Parses a qualified class name into its components.\n *\n * @param qualifiedName - The qualified name to parse\n * @returns Object with packageName and className\n * @throws Error if the name is not a valid qualified name\n *\n * @example\n * parseQualifiedName('@happyvertical/smrt-core:Product')\n * // Returns: { packageName: '@happyvertical/smrt-core', className: 'Product' }\n */\nexport function parseQualifiedName(qualifiedName: string): ParsedQualifiedName {\n if (!isQualifiedName(qualifiedName)) {\n throw new Error(\n `Invalid qualified name: \"${qualifiedName}\". ` +\n `Expected format: \"@package/name:ClassName\"`,\n );\n }\n\n // Use lastIndexOf to handle edge cases like \"@scope/pkg:Nested:Class\"\n // (though class names shouldn't have colons, this is defensive)\n const colonIndex = qualifiedName.lastIndexOf(':');\n\n return {\n packageName: qualifiedName.slice(0, colonIndex),\n className: qualifiedName.slice(colonIndex + 1),\n };\n}\n\n/**\n * Checks if a name is a valid qualified class name.\n *\n * A valid qualified name:\n * - Contains a colon (:) separating package and class\n * - Has a package name with a forward slash (scoped npm package)\n * - Has a non-empty class name after the colon\n *\n * @param name - The name to check\n * @returns true if the name is a valid qualified class name\n *\n * @example\n * isQualifiedName('@happyvertical/smrt-core:Product') // true\n * isQualifiedName('Product') // false\n * isQualifiedName('smrt-core:Product') // false (no @ scope)\n */\nexport function isQualifiedName(name: string): name is QualifiedClassName {\n if (!name || typeof name !== 'string') {\n return false;\n }\n\n const colonIndex = name.lastIndexOf(':');\n if (colonIndex === -1) {\n return false;\n }\n\n const packageName = name.slice(0, colonIndex);\n const className = name.slice(colonIndex + 1);\n\n // Package name must be a scoped npm package (contains @)\n // and have a path (contains /)\n if (!packageName.startsWith('@') || !packageName.includes('/')) {\n return false;\n }\n\n // Class name must be non-empty\n if (!className) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Extracts just the class name from a qualified name or simple name.\n * Useful when you need the class name regardless of whether\n * the input is qualified or not.\n *\n * @param name - Qualified name or simple class name\n * @returns The simple class name\n *\n * @example\n * getClassName('@happyvertical/smrt-core:Product') // 'Product'\n * getClassName('Product') // 'Product'\n */\nexport function getClassName(name: string): string {\n if (isQualifiedName(name)) {\n return parseQualifiedName(name).className;\n }\n return name;\n}\n\n/**\n * Extracts the package name from a qualified name.\n * Returns undefined if the name is not qualified.\n *\n * @param name - Qualified name or simple class name\n * @returns The package name, or undefined if not qualified\n *\n * @example\n * getPackageFromQualifiedName('@happyvertical/smrt-core:Product')\n * // Returns: '@happyvertical/smrt-core'\n *\n * getPackageFromQualifiedName('Product')\n * // Returns: undefined\n */\nexport function getPackageFromQualifiedName(name: string): string | undefined {\n if (isQualifiedName(name)) {\n return parseQualifiedName(name).packageName;\n }\n return undefined;\n}\n\n/**\n * Compares two qualified names for equality.\n * Case-sensitive comparison.\n *\n * @param a - First qualified name\n * @param b - Second qualified name\n * @returns true if the qualified names are equal\n */\nexport function qualifiedNamesEqual(a: string, b: string): boolean {\n return a === b;\n}\n\n/**\n * Checks if a qualified name belongs to a specific package.\n *\n * @param qualifiedName - The qualified name to check\n * @param packageName - The package name to match against\n * @returns true if the qualified name is from the specified package\n *\n * @example\n * isFromPackage('@happyvertical/smrt-core:Product', '@happyvertical/smrt-core')\n * // Returns: true\n */\nexport function isFromPackage(\n qualifiedName: string,\n packageName: string,\n): boolean {\n if (!isQualifiedName(qualifiedName)) {\n return false;\n }\n const parsed = parseQualifiedName(qualifiedName);\n return parsed.packageName === packageName;\n}\n\n/**\n * Checks if a qualified name (like `_meta_type`) matches a short class name.\n * This is useful for STI filtering where the database stores qualified names\n * but code often uses short names for comparisons.\n *\n * @param qualifiedName - The qualified name or simple name to check (e.g., from `_meta_type`)\n * @param shortName - The short class name to match against\n * @returns true if the class name portion matches\n *\n * @example\n * ```typescript\n * // Filtering STI records by type\n * const meetings = events.filter(e => isType(e._meta_type, 'Meeting'));\n *\n * // Works with both qualified and simple names\n * isType('@happyvertical/praeco:MeetingRecap', 'MeetingRecap') // true\n * isType('@happyvertical/praeco:MeetingRecap', 'Article') // false\n * isType('MeetingRecap', 'MeetingRecap') // true (simple name passthrough)\n * ```\n */\nexport function isType(\n qualifiedName: string | null | undefined,\n shortName: string,\n): boolean {\n if (!qualifiedName) {\n return false;\n }\n return getClassName(qualifiedName) === shortName;\n}\n"],"mappings":";;;;;;;;;;;;AAuBA,SAAgB,oBACd,aACA,WACoB;CACpB,IAAI,CAAC,aACH,MAAM,IAAI,MACR,yEACoB,UAAU,sBAChC;CAEF,IAAI,CAAC,WACH,MAAM,IAAI,MACR,yEACsB,YAAY,oBACpC;CAEF,OAAO,GAAG,YAAY,GAAG;AAC3B;;;;;;;;;;;;AAuBA,SAAgB,mBAAmB,eAA4C;CAC7E,IAAI,CAAC,gBAAgB,aAAa,GAChC,MAAM,IAAI,MACR,4BAA4B,cAAc,8CAE5C;CAKF,MAAM,aAAa,cAAc,YAAY,GAAG;CAEhD,OAAO;EACL,aAAa,cAAc,MAAM,GAAG,UAAU;EAC9C,WAAW,cAAc,MAAM,aAAa,CAAC;CAC/C;AACF;;;;;;;;;;;;;;;;;AAkBA,SAAgB,gBAAgB,MAA0C;CACxE,IAAI,CAAC,QAAQ,OAAO,SAAS,UAC3B,OAAO;CAGT,MAAM,aAAa,KAAK,YAAY,GAAG;CACvC,IAAI,eAAe,IACjB,OAAO;CAGT,MAAM,cAAc,KAAK,MAAM,GAAG,UAAU;CAC5C,MAAM,YAAY,KAAK,MAAM,aAAa,CAAC;CAI3C,IAAI,CAAC,YAAY,WAAW,GAAG,KAAK,CAAC,YAAY,SAAS,GAAG,GAC3D,OAAO;CAIT,IAAI,CAAC,WACH,OAAO;CAGT,OAAO;AACT;;;;;;;;;;;;;AAcA,SAAgB,aAAa,MAAsB;CACjD,IAAI,gBAAgB,IAAI,GACtB,OAAO,mBAAmB,IAAI,CAAC,CAAC;CAElC,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,4BAA4B,MAAkC;CAC5E,IAAI,gBAAgB,IAAI,GACtB,OAAO,mBAAmB,IAAI,CAAC,CAAC;AAGpC;;;;;;;;;AAUA,SAAgB,oBAAoB,GAAW,GAAoB;CACjE,OAAO,MAAM;AACf;;;;;;;;;;;;AAaA,SAAgB,cACd,eACA,aACS;CACT,IAAI,CAAC,gBAAgB,aAAa,GAChC,OAAO;CAGT,OADe,mBAAmB,aAC3B,CAAA,CAAO,gBAAgB;AAChC;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,OACd,eACA,WACS;CACT,IAAI,CAAC,eACH,OAAO;CAET,OAAO,aAAa,aAAa,MAAM;AACzC"}
1
+ {"version":3,"file":"qualified-names.js","names":[],"sources":["../../src/utils/qualified-names.ts"],"sourcesContent":["/**\n * Utilities for working with qualified class names in the SMRT framework.\n *\n * Qualified names follow the format: \"@package/name:ClassName\"\n * Example: \"@happyvertical/smrt-core:Product\"\n *\n * This format uniquely identifies classes across packages, eliminating\n * name collisions when multiple packages define classes with the same name.\n */\n\nimport type { QualifiedClassName } from '../scanner/types.js';\n\n/**\n * Creates a qualified class name from package name and class name.\n *\n * @param packageName - The npm package name (e.g., \"@happyvertical/smrt-core\")\n * @param className - The class name (e.g., \"Product\")\n * @returns Qualified name in format \"@package/name:ClassName\"\n *\n * @example\n * createQualifiedName('@happyvertical/smrt-core', 'Product')\n * // Returns: \"@happyvertical/smrt-core:Product\"\n */\nexport function createQualifiedName(\n packageName: string,\n className: string,\n): QualifiedClassName {\n if (!packageName) {\n throw new Error(\n `Cannot create qualified name: packageName is required. ` +\n `Got className=\"${className}\" but no packageName.`,\n );\n }\n if (!className) {\n throw new Error(\n `Cannot create qualified name: className is required. ` +\n `Got packageName=\"${packageName}\" but no className.`,\n );\n }\n return `${packageName}:${className}` as QualifiedClassName;\n}\n\n/**\n * Result of parsing a qualified class name.\n */\nexport interface ParsedQualifiedName {\n /** The package name (e.g., \"@happyvertical/smrt-core\") */\n packageName: string;\n /** The class name (e.g., \"Product\") */\n className: string;\n}\n\n/**\n * Parses a qualified class name into its components.\n *\n * @param qualifiedName - The qualified name to parse\n * @returns Object with packageName and className\n * @throws Error if the name is not a valid qualified name\n *\n * @example\n * parseQualifiedName('@happyvertical/smrt-core:Product')\n * // Returns: { packageName: '@happyvertical/smrt-core', className: 'Product' }\n */\nexport function parseQualifiedName(qualifiedName: string): ParsedQualifiedName {\n if (!isQualifiedName(qualifiedName)) {\n throw new Error(\n `Invalid qualified name: \"${qualifiedName}\". ` +\n `Expected format: \"@package/name:ClassName\"`,\n );\n }\n\n // Use lastIndexOf to handle edge cases like \"@scope/pkg:Nested:Class\"\n // (though class names shouldn't have colons, this is defensive)\n const colonIndex = qualifiedName.lastIndexOf(':');\n\n return {\n packageName: qualifiedName.slice(0, colonIndex),\n className: qualifiedName.slice(colonIndex + 1),\n };\n}\n\n/**\n * Checks if a name is a valid qualified class name.\n *\n * A valid qualified name:\n * - Contains a colon (:) separating package and class\n * - Has a package name with a forward slash (scoped npm package)\n * - Has a non-empty class name after the colon\n *\n * @param name - The name to check\n * @returns true if the name is a valid qualified class name\n *\n * @example\n * isQualifiedName('@happyvertical/smrt-core:Product') // true\n * isQualifiedName('Product') // false\n * isQualifiedName('smrt-core:Product') // false (no @ scope)\n */\nexport function isQualifiedName(name: string): name is QualifiedClassName {\n if (!name || typeof name !== 'string') {\n return false;\n }\n\n const colonIndex = name.lastIndexOf(':');\n if (colonIndex === -1) {\n return false;\n }\n\n const packageName = name.slice(0, colonIndex);\n const className = name.slice(colonIndex + 1);\n\n // Package name must be a scoped npm package (contains @)\n // and have a path (contains /)\n if (!packageName.startsWith('@') || !packageName.includes('/')) {\n return false;\n }\n\n // Class name must be non-empty\n if (!className) {\n return false;\n }\n\n return true;\n}\n\n/**\n * Extracts just the class name from a qualified name or simple name.\n * Useful when you need the class name regardless of whether\n * the input is qualified or not.\n *\n * @param name - Qualified name or simple class name\n * @returns The simple class name\n *\n * @example\n * getClassName('@happyvertical/smrt-core:Product') // 'Product'\n * getClassName('Product') // 'Product'\n */\nexport function getClassName(name: string): string {\n if (isQualifiedName(name)) {\n return parseQualifiedName(name).className;\n }\n return name;\n}\n\n/**\n * Extracts the package name from a qualified name.\n * Returns undefined if the name is not qualified.\n *\n * @param name - Qualified name or simple class name\n * @returns The package name, or undefined if not qualified\n *\n * @example\n * getPackageFromQualifiedName('@happyvertical/smrt-core:Product')\n * // Returns: '@happyvertical/smrt-core'\n *\n * getPackageFromQualifiedName('Product')\n * // Returns: undefined\n */\nexport function getPackageFromQualifiedName(name: string): string | undefined {\n if (isQualifiedName(name)) {\n return parseQualifiedName(name).packageName;\n }\n return undefined;\n}\n\n/**\n * Compares two qualified names for equality.\n * Case-sensitive comparison.\n *\n * @param a - First qualified name\n * @param b - Second qualified name\n * @returns true if the qualified names are equal\n */\nexport function qualifiedNamesEqual(a: string, b: string): boolean {\n return a === b;\n}\n\n/**\n * Checks if a qualified name belongs to a specific package.\n *\n * @param qualifiedName - The qualified name to check\n * @param packageName - The package name to match against\n * @returns true if the qualified name is from the specified package\n *\n * @example\n * isFromPackage('@happyvertical/smrt-core:Product', '@happyvertical/smrt-core')\n * // Returns: true\n */\nexport function isFromPackage(\n qualifiedName: string,\n packageName: string,\n): boolean {\n if (!isQualifiedName(qualifiedName)) {\n return false;\n }\n const parsed = parseQualifiedName(qualifiedName);\n return parsed.packageName === packageName;\n}\n\n/**\n * Checks if a qualified name (like `_meta_type`) matches a short class name.\n * This is useful for STI filtering where the database stores qualified names\n * but code often uses short names for comparisons.\n *\n * @param qualifiedName - The qualified name or simple name to check (e.g., from `_meta_type`)\n * @param shortName - The short class name to match against\n * @returns true if the class name portion matches\n *\n * @example\n * ```typescript\n * // Filtering STI records by type\n * const meetings = events.filter(e => isType(e._meta_type, 'Meeting'));\n *\n * // Works with both qualified and simple names\n * isType('@happyvertical/praeco:MeetingRecap', 'MeetingRecap') // true\n * isType('@happyvertical/praeco:MeetingRecap', 'Article') // false\n * isType('MeetingRecap', 'MeetingRecap') // true (simple name passthrough)\n * ```\n */\nexport function isType(\n qualifiedName: string | null | undefined,\n shortName: string,\n): boolean {\n if (!qualifiedName) {\n return false;\n }\n return getClassName(qualifiedName) === shortName;\n}\n\n/**\n * A valid `previousQualifiedNames` entry: exactly what {@link isQualifiedName}\n * accepts (`@scope/package:ClassName` — the predicate eager lookups,\n * `resolveType()` and the lazy manifest loader all use), with an identifier\n * class name and exactly one colon. An unscoped `package:ClassName` is\n * refused: no qualified lookup treats it as qualified, so it could never\n * resolve (#3338).\n */\nexport function isQualifiedNameAliasFormat(name: unknown): name is string {\n if (typeof name !== 'string' || !isQualifiedName(name)) return false;\n const colon = name.lastIndexOf(':');\n // `isQualifiedName()` splits at the LAST colon, so `@scope/pkg:x:Class`\n // would name the package `@scope/pkg:x`, which the lazy loader cannot\n // resolve. An alias's package part may not contain a colon.\n return (\n name.indexOf(':') === colon &&\n !/\\s/u.test(name.slice(0, colon)) &&\n /^[A-Za-z_$][\\w$]*$/u.test(name.slice(colon + 1))\n );\n}\n\n/** The declared aliases of one decorator/manifest config, as strings. */\nexport function readPreviousQualifiedNames(config: unknown): string[] {\n if (!config || typeof config !== 'object') return [];\n const declared = (config as { previousQualifiedNames?: unknown })\n .previousQualifiedNames;\n if (!Array.isArray(declared)) return [];\n return declared.filter((name): name is string => typeof name === 'string');\n}\n"],"mappings":";;;;;;;;;;;;AAuBA,SAAgB,oBACd,aACA,WACoB;CACpB,IAAI,CAAC,aACH,MAAM,IAAI,MACR,yEACoB,UAAU,sBAChC;CAEF,IAAI,CAAC,WACH,MAAM,IAAI,MACR,yEACsB,YAAY,oBACpC;CAEF,OAAO,GAAG,YAAY,GAAG;AAC3B;;;;;;;;;;;;AAuBA,SAAgB,mBAAmB,eAA4C;CAC7E,IAAI,CAAC,gBAAgB,aAAa,GAChC,MAAM,IAAI,MACR,4BAA4B,cAAc,8CAE5C;CAKF,MAAM,aAAa,cAAc,YAAY,GAAG;CAEhD,OAAO;EACL,aAAa,cAAc,MAAM,GAAG,UAAU;EAC9C,WAAW,cAAc,MAAM,aAAa,CAAC;CAC/C;AACF;;;;;;;;;;;;;;;;;AAkBA,SAAgB,gBAAgB,MAA0C;CACxE,IAAI,CAAC,QAAQ,OAAO,SAAS,UAC3B,OAAO;CAGT,MAAM,aAAa,KAAK,YAAY,GAAG;CACvC,IAAI,eAAe,IACjB,OAAO;CAGT,MAAM,cAAc,KAAK,MAAM,GAAG,UAAU;CAC5C,MAAM,YAAY,KAAK,MAAM,aAAa,CAAC;CAI3C,IAAI,CAAC,YAAY,WAAW,GAAG,KAAK,CAAC,YAAY,SAAS,GAAG,GAC3D,OAAO;CAIT,IAAI,CAAC,WACH,OAAO;CAGT,OAAO;AACT;;;;;;;;;;;;;AAcA,SAAgB,aAAa,MAAsB;CACjD,IAAI,gBAAgB,IAAI,GACtB,OAAO,mBAAmB,IAAI,CAAC,CAAC;CAElC,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,SAAgB,4BAA4B,MAAkC;CAC5E,IAAI,gBAAgB,IAAI,GACtB,OAAO,mBAAmB,IAAI,CAAC,CAAC;AAGpC;;;;;;;;;AAUA,SAAgB,oBAAoB,GAAW,GAAoB;CACjE,OAAO,MAAM;AACf;;;;;;;;;;;;AAaA,SAAgB,cACd,eACA,aACS;CACT,IAAI,CAAC,gBAAgB,aAAa,GAChC,OAAO;CAGT,OADe,mBAAmB,aAC3B,CAAA,CAAO,gBAAgB;AAChC;;;;;;;;;;;;;;;;;;;;;AAsBA,SAAgB,OACd,eACA,WACS;CACT,IAAI,CAAC,eACH,OAAO;CAET,OAAO,aAAa,aAAa,MAAM;AACzC;;;;;;;;;AAUA,SAAgB,2BAA2B,MAA+B;CACxE,IAAI,OAAO,SAAS,YAAY,CAAC,gBAAgB,IAAI,GAAG,OAAO;CAC/D,MAAM,QAAQ,KAAK,YAAY,GAAG;CAIlC,OACE,KAAK,QAAQ,GAAG,MAAM,SACtB,CAAC,MAAM,KAAK,KAAK,MAAM,GAAG,KAAK,CAAC,KAChC,sBAAsB,KAAK,KAAK,MAAM,QAAQ,CAAC,CAAC;AAEpD;;AAGA,SAAgB,2BAA2B,QAA2B;CACpE,IAAI,CAAC,UAAU,OAAO,WAAW,UAAU,OAAO,CAAC;CACnD,MAAM,WAAY,OACf;CACH,IAAI,CAAC,MAAM,QAAQ,QAAQ,GAAG,OAAO,CAAC;CACtC,OAAO,SAAS,QAAQ,SAAyB,OAAO,SAAS,QAAQ;AAC3E"}
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-core",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "Core AI agent framework with standardized collections, object-relational mapping, and code generators",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
7
7
  "engines": {
8
- "node": ">=24.18.0"
8
+ "node": ">=26.0.0"
9
9
  },
10
10
  "main": "dist/index.js",
11
11
  "types": "dist/index.d.ts",
@@ -150,15 +150,15 @@
150
150
  }
151
151
  },
152
152
  "dependencies": {
153
- "@happyvertical/ai": "^0.96.1",
154
- "@happyvertical/files": "^0.96.1",
155
- "@happyvertical/json": "^0.96.1",
156
- "@happyvertical/logger": "^0.96.1",
157
- "@happyvertical/smrt-config": "0.52.0",
158
- "@happyvertical/smrt-scanner": "0.52.0",
159
- "@happyvertical/smrt-types": "0.52.0",
160
- "@happyvertical/sql": "^0.96.1",
161
- "@happyvertical/utils": "^0.96.1",
153
+ "@happyvertical/ai": "^0.98.0",
154
+ "@happyvertical/files": "^0.98.0",
155
+ "@happyvertical/json": "^0.98.0",
156
+ "@happyvertical/logger": "^0.98.0",
157
+ "@happyvertical/smrt-config": "0.53.0",
158
+ "@happyvertical/smrt-scanner": "0.53.0",
159
+ "@happyvertical/smrt-types": "0.53.0",
160
+ "@happyvertical/sql": "^0.98.0",
161
+ "@happyvertical/utils": "^0.98.0",
162
162
  "@oxc-project/runtime": "^0.150.0",
163
163
  "cheerio": "^1.2.0",
164
164
  "fast-glob": "3.3.3",