@happyvertical/smrt-core 0.44.0 → 0.45.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 (104) hide show
  1. package/AGENTS.md +8 -8
  2. package/agents/change-feed.md +3 -2
  3. package/agents/generators.md +34 -0
  4. package/agents/revision-guard.md +66 -0
  5. package/dist/browser.js +2 -1
  6. package/dist/cascade.d.ts +5 -9
  7. package/dist/cascade.d.ts.map +1 -1
  8. package/dist/cascade.js +65 -30
  9. package/dist/cascade.js.map +1 -1
  10. package/dist/change-feed.d.ts +60 -1
  11. package/dist/change-feed.d.ts.map +1 -1
  12. package/dist/change-feed.js +464 -27
  13. package/dist/change-feed.js.map +1 -1
  14. package/dist/change-signals.d.ts.map +1 -1
  15. package/dist/change-signals.js +13 -11
  16. package/dist/change-signals.js.map +1 -1
  17. package/dist/embedded-write-queue.d.ts +8 -0
  18. package/dist/embedded-write-queue.d.ts.map +1 -1
  19. package/dist/embedded-write-queue.js +12 -2
  20. package/dist/embedded-write-queue.js.map +1 -1
  21. package/dist/generators/cli.d.ts.map +1 -1
  22. package/dist/generators/cli.js +10 -18
  23. package/dist/generators/cli.js.map +1 -1
  24. package/dist/generators/custom-action.d.ts +216 -0
  25. package/dist/generators/custom-action.d.ts.map +1 -1
  26. package/dist/generators/custom-action.js +256 -1
  27. package/dist/generators/custom-action.js.map +1 -1
  28. package/dist/generators/index.d.ts +2 -1
  29. package/dist/generators/index.d.ts.map +1 -1
  30. package/dist/generators/index.js +3 -2
  31. package/dist/generators/mcp.d.ts.map +1 -1
  32. package/dist/generators/mcp.js +14 -39
  33. package/dist/generators/mcp.js.map +1 -1
  34. package/dist/generators/preflight-route.d.ts +151 -0
  35. package/dist/generators/preflight-route.d.ts.map +1 -0
  36. package/dist/generators/preflight-route.js +194 -0
  37. package/dist/generators/preflight-route.js.map +1 -0
  38. package/dist/generators/rest.d.ts +12 -0
  39. package/dist/generators/rest.d.ts.map +1 -1
  40. package/dist/generators/rest.js +14 -15
  41. package/dist/generators/rest.js.map +1 -1
  42. package/dist/generators/tool-schema.d.ts.map +1 -1
  43. package/dist/generators/tool-schema.js +2 -8
  44. package/dist/generators/tool-schema.js.map +1 -1
  45. package/dist/generators.js +3 -2
  46. package/dist/index.d.ts +3 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +7 -4
  49. package/dist/knowledge.d.ts.map +1 -1
  50. package/dist/knowledge.js +283 -35
  51. package/dist/knowledge.js.map +1 -1
  52. package/dist/manifest/static-manifest.d.ts.map +1 -1
  53. package/dist/manifest/static-manifest.js +6 -2
  54. package/dist/manifest/static-manifest.js.map +1 -1
  55. package/dist/manifest/store.js +1 -1
  56. package/dist/manifest/store.js.map +1 -1
  57. package/dist/manifest.json +8 -2
  58. package/dist/object.d.ts +31 -1
  59. package/dist/object.d.ts.map +1 -1
  60. package/dist/object.js +57 -9
  61. package/dist/object.js.map +1 -1
  62. package/dist/registry/framework-base-classes.d.ts +10 -0
  63. package/dist/registry/framework-base-classes.d.ts.map +1 -0
  64. package/dist/registry/framework-base-classes.js +92 -0
  65. package/dist/registry/framework-base-classes.js.map +1 -0
  66. package/dist/registry/schema-builder.d.ts.map +1 -1
  67. package/dist/registry/schema-builder.js +2 -0
  68. package/dist/registry/schema-builder.js.map +1 -1
  69. package/dist/registry/types.d.ts +27 -6
  70. package/dist/registry/types.d.ts.map +1 -1
  71. package/dist/registry.d.ts +1 -0
  72. package/dist/registry.d.ts.map +1 -1
  73. package/dist/registry.js +2 -1
  74. package/dist/registry.js.map +1 -1
  75. package/dist/revision-guard.d.ts +84 -0
  76. package/dist/revision-guard.d.ts.map +1 -0
  77. package/dist/revision-guard.js +120 -0
  78. package/dist/revision-guard.js.map +1 -0
  79. package/dist/scanner/manifest-generator.d.ts +46 -16
  80. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  81. package/dist/scanner/manifest-generator.js +198 -45
  82. package/dist/scanner/manifest-generator.js.map +1 -1
  83. package/dist/smrt-knowledge.json +18 -9
  84. package/dist/system/bootstrap.d.ts.map +1 -1
  85. package/dist/system/bootstrap.js +2 -1
  86. package/dist/system/bootstrap.js.map +1 -1
  87. package/dist/system/schema.d.ts +75 -2
  88. package/dist/system/schema.d.ts.map +1 -1
  89. package/dist/system/schema.js +326 -4
  90. package/dist/system/schema.js.map +1 -1
  91. package/dist/vite-plugin/index.d.ts.map +1 -1
  92. package/dist/vite-plugin/index.js +48 -23
  93. package/dist/vite-plugin/index.js.map +1 -1
  94. package/dist/vite-plugin/sveltekit-generator.d.ts +33 -7
  95. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  96. package/dist/vite-plugin/sveltekit-generator.js +88 -17
  97. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  98. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
  99. package/dist/vite-plugin/sync-apply-route.js +2 -0
  100. package/dist/vite-plugin/sync-apply-route.js.map +1 -1
  101. package/dist/vite-plugin/web-collections.d.ts.map +1 -1
  102. package/dist/vite-plugin/web-collections.js +28 -5
  103. package/dist/vite-plugin/web-collections.js.map +1 -1
  104. package/package.json +4 -4
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Revision compare-and-swap predicate construction (#2620).
3
+ *
4
+ * `SmrtObject.save()` and {@link SmrtObject.claimRevision} guard every write to
5
+ * a persisted row with a predicate on the revision the caller loaded. On
6
+ * PostgreSQL that predicate cannot be a plain equality against
7
+ * `Date.prototype.toISOString()`, because the JavaScript `Date` that carries the
8
+ * revision is two lossy conversions away from the stored value:
9
+ *
10
+ * 1. **Precision.** `updated_at` is a microsecond column — `timestamptz(6)` on
11
+ * schemas this version materializes, `timestamp(6)` on older ones — so any
12
+ * row last written by raw SQL (`updated_at = CURRENT_TIMESTAMP` / `now()`)
13
+ * stores microseconds, for example `2026-09-02 08:11:28.939980`. A `Date`
14
+ * holds milliseconds, so the exact-equality predicate matched no row and
15
+ * every later `save()` on that row raised `RUNTIME_REVISION_CONFLICT`
16
+ * forever — a permanent failure, not a lost race.
17
+ * 2. **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping
18
+ * still hold `updated_at` as `timestamp WITHOUT time zone`, and `pg`
19
+ * hydrates that type by reading the stored wall clock in the *process* zone.
20
+ * On a non-UTC host the resulting `Date` is offset from the instant the
21
+ * writer meant, so `toISOString()` produced a wall clock the row never held
22
+ * and every guarded save conflicted. The same columns are also written under
23
+ * three different conventions — `pg` serializes a `Date` in the process
24
+ * zone, `claimRevision()` writes a UTC ISO string, and raw
25
+ * `CURRENT_TIMESTAMP` writes in the *server* zone — so no single rendering
26
+ * can match every row.
27
+ *
28
+ * Both are fixed here without asking callers to compensate:
29
+ *
30
+ * - the column is truncated to milliseconds in SQL, the finest precision a
31
+ * `Date` can represent, so a microsecond tail can no longer hide the row; and
32
+ * - the revision is offered in **both** wall-clock renderings — the process-zone
33
+ * one (the inverse of `pg`'s local hydration of `timestamp`) and the UTC one
34
+ * (what `timestamptz` hydration and SMRT's own ISO writes produce). Each is
35
+ * tagged `+00`, which a `timestamptz` comparison honours and a `timestamp`
36
+ * comparison discards, so the predicate never depends on the *session*
37
+ * TimeZone either. Accepting either candidate keeps the guard correct
38
+ * whatever convention the column and driver use, so a future UTC-hydration
39
+ * fix in `@happyvertical/sql` cannot silently break it. That driver-layer
40
+ * half is tracked as happyvertical/sdk#1223; this guard deliberately does not
41
+ * wait for it.
42
+ *
43
+ * Lost-race semantics are preserved. A concurrent writer advances `updated_at`
44
+ * to roughly "now" (see `nextRevisionTimestamp`), which would have to land on
45
+ * the loaded revision — or, on a non-UTC process only, on exactly that revision
46
+ * shifted by the process's whole-hour-scale UTC offset — to the millisecond
47
+ * before it could slip past. Any ordinary concurrent write differs by at least
48
+ * one millisecond and still conflicts. On a UTC process the two renderings
49
+ * coincide and the predicate is single-valued, so it is strictly no weaker than
50
+ * the exact equality it replaces. Making it single-valued on a non-UTC process
51
+ * too — by resolving the column's actual type, or by deleting the process-zone
52
+ * rendering once happyvertical/sdk#1223 hydrates `timestamp` as UTC — is
53
+ * tracked as #2623.
54
+ *
55
+ * The predicate is PostgreSQL-only. Embedded engines take the compare/upsert
56
+ * fallback in `usesEmbeddedRevisionFallback`, and remote LibSQL stores ISO text
57
+ * whose exact equality already round-trips losslessly.
58
+ */
59
+ /** The SQL expression the PostgreSQL revision predicate compares against. */
60
+ export declare const POSTGRES_REVISION_GUARD_EXPRESSION = "date_trunc('milliseconds', updated_at)";
61
+ /**
62
+ * Render a revision as every `timestamp without time zone` wall clock it could
63
+ * legitimately correspond to, at millisecond precision.
64
+ *
65
+ * The process-zone rendering comes first because it is the inverse of `pg`'s
66
+ * current hydration; the UTC rendering is what SMRT's own writes persist. On a
67
+ * UTC process the two coincide and a single candidate is returned.
68
+ *
69
+ * @param revision - Revision loaded from the row, or supplied by the caller as
70
+ * `save({ expectedUpdatedAt })` / `claimRevision()`. Strings are parsed with
71
+ * `Date` semantics, so an ISO instant and a bare SQL wall clock both work.
72
+ * @returns One or two `YYYY-MM-DD HH:MM:SS.mmm+00` strings
73
+ * @throws {RangeError} If `revision` does not parse to a valid date
74
+ */
75
+ export declare function postgresRevisionCandidates(revision: Date | string): string[];
76
+ /**
77
+ * Build the PostgreSQL revision condition for a generic `db.update()` WHERE
78
+ * clause.
79
+ *
80
+ * @param revision - The revision the writer loaded
81
+ * @returns A single-entry condition object to spread into the update predicate
82
+ */
83
+ export declare function postgresRevisionCondition(revision: Date | string): Record<string, string[]>;
84
+ //# sourceMappingURL=revision-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revision-guard.d.ts","sourceRoot":"","sources":["../src/revision-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AAIH,6EAA6E;AAC7E,eAAO,MAAM,kCAAkC,2CACL,CAAC;AAgB3C;;;;;;;;;;;;;GAaG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,IAAI,GAAG,MAAM,GAAG,MAAM,EAAE,CA0B5E;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,IAAI,GAAG,MAAM,GACtB,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAK1B"}
@@ -0,0 +1,120 @@
1
+ import { raw } from "@happyvertical/sql";
2
+ //#region src/revision-guard.ts
3
+ /**
4
+ * Revision compare-and-swap predicate construction (#2620).
5
+ *
6
+ * `SmrtObject.save()` and {@link SmrtObject.claimRevision} guard every write to
7
+ * a persisted row with a predicate on the revision the caller loaded. On
8
+ * PostgreSQL that predicate cannot be a plain equality against
9
+ * `Date.prototype.toISOString()`, because the JavaScript `Date` that carries the
10
+ * revision is two lossy conversions away from the stored value:
11
+ *
12
+ * 1. **Precision.** `updated_at` is a microsecond column — `timestamptz(6)` on
13
+ * schemas this version materializes, `timestamp(6)` on older ones — so any
14
+ * row last written by raw SQL (`updated_at = CURRENT_TIMESTAMP` / `now()`)
15
+ * stores microseconds, for example `2026-09-02 08:11:28.939980`. A `Date`
16
+ * holds milliseconds, so the exact-equality predicate matched no row and
17
+ * every later `save()` on that row raised `RUNTIME_REVISION_CONFLICT`
18
+ * forever — a permanent failure, not a lost race.
19
+ * 2. **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping
20
+ * still hold `updated_at` as `timestamp WITHOUT time zone`, and `pg`
21
+ * hydrates that type by reading the stored wall clock in the *process* zone.
22
+ * On a non-UTC host the resulting `Date` is offset from the instant the
23
+ * writer meant, so `toISOString()` produced a wall clock the row never held
24
+ * and every guarded save conflicted. The same columns are also written under
25
+ * three different conventions — `pg` serializes a `Date` in the process
26
+ * zone, `claimRevision()` writes a UTC ISO string, and raw
27
+ * `CURRENT_TIMESTAMP` writes in the *server* zone — so no single rendering
28
+ * can match every row.
29
+ *
30
+ * Both are fixed here without asking callers to compensate:
31
+ *
32
+ * - the column is truncated to milliseconds in SQL, the finest precision a
33
+ * `Date` can represent, so a microsecond tail can no longer hide the row; and
34
+ * - the revision is offered in **both** wall-clock renderings — the process-zone
35
+ * one (the inverse of `pg`'s local hydration of `timestamp`) and the UTC one
36
+ * (what `timestamptz` hydration and SMRT's own ISO writes produce). Each is
37
+ * tagged `+00`, which a `timestamptz` comparison honours and a `timestamp`
38
+ * comparison discards, so the predicate never depends on the *session*
39
+ * TimeZone either. Accepting either candidate keeps the guard correct
40
+ * whatever convention the column and driver use, so a future UTC-hydration
41
+ * fix in `@happyvertical/sql` cannot silently break it. That driver-layer
42
+ * half is tracked as happyvertical/sdk#1223; this guard deliberately does not
43
+ * wait for it.
44
+ *
45
+ * Lost-race semantics are preserved. A concurrent writer advances `updated_at`
46
+ * to roughly "now" (see `nextRevisionTimestamp`), which would have to land on
47
+ * the loaded revision — or, on a non-UTC process only, on exactly that revision
48
+ * shifted by the process's whole-hour-scale UTC offset — to the millisecond
49
+ * before it could slip past. Any ordinary concurrent write differs by at least
50
+ * one millisecond and still conflicts. On a UTC process the two renderings
51
+ * coincide and the predicate is single-valued, so it is strictly no weaker than
52
+ * the exact equality it replaces. Making it single-valued on a non-UTC process
53
+ * too — by resolving the column's actual type, or by deleting the process-zone
54
+ * rendering once happyvertical/sdk#1223 hydrates `timestamp` as UTC — is
55
+ * tracked as #2623.
56
+ *
57
+ * The predicate is PostgreSQL-only. Embedded engines take the compare/upsert
58
+ * fallback in `usesEmbeddedRevisionFallback`, and remote LibSQL stores ISO text
59
+ * whose exact equality already round-trips losslessly.
60
+ */
61
+ /** The SQL expression the PostgreSQL revision predicate compares against. */
62
+ var POSTGRES_REVISION_GUARD_EXPRESSION = "date_trunc('milliseconds', updated_at)";
63
+ function pad(value, width = 2) {
64
+ return String(value).padStart(width, "0");
65
+ }
66
+ function formatWallClock(parts) {
67
+ const [year, month, day, hours, minutes, seconds, milliseconds] = parts;
68
+ return `${pad(year, 4)}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}+00`;
69
+ }
70
+ /**
71
+ * Render a revision as every `timestamp without time zone` wall clock it could
72
+ * legitimately correspond to, at millisecond precision.
73
+ *
74
+ * The process-zone rendering comes first because it is the inverse of `pg`'s
75
+ * current hydration; the UTC rendering is what SMRT's own writes persist. On a
76
+ * UTC process the two coincide and a single candidate is returned.
77
+ *
78
+ * @param revision - Revision loaded from the row, or supplied by the caller as
79
+ * `save({ expectedUpdatedAt })` / `claimRevision()`. Strings are parsed with
80
+ * `Date` semantics, so an ISO instant and a bare SQL wall clock both work.
81
+ * @returns One or two `YYYY-MM-DD HH:MM:SS.mmm+00` strings
82
+ * @throws {RangeError} If `revision` does not parse to a valid date
83
+ */
84
+ function postgresRevisionCandidates(revision) {
85
+ const date = revision instanceof Date ? revision : new Date(revision);
86
+ if (Number.isNaN(date.getTime())) throw new RangeError(`Revision guard requires a valid timestamp, received: ${String(revision)}`);
87
+ const local = formatWallClock([
88
+ date.getFullYear(),
89
+ date.getMonth() + 1,
90
+ date.getDate(),
91
+ date.getHours(),
92
+ date.getMinutes(),
93
+ date.getSeconds(),
94
+ date.getMilliseconds()
95
+ ]);
96
+ const utc = formatWallClock([
97
+ date.getUTCFullYear(),
98
+ date.getUTCMonth() + 1,
99
+ date.getUTCDate(),
100
+ date.getUTCHours(),
101
+ date.getUTCMinutes(),
102
+ date.getUTCSeconds(),
103
+ date.getUTCMilliseconds()
104
+ ]);
105
+ return local === utc ? [local] : [local, utc];
106
+ }
107
+ /**
108
+ * Build the PostgreSQL revision condition for a generic `db.update()` WHERE
109
+ * clause.
110
+ *
111
+ * @param revision - The revision the writer loaded
112
+ * @returns A single-entry condition object to spread into the update predicate
113
+ */
114
+ function postgresRevisionCondition(revision) {
115
+ return { [raw(`${POSTGRES_REVISION_GUARD_EXPRESSION} in`)]: postgresRevisionCandidates(revision) };
116
+ }
117
+ //#endregion
118
+ export { POSTGRES_REVISION_GUARD_EXPRESSION, postgresRevisionCandidates, postgresRevisionCondition };
119
+
120
+ //# sourceMappingURL=revision-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"revision-guard.js","names":[],"sources":["../src/revision-guard.ts"],"sourcesContent":["/**\n * Revision compare-and-swap predicate construction (#2620).\n *\n * `SmrtObject.save()` and {@link SmrtObject.claimRevision} guard every write to\n * a persisted row with a predicate on the revision the caller loaded. On\n * PostgreSQL that predicate cannot be a plain equality against\n * `Date.prototype.toISOString()`, because the JavaScript `Date` that carries the\n * revision is two lossy conversions away from the stored value:\n *\n * 1. **Precision.** `updated_at` is a microsecond column — `timestamptz(6)` on\n * schemas this version materializes, `timestamp(6)` on older ones — so any\n * row last written by raw SQL (`updated_at = CURRENT_TIMESTAMP` / `now()`)\n * stores microseconds, for example `2026-09-02 08:11:28.939980`. A `Date`\n * holds milliseconds, so the exact-equality predicate matched no row and\n * every later `save()` on that row raised `RUNTIME_REVISION_CONFLICT`\n * forever — a permanent failure, not a lost race.\n * 2. **Process timezone.** Schemas created before the `TIMESTAMPTZ` mapping\n * still hold `updated_at` as `timestamp WITHOUT time zone`, and `pg`\n * hydrates that type by reading the stored wall clock in the *process* zone.\n * On a non-UTC host the resulting `Date` is offset from the instant the\n * writer meant, so `toISOString()` produced a wall clock the row never held\n * and every guarded save conflicted. The same columns are also written under\n * three different conventions — `pg` serializes a `Date` in the process\n * zone, `claimRevision()` writes a UTC ISO string, and raw\n * `CURRENT_TIMESTAMP` writes in the *server* zone — so no single rendering\n * can match every row.\n *\n * Both are fixed here without asking callers to compensate:\n *\n * - the column is truncated to milliseconds in SQL, the finest precision a\n * `Date` can represent, so a microsecond tail can no longer hide the row; and\n * - the revision is offered in **both** wall-clock renderings — the process-zone\n * one (the inverse of `pg`'s local hydration of `timestamp`) and the UTC one\n * (what `timestamptz` hydration and SMRT's own ISO writes produce). Each is\n * tagged `+00`, which a `timestamptz` comparison honours and a `timestamp`\n * comparison discards, so the predicate never depends on the *session*\n * TimeZone either. Accepting either candidate keeps the guard correct\n * whatever convention the column and driver use, so a future UTC-hydration\n * fix in `@happyvertical/sql` cannot silently break it. That driver-layer\n * half is tracked as happyvertical/sdk#1223; this guard deliberately does not\n * wait for it.\n *\n * Lost-race semantics are preserved. A concurrent writer advances `updated_at`\n * to roughly \"now\" (see `nextRevisionTimestamp`), which would have to land on\n * the loaded revision — or, on a non-UTC process only, on exactly that revision\n * shifted by the process's whole-hour-scale UTC offset — to the millisecond\n * before it could slip past. Any ordinary concurrent write differs by at least\n * one millisecond and still conflicts. On a UTC process the two renderings\n * coincide and the predicate is single-valued, so it is strictly no weaker than\n * the exact equality it replaces. Making it single-valued on a non-UTC process\n * too — by resolving the column's actual type, or by deleting the process-zone\n * rendering once happyvertical/sdk#1223 hydrates `timestamp` as UTC — is\n * tracked as #2623.\n *\n * The predicate is PostgreSQL-only. Embedded engines take the compare/upsert\n * fallback in `usesEmbeddedRevisionFallback`, and remote LibSQL stores ISO text\n * whose exact equality already round-trips losslessly.\n */\n\nimport { raw } from '@happyvertical/sql';\n\n/** The SQL expression the PostgreSQL revision predicate compares against. */\nexport const POSTGRES_REVISION_GUARD_EXPRESSION =\n \"date_trunc('milliseconds', updated_at)\";\n\nfunction pad(value: number, width = 2): string {\n return String(value).padStart(width, '0');\n}\n\nfunction formatWallClock(\n parts: [number, number, number, number, number, number, number],\n): string {\n const [year, month, day, hours, minutes, seconds, milliseconds] = parts;\n return (\n `${pad(year, 4)}-${pad(month)}-${pad(day)} ` +\n `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}+00`\n );\n}\n\n/**\n * Render a revision as every `timestamp without time zone` wall clock it could\n * legitimately correspond to, at millisecond precision.\n *\n * The process-zone rendering comes first because it is the inverse of `pg`'s\n * current hydration; the UTC rendering is what SMRT's own writes persist. On a\n * UTC process the two coincide and a single candidate is returned.\n *\n * @param revision - Revision loaded from the row, or supplied by the caller as\n * `save({ expectedUpdatedAt })` / `claimRevision()`. Strings are parsed with\n * `Date` semantics, so an ISO instant and a bare SQL wall clock both work.\n * @returns One or two `YYYY-MM-DD HH:MM:SS.mmm+00` strings\n * @throws {RangeError} If `revision` does not parse to a valid date\n */\nexport function postgresRevisionCandidates(revision: Date | string): string[] {\n const date = revision instanceof Date ? revision : new Date(revision);\n if (Number.isNaN(date.getTime())) {\n throw new RangeError(\n `Revision guard requires a valid timestamp, received: ${String(revision)}`,\n );\n }\n const local = formatWallClock([\n date.getFullYear(),\n date.getMonth() + 1,\n date.getDate(),\n date.getHours(),\n date.getMinutes(),\n date.getSeconds(),\n date.getMilliseconds(),\n ]);\n const utc = formatWallClock([\n date.getUTCFullYear(),\n date.getUTCMonth() + 1,\n date.getUTCDate(),\n date.getUTCHours(),\n date.getUTCMinutes(),\n date.getUTCSeconds(),\n date.getUTCMilliseconds(),\n ]);\n return local === utc ? [local] : [local, utc];\n}\n\n/**\n * Build the PostgreSQL revision condition for a generic `db.update()` WHERE\n * clause.\n *\n * @param revision - The revision the writer loaded\n * @returns A single-entry condition object to spread into the update predicate\n */\nexport function postgresRevisionCondition(\n revision: Date | string,\n): Record<string, string[]> {\n return {\n [raw(`${POSTGRES_REVISION_GUARD_EXPRESSION} in`)]:\n postgresRevisionCandidates(revision),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8DA,IAAa,qCACX;AAEF,SAAS,IAAI,OAAe,QAAQ,GAAW;CAC7C,OAAO,OAAO,KAAK,CAAC,CAAC,SAAS,OAAO,GAAG;AAC1C;AAEA,SAAS,gBACP,OACQ;CACR,MAAM,CAAC,MAAM,OAAO,KAAK,OAAO,SAAS,SAAS,gBAAgB;CAClE,OACE,GAAG,IAAI,MAAM,CAAC,EAAE,GAAG,IAAI,KAAK,EAAE,GAAG,IAAI,GAAG,EAAE,GACvC,IAAI,KAAK,EAAE,GAAG,IAAI,OAAO,EAAE,GAAG,IAAI,OAAO,EAAE,GAAG,IAAI,cAAc,CAAC,EAAE;AAE1E;;;;;;;;;;;;;;;AAgBA,SAAgB,2BAA2B,UAAmC;CAC5E,MAAM,OAAO,oBAAoB,OAAO,WAAW,IAAI,KAAK,QAAQ;CACpE,IAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,GAC7B,MAAM,IAAI,WACR,wDAAwD,OAAO,QAAQ,GACzE;CAEF,MAAM,QAAQ,gBAAgB;EAC5B,KAAK,YAAY;EACjB,KAAK,SAAS,IAAI;EAClB,KAAK,QAAQ;EACb,KAAK,SAAS;EACd,KAAK,WAAW;EAChB,KAAK,WAAW;EAChB,KAAK,gBAAgB;CACvB,CAAC;CACD,MAAM,MAAM,gBAAgB;EAC1B,KAAK,eAAe;EACpB,KAAK,YAAY,IAAI;EACrB,KAAK,WAAW;EAChB,KAAK,YAAY;EACjB,KAAK,cAAc;EACnB,KAAK,cAAc;EACnB,KAAK,mBAAmB;CAC1B,CAAC;CACD,OAAO,UAAU,MAAM,CAAC,KAAK,IAAI,CAAC,OAAO,GAAG;AAC9C;;;;;;;;AASA,SAAgB,0BACd,UAC0B;CAC1B,OAAO,GACJ,IAAI,GAAG,mCAAmC,IAAI,IAC7C,2BAA2B,QAAQ,EACvC;AACF"}
@@ -131,22 +131,6 @@ export declare class ManifestGenerator {
131
131
  * Also checks external SMRT packages for parent class definitions.
132
132
  */
133
133
  private isSTIChildClass;
134
- /**
135
- * Check whether `obj` extends a framework abstract base class anywhere
136
- * in its chain.
137
- *
138
- * Framework abstract bases (`SmrtHierarchical`, `SmrtJunction`, …) have
139
- * no table of their own — fields they declare must be merged into every
140
- * subclass's manifest, even when the subclass uses CTI. Without this,
141
- * a class like `Account extends SmrtHierarchical` would silently lose
142
- * `parentId` from its `fields` map and downstream WHERE-clause
143
- * validation would reject queries on the inherited column.
144
- *
145
- * Identified by name against the same hardcoded set the scanner's
146
- * `FRAMEWORK_BASE_CLASSES` recognizes (`packages/scanner/src/
147
- * inheritance-resolver.ts`). Keep the two lists in sync.
148
- */
149
- private extendsFrameworkAbstractBase;
150
134
  /**
151
135
  * Resolve a class name (simple `Content` or qualified `@pkg:Content`) to
152
136
  * the key it is stored under in `manifest.objects`, or `undefined` when the
@@ -290,6 +274,52 @@ export declare class ManifestGenerator {
290
274
  * Generate simple MCP tool names for testing/documentation
291
275
  */
292
276
  generateMCPTools(manifest: SmartObjectManifest): string;
277
+ /**
278
+ * Choose exactly one canonical MCP owner per `collection`.
279
+ *
280
+ * MCP tool names are keyed on `collection`, but the framework's normal shape
281
+ * puts a model class and its `FooCollection` access class in the manifest
282
+ * under the SAME collection. Emitting both produces duplicate tool names with
283
+ * differing create/update schemas, and an MCP client that indexes tools by
284
+ * name then resolves an arbitrary one (#2631 review).
285
+ *
286
+ * Owners are ranked, in order:
287
+ *
288
+ * 1. The model class beats the collection-access class. `create`/`update`
289
+ * input schemas derive from `obj.fields`, and the row shape lives on the
290
+ * model, not on the access class.
291
+ * 2. The shallowest class in the collection's own inheritance tree wins. An
292
+ * STI family shares one `collection`, and the base defines the shared row
293
+ * contract; a leaf subclass would narrow the emitted schema to one variant
294
+ * of a table the tool addresses as a whole.
295
+ * 3. Remaining ties break lexically on the qualified name, so the emitted
296
+ * surface is deterministic across generation passes.
297
+ */
298
+ private selectMCPToolOwners;
299
+ /** Stable identity for an MCP owner candidate. */
300
+ private mcpOwnerKey;
301
+ /**
302
+ * Depth of each candidate inside the candidate set's OWN inheritance tree,
303
+ * keyed by {@link mcpOwnerKey}. A candidate with no candidate ancestor is a
304
+ * root at depth 0. Ancestors outside the set (framework bases, classes in
305
+ * other collections) do not add depth: only the shared-collection family
306
+ * decides which member is canonical.
307
+ *
308
+ * Parents resolve on `extendsQualified` first. Two packages may legally
309
+ * contribute same-simple-name classes to one collection, so a simple-name
310
+ * match is used only when it is unambiguous within the candidate set —
311
+ * otherwise the walk stops rather than linking unrelated classes.
312
+ */
313
+ private inheritanceDepthsWithin;
314
+ /** True when `candidate` is the better canonical MCP owner than `current`. */
315
+ private preferMCPToolOwner;
316
+ /**
317
+ * Collection-access class detection, matching the convention already used by
318
+ * `generateRestRoutes` (direct base / generic type argument) plus the
319
+ * `FooCollection` naming fallback this generator uses elsewhere, so a deeper
320
+ * subclass that carries no type argument of its own is still recognised.
321
+ */
322
+ private isCollectionAccessClass;
293
323
  /**
294
324
  * Generate MCP tool JSON definitions
295
325
  */
@@ -1 +1 @@
1
- {"version":3,"file":"manifest-generator.d.ts","sourceRoot":"","sources":["../../src/scanner/manifest-generator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"manifest-generator.d.ts","sourceRoot":"","sources":["../../src/scanner/manifest-generator.ts"],"names":[],"mappings":"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"}
@@ -47,6 +47,57 @@ var FRAMEWORK_ABSTRACT_BASE_NAMES = /* @__PURE__ */ new Set([
47
47
  "SmrtReportCollection"
48
48
  ]);
49
49
  /**
50
+ * Framework base classes to exclude from the ancestor **method** merge
51
+ * below (#2624). Unlike fields, methods are never columns, so they never
52
+ * needed the STI-vs-CTI field-merge rule above — they inherited it only by
53
+ * sharing the same loop.
54
+ *
55
+ * Deliberately NARROWER than `FRAMEWORK_ABSTRACT_BASE_NAMES`: only the
56
+ * three universal object/collection primitives (`SmrtObject`, `SmrtClass`,
57
+ * `SmrtCollection`) are internal-only. These are the same three the
58
+ * runtime resolver's `getAllMethods()`
59
+ * (`packages/core/src/registry/inheritance-resolver.ts`) explicitly skips
60
+ * by name, and the same three the field-merge doc comment above excludes
61
+ * from `FRAMEWORK_ABSTRACT_BASE_NAMES` for the identical reason: their
62
+ * surface (`save`, `destroy`, `toJSON`, `withTransaction`, ...) is generic
63
+ * object-lifecycle plumbing, never a subclass-specific action.
64
+ *
65
+ * `FRAMEWORK_ABSTRACT_BASE_NAMES` (`SmrtJunction`, `SmrtHierarchical`,
66
+ * `SmrtPolymorphicAssociation`, `SmrtReport`, `SmrtReportCollection`) is
67
+ * intentionally NOT folded in here, unlike an earlier version of this fix.
68
+ * Those classes are mixin-style bases whose declared methods ARE the
69
+ * subclass's real, intended public API — the same way `SmrtHierarchical.
70
+ * parentId` is a real, intended field, which is exactly why
71
+ * `FRAMEWORK_ABSTRACT_BASE_NAMES` fields already merge into every subclass
72
+ * regardless of STI/CTI (see above). Directly confirmed for two of the
73
+ * five: `SmrtJunction.attach`/`detach`/`byLeft`/`byRight`/`setLinks`
74
+ * (`packages/core/src/junction.ts`) — a `packages/template-sveltekit/
75
+ * __tests__/runtimeProfileParity.test.ts` snapshot broke when an earlier
76
+ * revision of this fix folded all 8 `FRAMEWORK_BASE_CLASSES` in here,
77
+ * which is how this got caught — and `SmrtHierarchical.getParent`/
78
+ * `getChildren`/`getAncestors`/`getDescendants`/`getHierarchy`/`moveTo`
79
+ * (`packages/core/src/hierarchical.ts`). `SmrtPolymorphicAssociation`
80
+ * follows the identical pattern by inspection but has no dedicated
81
+ * regression test yet. `SmrtReport`/`SmrtReportCollection` are the
82
+ * least-audited of the five: `SmrtReportCollection` overrides `list`/`get`
83
+ * (`packages/reports/src/report.ts`), which are also the universal CRUD
84
+ * operation names — a downstream `class X extends SmrtReportCollection`
85
+ * with default (non-strict) MCP config would have seen a generated
86
+ * custom-action tool collide with the standard `list`/`get` tool name.
87
+ * #2646 closed that separately and independently of this exclusion set: the
88
+ * MCP generator now refuses to expose a CRUD-named method as a custom action,
89
+ * matching case-folded because its tool ids are lowercased whole. Each emitter
90
+ * reserves on its own terms, and not all of them read the shared
91
+ * `CRUD_OPERATIONS` list yet — see its docblock in
92
+ * `generators/custom-action.ts` for the per-emitter rules. Keep in sync with the field-merge set's own doc comment; this one
93
+ * governs method merging only.
94
+ */
95
+ var FRAMEWORK_METHOD_BASE_NAMES = /* @__PURE__ */ new Set([
96
+ "SmrtObject",
97
+ "SmrtClass",
98
+ "SmrtCollection"
99
+ ]);
100
+ /**
50
101
  * Infer visibility from file path and explicit config
51
102
  *
52
103
  * Priority:
@@ -605,36 +656,6 @@ var ManifestGenerator = class {
605
656
  return false;
606
657
  }
607
658
  /**
608
- * Check whether `obj` extends a framework abstract base class anywhere
609
- * in its chain.
610
- *
611
- * Framework abstract bases (`SmrtHierarchical`, `SmrtJunction`, …) have
612
- * no table of their own — fields they declare must be merged into every
613
- * subclass's manifest, even when the subclass uses CTI. Without this,
614
- * a class like `Account extends SmrtHierarchical` would silently lose
615
- * `parentId` from its `fields` map and downstream WHERE-clause
616
- * validation would reject queries on the inherited column.
617
- *
618
- * Identified by name against the same hardcoded set the scanner's
619
- * `FRAMEWORK_BASE_CLASSES` recognizes (`packages/scanner/src/
620
- * inheritance-resolver.ts`). Keep the two lists in sync.
621
- */
622
- extendsFrameworkAbstractBase(obj, objectsByName, manifest) {
623
- if (!obj.extends) return false;
624
- let currentClass = obj.extends;
625
- const visited = /* @__PURE__ */ new Set();
626
- while (currentClass) {
627
- if (visited.has(currentClass)) break;
628
- visited.add(currentClass);
629
- if (FRAMEWORK_ABSTRACT_BASE_NAMES.has(currentClass)) return true;
630
- let parentObj = objectsByName.get(currentClass);
631
- if (!parentObj && manifest.smrtDependencies && manifest.smrtDependencies.length > 0) parentObj = this.loadParentFromExternalPackage(currentClass, manifest.smrtDependencies, objectsByName);
632
- if (!parentObj) break;
633
- currentClass = parentObj.extends;
634
- }
635
- return false;
636
- }
637
- /**
638
659
  * Resolve a class name (simple `Content` or qualified `@pkg:Content`) to
639
660
  * the key it is stored under in `manifest.objects`, or `undefined` when the
640
661
  * manifest does not carry it. Manifest keys are qualified names; `extends`
@@ -728,8 +749,6 @@ var ManifestGenerator = class {
728
749
  for (const obj of Object.values(manifest.objects)) {
729
750
  if (!obj.extends) continue;
730
751
  const usesSTI = this.isSTIClass(obj, objectsByName, manifest);
731
- const extendsFrameworkBase = this.extendsFrameworkAbstractBase(obj, objectsByName, manifest);
732
- if (!usesSTI && !extendsFrameworkBase) continue;
733
752
  logger.info(`[manifest-generator] Merging inherited fields for ${obj.className} from ${obj.extends}`);
734
753
  const inheritanceChain = [];
735
754
  let currentClass = obj.extends;
@@ -755,8 +774,7 @@ var ManifestGenerator = class {
755
774
  const ancestor = objectsByName.get(ancestorName);
756
775
  if (!ancestor) continue;
757
776
  const ancestorIsFrameworkBase = FRAMEWORK_ABSTRACT_BASE_NAMES.has(ancestorName);
758
- if (!usesSTI && !ancestorIsFrameworkBase) continue;
759
- for (const [fieldName, fieldDef] of Object.entries(ancestor.fields)) {
777
+ if (usesSTI || ancestorIsFrameworkBase) for (const [fieldName, fieldDef] of Object.entries(ancestor.fields)) {
760
778
  const existingOwner = mergedFieldOwners.get(fieldName);
761
779
  const replacesFrameworkDefault = existingOwner !== void 0 && FRAMEWORK_ABSTRACT_BASE_NAMES.has(this.simpleClassName(existingOwner)) && !FRAMEWORK_ABSTRACT_BASE_NAMES.has(this.simpleClassName(ancestorName));
762
780
  if (!existingOwner || replacesFrameworkDefault) {
@@ -764,7 +782,7 @@ var ManifestGenerator = class {
764
782
  mergedFieldOwners.set(fieldName, ancestorName);
765
783
  }
766
784
  }
767
- for (const [methodName, methodDef] of Object.entries(ancestor.methods || {})) if (!mergedMethods[methodName]) mergedMethods[methodName] = methodDef;
785
+ if (!FRAMEWORK_METHOD_BASE_NAMES.has(ancestorName)) for (const [methodName, methodDef] of Object.entries(ancestor.methods || {})) mergedMethods[methodName] = methodDef;
768
786
  }
769
787
  for (const [fieldName, fieldDef] of Object.entries(obj.fields)) mergedFields[fieldName] = fieldDef;
770
788
  for (const [methodName, methodDef] of Object.entries(obj.methods || {})) mergedMethods[methodName] = methodDef;
@@ -1159,16 +1177,123 @@ ${fields}
1159
1177
  */
1160
1178
  generateMCPTools(manifest) {
1161
1179
  const tools = [];
1162
- for (const [_name, obj] of Object.entries(manifest.objects)) if (obj.decoratorConfig.mcp !== false) tools.push(...this.getSimpleMCPToolNames(obj));
1180
+ for (const obj of this.selectMCPToolOwners(manifest)) tools.push(...this.getSimpleMCPToolNames(obj));
1163
1181
  return tools.join("\n");
1164
1182
  }
1165
1183
  /**
1184
+ * Choose exactly one canonical MCP owner per `collection`.
1185
+ *
1186
+ * MCP tool names are keyed on `collection`, but the framework's normal shape
1187
+ * puts a model class and its `FooCollection` access class in the manifest
1188
+ * under the SAME collection. Emitting both produces duplicate tool names with
1189
+ * differing create/update schemas, and an MCP client that indexes tools by
1190
+ * name then resolves an arbitrary one (#2631 review).
1191
+ *
1192
+ * Owners are ranked, in order:
1193
+ *
1194
+ * 1. The model class beats the collection-access class. `create`/`update`
1195
+ * input schemas derive from `obj.fields`, and the row shape lives on the
1196
+ * model, not on the access class.
1197
+ * 2. The shallowest class in the collection's own inheritance tree wins. An
1198
+ * STI family shares one `collection`, and the base defines the shared row
1199
+ * contract; a leaf subclass would narrow the emitted schema to one variant
1200
+ * of a table the tool addresses as a whole.
1201
+ * 3. Remaining ties break lexically on the qualified name, so the emitted
1202
+ * surface is deterministic across generation passes.
1203
+ */
1204
+ selectMCPToolOwners(manifest) {
1205
+ const candidatesByCollection = /* @__PURE__ */ new Map();
1206
+ for (const obj of Object.values(manifest.objects)) {
1207
+ if (obj.decoratorConfig.mcp === false) continue;
1208
+ const bucket = candidatesByCollection.get(obj.collection);
1209
+ if (bucket) bucket.push(obj);
1210
+ else candidatesByCollection.set(obj.collection, [obj]);
1211
+ }
1212
+ return [...candidatesByCollection.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, candidates]) => {
1213
+ const depths = this.inheritanceDepthsWithin(candidates);
1214
+ let owner = candidates[0];
1215
+ for (const candidate of candidates.slice(1)) if (this.preferMCPToolOwner(candidate, owner, depths)) owner = candidate;
1216
+ return owner;
1217
+ });
1218
+ }
1219
+ /** Stable identity for an MCP owner candidate. */
1220
+ mcpOwnerKey(obj) {
1221
+ return obj.qualifiedName || obj.className;
1222
+ }
1223
+ /**
1224
+ * Depth of each candidate inside the candidate set's OWN inheritance tree,
1225
+ * keyed by {@link mcpOwnerKey}. A candidate with no candidate ancestor is a
1226
+ * root at depth 0. Ancestors outside the set (framework bases, classes in
1227
+ * other collections) do not add depth: only the shared-collection family
1228
+ * decides which member is canonical.
1229
+ *
1230
+ * Parents resolve on `extendsQualified` first. Two packages may legally
1231
+ * contribute same-simple-name classes to one collection, so a simple-name
1232
+ * match is used only when it is unambiguous within the candidate set —
1233
+ * otherwise the walk stops rather than linking unrelated classes.
1234
+ */
1235
+ inheritanceDepthsWithin(candidates) {
1236
+ const byQualified = /* @__PURE__ */ new Map();
1237
+ const bySimpleName = /* @__PURE__ */ new Map();
1238
+ for (const candidate of candidates) {
1239
+ byQualified.set(this.mcpOwnerKey(candidate), candidate);
1240
+ const simple = this.simpleClassName(candidate.className);
1241
+ bySimpleName.set(simple, bySimpleName.has(simple) ? null : candidate);
1242
+ }
1243
+ const resolveParent = (obj) => {
1244
+ if (obj.extendsQualified) return byQualified.get(obj.extendsQualified);
1245
+ if (!obj.extends) return void 0;
1246
+ return bySimpleName.get(this.simpleClassName(obj.extends)) ?? void 0;
1247
+ };
1248
+ const depths = /* @__PURE__ */ new Map();
1249
+ for (const candidate of candidates) {
1250
+ const key = this.mcpOwnerKey(candidate);
1251
+ let depth = 0;
1252
+ let current = candidate;
1253
+ const visited = /* @__PURE__ */ new Set([key]);
1254
+ while (current) {
1255
+ const parent = resolveParent(current);
1256
+ if (!parent) break;
1257
+ const parentKey = this.mcpOwnerKey(parent);
1258
+ if (visited.has(parentKey)) break;
1259
+ visited.add(parentKey);
1260
+ depth += 1;
1261
+ current = parent;
1262
+ }
1263
+ depths.set(key, depth);
1264
+ }
1265
+ return depths;
1266
+ }
1267
+ /** True when `candidate` is the better canonical MCP owner than `current`. */
1268
+ preferMCPToolOwner(candidate, current, depths) {
1269
+ const candidateIsCollection = this.isCollectionAccessClass(candidate);
1270
+ if (candidateIsCollection !== this.isCollectionAccessClass(current)) return !candidateIsCollection;
1271
+ const candidateKey = this.mcpOwnerKey(candidate);
1272
+ const currentKey = this.mcpOwnerKey(current);
1273
+ const candidateDepth = depths.get(candidateKey) ?? 0;
1274
+ const currentDepth = depths.get(currentKey) ?? 0;
1275
+ if (candidateDepth !== currentDepth) return candidateDepth < currentDepth;
1276
+ return candidateKey < currentKey;
1277
+ }
1278
+ /**
1279
+ * Collection-access class detection, matching the convention already used by
1280
+ * `generateRestRoutes` (direct base / generic type argument) plus the
1281
+ * `FooCollection` naming fallback this generator uses elsewhere, so a deeper
1282
+ * subclass that carries no type argument of its own is still recognised.
1283
+ */
1284
+ isCollectionAccessClass(obj) {
1285
+ return obj.extends === "SmrtCollection" || !!obj.extendsTypeArg || obj.className.endsWith("Collection");
1286
+ }
1287
+ /**
1166
1288
  * Generate MCP tool JSON definitions
1167
1289
  */
1168
1290
  generateMCPToolsCode(manifest) {
1169
1291
  const tools = [];
1170
- for (const [_name, obj] of Object.entries(manifest.objects)) if (obj.decoratorConfig.mcp !== false) tools.push(this.generateMCPTool(obj));
1171
- return `[\n${tools.join(",\n")}\n]`;
1292
+ for (const obj of this.selectMCPToolOwners(manifest)) {
1293
+ const code = this.generateMCPTool(obj);
1294
+ if (code) tools.push(code);
1295
+ }
1296
+ return tools.length > 0 ? `[\n${tools.join(",\n")}\n]` : "[]";
1172
1297
  }
1173
1298
  /**
1174
1299
  * Get simple MCP tool names for an object
@@ -1195,7 +1320,7 @@ ${fields}
1195
1320
  * Generate a single MCP tool
1196
1321
  */
1197
1322
  generateMCPTool(obj) {
1198
- const { collection, className, name } = obj;
1323
+ const { collection } = obj;
1199
1324
  const config = obj.decoratorConfig.mcp;
1200
1325
  const exclude = typeof config === "object" && config?.exclude || [];
1201
1326
  const include = typeof config === "object" && config?.include || void 0;
@@ -1218,28 +1343,56 @@ ${fields}
1218
1343
  }
1219
1344
  }`);
1220
1345
  if (shouldInclude("get")) tools.push(` {
1221
- name: "get_${name}",
1222
- description: "Get a ${name} by ID",
1346
+ name: "get_${collection}",
1347
+ description: "Get a ${collection} entry by ID",
1223
1348
  inputSchema: {
1224
1349
  type: "object",
1225
1350
  properties: {
1226
- id: { type: "string", description: "The ${name} ID" }
1351
+ id: { type: "string", description: "The ${collection} entry ID" }
1227
1352
  },
1228
1353
  required: ["id"]
1229
1354
  }
1230
1355
  }`);
1356
+ const schemaProperties = JSON.stringify(this.generateSchemaProperties(obj.fields), null, 6);
1231
1357
  if (shouldInclude("create")) {
1232
1358
  const requiredFields = Object.entries(obj.fields).filter(([_, field]) => field.required).map(([fieldName]) => fieldName);
1233
1359
  tools.push(` {
1234
- name: "create_${name}",
1235
- description: "Create a new ${name}",
1360
+ name: "create_${collection}",
1361
+ description: "Create a new ${collection} entry",
1236
1362
  inputSchema: {
1237
1363
  type: "object",
1238
- properties: ${JSON.stringify(this.generateSchemaProperties(obj.fields), null, 6)},
1364
+ properties: ${schemaProperties},
1239
1365
  required: ${JSON.stringify(requiredFields)}
1240
1366
  }
1241
1367
  }`);
1242
1368
  }
1369
+ if (shouldInclude("update")) tools.push(` {
1370
+ name: "update_${collection}",
1371
+ description: "Update an existing ${collection} entry",
1372
+ inputSchema: {
1373
+ type: "object",
1374
+ properties: {
1375
+ id: { type: "string", description: "The ${collection} entry ID" },
1376
+ data: {
1377
+ type: "object",
1378
+ description: "Fields to update on the ${collection} entry",
1379
+ properties: ${schemaProperties}
1380
+ }
1381
+ },
1382
+ required: ["id", "data"]
1383
+ }
1384
+ }`);
1385
+ if (shouldInclude("delete")) tools.push(` {
1386
+ name: "delete_${collection}",
1387
+ description: "Delete a ${collection} entry by ID",
1388
+ inputSchema: {
1389
+ type: "object",
1390
+ properties: {
1391
+ id: { type: "string", description: "The ${collection} entry ID" }
1392
+ },
1393
+ required: ["id"]
1394
+ }
1395
+ }`);
1243
1396
  return tools.join(",\n");
1244
1397
  }
1245
1398
  /**