@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.
- package/AGENTS.md +8 -8
- package/agents/change-feed.md +3 -2
- package/agents/generators.md +34 -0
- package/agents/revision-guard.md +66 -0
- package/dist/browser.js +2 -1
- package/dist/cascade.d.ts +5 -9
- package/dist/cascade.d.ts.map +1 -1
- package/dist/cascade.js +65 -30
- package/dist/cascade.js.map +1 -1
- package/dist/change-feed.d.ts +60 -1
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +464 -27
- package/dist/change-feed.js.map +1 -1
- package/dist/change-signals.d.ts.map +1 -1
- package/dist/change-signals.js +13 -11
- package/dist/change-signals.js.map +1 -1
- package/dist/embedded-write-queue.d.ts +8 -0
- package/dist/embedded-write-queue.d.ts.map +1 -1
- package/dist/embedded-write-queue.js +12 -2
- package/dist/embedded-write-queue.js.map +1 -1
- package/dist/generators/cli.d.ts.map +1 -1
- package/dist/generators/cli.js +10 -18
- package/dist/generators/cli.js.map +1 -1
- package/dist/generators/custom-action.d.ts +216 -0
- package/dist/generators/custom-action.d.ts.map +1 -1
- package/dist/generators/custom-action.js +256 -1
- package/dist/generators/custom-action.js.map +1 -1
- package/dist/generators/index.d.ts +2 -1
- package/dist/generators/index.d.ts.map +1 -1
- package/dist/generators/index.js +3 -2
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +14 -39
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/preflight-route.d.ts +151 -0
- package/dist/generators/preflight-route.d.ts.map +1 -0
- package/dist/generators/preflight-route.js +194 -0
- package/dist/generators/preflight-route.js.map +1 -0
- package/dist/generators/rest.d.ts +12 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +14 -15
- package/dist/generators/rest.js.map +1 -1
- package/dist/generators/tool-schema.d.ts.map +1 -1
- package/dist/generators/tool-schema.js +2 -8
- package/dist/generators/tool-schema.js.map +1 -1
- package/dist/generators.js +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +283 -35
- package/dist/knowledge.js.map +1 -1
- package/dist/manifest/static-manifest.d.ts.map +1 -1
- package/dist/manifest/static-manifest.js +6 -2
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +8 -2
- package/dist/object.d.ts +31 -1
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +57 -9
- package/dist/object.js.map +1 -1
- package/dist/registry/framework-base-classes.d.ts +10 -0
- package/dist/registry/framework-base-classes.d.ts.map +1 -0
- package/dist/registry/framework-base-classes.js +92 -0
- package/dist/registry/framework-base-classes.js.map +1 -0
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +2 -0
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +27 -6
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +1 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +2 -1
- package/dist/registry.js.map +1 -1
- package/dist/revision-guard.d.ts +84 -0
- package/dist/revision-guard.d.ts.map +1 -0
- package/dist/revision-guard.js +120 -0
- package/dist/revision-guard.js.map +1 -0
- package/dist/scanner/manifest-generator.d.ts +46 -16
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +198 -45
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/smrt-knowledge.json +18 -9
- package/dist/system/bootstrap.d.ts.map +1 -1
- package/dist/system/bootstrap.js +2 -1
- package/dist/system/bootstrap.js.map +1 -1
- package/dist/system/schema.d.ts +75 -2
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +326 -4
- package/dist/system/schema.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +48 -23
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts +33 -7
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +88 -17
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
- package/dist/vite-plugin/sync-apply-route.js +2 -0
- package/dist/vite-plugin/sync-apply-route.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +28 -5
- package/dist/vite-plugin/web-collections.js.map +1 -1
- 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":"AAAA;;GAEG;AA0BH,OAAO,KAAK,EAYV,UAAU,EAEV,mBAAmB,EACnB,cAAc,EAEf,MAAM,YAAY,CAAC;AAGpB,2EAA2E;AAC3E,UAAU,eAAe;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;
|
|
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 (
|
|
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 || {}))
|
|
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
|
|
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
|
|
1171
|
-
|
|
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
|
|
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_${
|
|
1222
|
-
description: "Get a ${
|
|
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 ${
|
|
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_${
|
|
1235
|
-
description: "Create a new ${
|
|
1360
|
+
name: "create_${collection}",
|
|
1361
|
+
description: "Create a new ${collection} entry",
|
|
1236
1362
|
inputSchema: {
|
|
1237
1363
|
type: "object",
|
|
1238
|
-
properties: ${
|
|
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
|
/**
|