@happyvertical/smrt-core 0.37.8 → 0.37.9
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 +10 -0
- package/dist/change-feed.d.ts +261 -0
- package/dist/change-feed.d.ts.map +1 -0
- package/dist/change-feed.js +482 -0
- package/dist/change-feed.js.map +1 -0
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +4 -2
- package/dist/class.js.map +1 -1
- package/dist/collection.js +2 -2
- package/dist/consumer-plugin/index.d.ts.map +1 -1
- package/dist/consumer-plugin/index.js +24 -19
- package/dist/consumer-plugin/index.js.map +1 -1
- package/dist/dispatch/bus.js +2 -2
- package/dist/dispatch/index.js +1 -1
- package/dist/generated-client-runtime.d.ts +26 -0
- package/dist/generated-client-runtime.d.ts.map +1 -0
- package/dist/generated-client-runtime.js +88 -0
- package/dist/generated-client-runtime.js.map +1 -0
- package/dist/generators/changes-route.d.ts +52 -0
- package/dist/generators/changes-route.d.ts.map +1 -0
- package/dist/generators/changes-route.js +111 -0
- package/dist/generators/changes-route.js.map +1 -0
- package/dist/generators/rest.d.ts +28 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +53 -15
- package/dist/generators/rest.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/manifest/static-manifest.js +2 -2
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
- package/dist/manifest/test-manifest-stub.js +502 -2
- package/dist/manifest/test-manifest-stub.js.map +1 -1
- package/dist/manifest.json +2 -2
- package/dist/object.js +2 -2
- package/dist/prebuild/index.d.ts.map +1 -1
- package/dist/prebuild/index.js +14 -7
- package/dist/prebuild/index.js.map +1 -1
- package/dist/smrt-knowledge.json +6 -6
- package/dist/system/schema.d.ts +24 -1
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +45 -3
- package/dist/system/schema.js.map +1 -1
- package/dist/testing/database.js +1 -1
- package/dist/vite-plugin/changes-route.d.ts +10 -0
- package/dist/vite-plugin/changes-route.d.ts.map +1 -0
- package/dist/vite-plugin/changes-route.js +174 -0
- package/dist/vite-plugin/changes-route.js.map +1 -0
- package/dist/vite-plugin/index.d.ts +7 -0
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +38 -20
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/route-header.d.ts +10 -0
- package/dist/vite-plugin/route-header.d.ts.map +1 -0
- package/dist/vite-plugin/route-header.js +14 -0
- package/dist/vite-plugin/route-header.js.map +1 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts +7 -0
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +5 -3
- 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 -2
- package/dist/vite-plugin/sync-apply-route.js.map +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared runtime source injected into every generated API client
|
|
3
|
+
* (`smrtPlugin`'s `@happyvertical/smrt-virt-client` and `smrtConsumer`'s
|
|
4
|
+
* `@smrt/client` fallback).
|
|
5
|
+
*
|
|
6
|
+
* The generated fetchers MUST reject on `!response.ok` (#1796): before this,
|
|
7
|
+
* every fetcher resolved `r.json()` regardless of HTTP status, so a 500 (or any
|
|
8
|
+
* error response) resolved successfully with the error payload. Mutation
|
|
9
|
+
* failures were therefore invisible to callers — an optimistic-update layer
|
|
10
|
+
* (e.g. TanStack DB `onInsert`) could never observe the failure and roll back.
|
|
11
|
+
*
|
|
12
|
+
* `SmrtClientError` carries the HTTP `status` and the parsed error `body` (when
|
|
13
|
+
* the response was JSON), so callers can branch on status and surface server
|
|
14
|
+
* messages. Both helpers are emitted with a `__smrt` prefix so they cannot
|
|
15
|
+
* collide with a collection key in the generated module.
|
|
16
|
+
*
|
|
17
|
+
* WIRE-SHAPE POLICY (#1797, ADR 0001): the server returns BARE JSON — a bare
|
|
18
|
+
* array for list, a bare object for get/create/update — with snake_case field
|
|
19
|
+
* names (`created_at`, `updated_at`) exactly as `SmrtObject.toJSON()` emits
|
|
20
|
+
* them. These helpers pass that JSON through unchanged; they do NOT wrap it in
|
|
21
|
+
* an envelope or camelCase the keys. The generated `.d.ts` declarations
|
|
22
|
+
* (vite-plugin + prebuild) are written to match this shape. This same
|
|
23
|
+
* snake_case wire feeds the mobile DTO codegen; do not change the wire here.
|
|
24
|
+
*/
|
|
25
|
+
export declare const CLIENT_FETCH_RUNTIME = "class SmrtClientError extends Error {\n constructor(message, status, body) {\n super(message);\n this.name = 'SmrtClientError';\n this.status = status;\n this.body = body;\n }\n}\n\nasync function __smrtParseBody(response) {\n const contentType = response.headers.get('content-type') || '';\n if (contentType.includes('application/json')) {\n try {\n return await response.json();\n } catch {\n return undefined;\n }\n }\n try {\n return await response.text();\n } catch {\n return undefined;\n }\n}\n\n// Rejects on !response.ok with a typed SmrtClientError; otherwise resolves the\n// parsed JSON body exactly as the server sent it (bare array/object, snake_case\n// fields).\nasync function __smrtFetchJson(url, init) {\n const response = await fetch(url, init);\n const body = await __smrtParseBody(response);\n if (!response.ok) {\n const detail =\n body && typeof body === 'object' && body.error ? ': ' + body.error : '';\n throw new SmrtClientError(\n 'Request failed with status ' + response.status + detail,\n response.status,\n body,\n );\n }\n return body;\n}\n\n// DELETE variant: rejects on !response.ok (so a failed delete is observable),\n// otherwise resolves true.\nasync function __smrtFetchOk(url, init) {\n const response = await fetch(url, init);\n if (!response.ok) {\n const body = await __smrtParseBody(response);\n const detail =\n body && typeof body === 'object' && body.error ? ': ' + body.error : '';\n throw new SmrtClientError(\n 'Request failed with status ' + response.status + detail,\n response.status,\n body,\n );\n }\n return true;\n}";
|
|
26
|
+
//# sourceMappingURL=generated-client-runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generated-client-runtime.d.ts","sourceRoot":"","sources":["../src/generated-client-runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,eAAO,MAAM,oBAAoB,4pDA0D/B,CAAC"}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
//#region src/generated-client-runtime.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared runtime source injected into every generated API client
|
|
4
|
+
* (`smrtPlugin`'s `@happyvertical/smrt-virt-client` and `smrtConsumer`'s
|
|
5
|
+
* `@smrt/client` fallback).
|
|
6
|
+
*
|
|
7
|
+
* The generated fetchers MUST reject on `!response.ok` (#1796): before this,
|
|
8
|
+
* every fetcher resolved `r.json()` regardless of HTTP status, so a 500 (or any
|
|
9
|
+
* error response) resolved successfully with the error payload. Mutation
|
|
10
|
+
* failures were therefore invisible to callers — an optimistic-update layer
|
|
11
|
+
* (e.g. TanStack DB `onInsert`) could never observe the failure and roll back.
|
|
12
|
+
*
|
|
13
|
+
* `SmrtClientError` carries the HTTP `status` and the parsed error `body` (when
|
|
14
|
+
* the response was JSON), so callers can branch on status and surface server
|
|
15
|
+
* messages. Both helpers are emitted with a `__smrt` prefix so they cannot
|
|
16
|
+
* collide with a collection key in the generated module.
|
|
17
|
+
*
|
|
18
|
+
* WIRE-SHAPE POLICY (#1797, ADR 0001): the server returns BARE JSON — a bare
|
|
19
|
+
* array for list, a bare object for get/create/update — with snake_case field
|
|
20
|
+
* names (`created_at`, `updated_at`) exactly as `SmrtObject.toJSON()` emits
|
|
21
|
+
* them. These helpers pass that JSON through unchanged; they do NOT wrap it in
|
|
22
|
+
* an envelope or camelCase the keys. The generated `.d.ts` declarations
|
|
23
|
+
* (vite-plugin + prebuild) are written to match this shape. This same
|
|
24
|
+
* snake_case wire feeds the mobile DTO codegen; do not change the wire here.
|
|
25
|
+
*/
|
|
26
|
+
var CLIENT_FETCH_RUNTIME = `class SmrtClientError extends Error {
|
|
27
|
+
constructor(message, status, body) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'SmrtClientError';
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.body = body;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function __smrtParseBody(response) {
|
|
36
|
+
const contentType = response.headers.get('content-type') || '';
|
|
37
|
+
if (contentType.includes('application/json')) {
|
|
38
|
+
try {
|
|
39
|
+
return await response.json();
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return await response.text();
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Rejects on !response.ok with a typed SmrtClientError; otherwise resolves the
|
|
52
|
+
// parsed JSON body exactly as the server sent it (bare array/object, snake_case
|
|
53
|
+
// fields).
|
|
54
|
+
async function __smrtFetchJson(url, init) {
|
|
55
|
+
const response = await fetch(url, init);
|
|
56
|
+
const body = await __smrtParseBody(response);
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const detail =
|
|
59
|
+
body && typeof body === 'object' && body.error ? ': ' + body.error : '';
|
|
60
|
+
throw new SmrtClientError(
|
|
61
|
+
'Request failed with status ' + response.status + detail,
|
|
62
|
+
response.status,
|
|
63
|
+
body,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return body;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// DELETE variant: rejects on !response.ok (so a failed delete is observable),
|
|
70
|
+
// otherwise resolves true.
|
|
71
|
+
async function __smrtFetchOk(url, init) {
|
|
72
|
+
const response = await fetch(url, init);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
const body = await __smrtParseBody(response);
|
|
75
|
+
const detail =
|
|
76
|
+
body && typeof body === 'object' && body.error ? ': ' + body.error : '';
|
|
77
|
+
throw new SmrtClientError(
|
|
78
|
+
'Request failed with status ' + response.status + detail,
|
|
79
|
+
response.status,
|
|
80
|
+
body,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}`;
|
|
85
|
+
//#endregion
|
|
86
|
+
export { CLIENT_FETCH_RUNTIME };
|
|
87
|
+
|
|
88
|
+
//# sourceMappingURL=generated-client-runtime.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generated-client-runtime.js","names":[],"sources":["../src/generated-client-runtime.ts"],"sourcesContent":["/**\n * Shared runtime source injected into every generated API client\n * (`smrtPlugin`'s `@happyvertical/smrt-virt-client` and `smrtConsumer`'s\n * `@smrt/client` fallback).\n *\n * The generated fetchers MUST reject on `!response.ok` (#1796): before this,\n * every fetcher resolved `r.json()` regardless of HTTP status, so a 500 (or any\n * error response) resolved successfully with the error payload. Mutation\n * failures were therefore invisible to callers — an optimistic-update layer\n * (e.g. TanStack DB `onInsert`) could never observe the failure and roll back.\n *\n * `SmrtClientError` carries the HTTP `status` and the parsed error `body` (when\n * the response was JSON), so callers can branch on status and surface server\n * messages. Both helpers are emitted with a `__smrt` prefix so they cannot\n * collide with a collection key in the generated module.\n *\n * WIRE-SHAPE POLICY (#1797, ADR 0001): the server returns BARE JSON — a bare\n * array for list, a bare object for get/create/update — with snake_case field\n * names (`created_at`, `updated_at`) exactly as `SmrtObject.toJSON()` emits\n * them. These helpers pass that JSON through unchanged; they do NOT wrap it in\n * an envelope or camelCase the keys. The generated `.d.ts` declarations\n * (vite-plugin + prebuild) are written to match this shape. This same\n * snake_case wire feeds the mobile DTO codegen; do not change the wire here.\n */\nexport const CLIENT_FETCH_RUNTIME = `class SmrtClientError extends Error {\n constructor(message, status, body) {\n super(message);\n this.name = 'SmrtClientError';\n this.status = status;\n this.body = body;\n }\n}\n\nasync function __smrtParseBody(response) {\n const contentType = response.headers.get('content-type') || '';\n if (contentType.includes('application/json')) {\n try {\n return await response.json();\n } catch {\n return undefined;\n }\n }\n try {\n return await response.text();\n } catch {\n return undefined;\n }\n}\n\n// Rejects on !response.ok with a typed SmrtClientError; otherwise resolves the\n// parsed JSON body exactly as the server sent it (bare array/object, snake_case\n// fields).\nasync function __smrtFetchJson(url, init) {\n const response = await fetch(url, init);\n const body = await __smrtParseBody(response);\n if (!response.ok) {\n const detail =\n body && typeof body === 'object' && body.error ? ': ' + body.error : '';\n throw new SmrtClientError(\n 'Request failed with status ' + response.status + detail,\n response.status,\n body,\n );\n }\n return body;\n}\n\n// DELETE variant: rejects on !response.ok (so a failed delete is observable),\n// otherwise resolves true.\nasync function __smrtFetchOk(url, init) {\n const response = await fetch(url, init);\n if (!response.ok) {\n const body = await __smrtParseBody(response);\n const detail =\n body && typeof body === 'object' && body.error ? ': ' + body.error : '';\n throw new SmrtClientError(\n 'Request failed with status ' + response.status + detail,\n response.status,\n body,\n );\n }\n return true;\n}`;\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAwBA,IAAa,uBAAuB"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated `_changes` HTTP route for the change feed (issue #1758).
|
|
3
|
+
*
|
|
4
|
+
* Handles `GET {basePath}/_changes` in the REST generator: an auth-guarded,
|
|
5
|
+
* tenant-scoped cursor read over the `_smrt_changes` log. This module keeps
|
|
6
|
+
* the feed logic out of `rest.ts` (which only registers the path) so
|
|
7
|
+
* sibling generator changes stay conflict-free.
|
|
8
|
+
*
|
|
9
|
+
* Contract (part of the client/mobile sync contract, PRD #1755):
|
|
10
|
+
* - `GET {basePath}/_changes?since=<cursor>&tables=<a,b>&limit=<n>` returns
|
|
11
|
+
* `{ changes, cursor, resyncRequired? }` — see `getChangesSince` for the
|
|
12
|
+
* exact cursor guarantee (strictly monotonic; reads miss no committed
|
|
13
|
+
* changes under concurrent writers). `resyncRequired: true` — served as
|
|
14
|
+
* HTTP 200, it is protocol state rather than an error — means the cursor
|
|
15
|
+
* cannot be served incrementally (pruned out of the retained window, or
|
|
16
|
+
* foreign/reset) and the client must re-fetch its data in full.
|
|
17
|
+
* - **Auth is fail-closed** (#1540 posture): the route requires the
|
|
18
|
+
* generator's `authMiddleware`. Without one configured, every request is
|
|
19
|
+
* refused with 401 — the feed spans all tables, so per-model
|
|
20
|
+
* `api: { public }` opt-outs deliberately do NOT apply to it.
|
|
21
|
+
* - **Tenant scoping** follows the active tenant context through the same
|
|
22
|
+
* dependency-inversion hook the DispatchBus uses: with tenancy enabled, a
|
|
23
|
+
* request only ever sees its own tenant's changes plus global rows; with
|
|
24
|
+
* tenancy enabled but no active tenant, only global rows (fail-closed).
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Structural copy of `APIConfig['authMiddleware']` (from `rest.ts`) so this
|
|
28
|
+
* module never imports the generator back (keeps the dependency one-way).
|
|
29
|
+
*/
|
|
30
|
+
type ChangesAuthMiddleware = (objectName: string, action: string) => (req: Request) => Promise<Request | Response>;
|
|
31
|
+
export interface ChangesRouteOptions {
|
|
32
|
+
/** The generator's configured auth middleware, if any. */
|
|
33
|
+
authMiddleware?: ChangesAuthMiddleware;
|
|
34
|
+
/** The generator's `APIContext.db` (instance, config object, or URL string). */
|
|
35
|
+
db?: unknown;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Pseudo object name passed to the auth middleware for the feed route, so
|
|
39
|
+
* middlewares can recognize and specially authorize it if they want to.
|
|
40
|
+
*/
|
|
41
|
+
export declare const CHANGES_ROUTE_OBJECT_NAME = "_changes";
|
|
42
|
+
/**
|
|
43
|
+
* Handle a request against the generated `_changes` route.
|
|
44
|
+
*
|
|
45
|
+
* Returns 401 when no auth middleware is configured (fail-closed) or the
|
|
46
|
+
* middleware rejects; 405 for non-GET methods; 400 for malformed `since`/
|
|
47
|
+
* `limit`; 503 when the generator has no database; otherwise a
|
|
48
|
+
* `{ changes, cursor }` page scoped to the active tenant context.
|
|
49
|
+
*/
|
|
50
|
+
export declare function handleChangesRoute(req: Request, options: ChangesRouteOptions): Promise<Response>;
|
|
51
|
+
export {};
|
|
52
|
+
//# sourceMappingURL=changes-route.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changes-route.d.ts","sourceRoot":"","sources":["../../src/generators/changes-route.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAYH;;;GAGG;AACH,KAAK,qBAAqB,GAAG,CAC3B,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,KACX,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,OAAO,GAAG,QAAQ,CAAC,CAAC;AAEnD,MAAM,WAAW,mBAAmB;IAClC,0DAA0D;IAC1D,cAAc,CAAC,EAAE,qBAAqB,CAAC;IACvC,gFAAgF;IAChF,EAAE,CAAC,EAAE,OAAO,CAAC;CACd;AAED;;;GAGG;AACH,eAAO,MAAM,yBAAyB,aAAa,CAAC;AAwDpD;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,GAAG,EAAE,OAAO,EACZ,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,QAAQ,CAAC,CAmEnB"}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { ensureChangeFeedTable, getTenantScopedChangesSince } from "../change-feed.js";
|
|
2
|
+
import { createLogger } from "@happyvertical/logger";
|
|
3
|
+
import { getDatabase } from "@happyvertical/sql";
|
|
4
|
+
//#region src/generators/changes-route.ts
|
|
5
|
+
/**
|
|
6
|
+
* Generated `_changes` HTTP route for the change feed (issue #1758).
|
|
7
|
+
*
|
|
8
|
+
* Handles `GET {basePath}/_changes` in the REST generator: an auth-guarded,
|
|
9
|
+
* tenant-scoped cursor read over the `_smrt_changes` log. This module keeps
|
|
10
|
+
* the feed logic out of `rest.ts` (which only registers the path) so
|
|
11
|
+
* sibling generator changes stay conflict-free.
|
|
12
|
+
*
|
|
13
|
+
* Contract (part of the client/mobile sync contract, PRD #1755):
|
|
14
|
+
* - `GET {basePath}/_changes?since=<cursor>&tables=<a,b>&limit=<n>` returns
|
|
15
|
+
* `{ changes, cursor, resyncRequired? }` — see `getChangesSince` for the
|
|
16
|
+
* exact cursor guarantee (strictly monotonic; reads miss no committed
|
|
17
|
+
* changes under concurrent writers). `resyncRequired: true` — served as
|
|
18
|
+
* HTTP 200, it is protocol state rather than an error — means the cursor
|
|
19
|
+
* cannot be served incrementally (pruned out of the retained window, or
|
|
20
|
+
* foreign/reset) and the client must re-fetch its data in full.
|
|
21
|
+
* - **Auth is fail-closed** (#1540 posture): the route requires the
|
|
22
|
+
* generator's `authMiddleware`. Without one configured, every request is
|
|
23
|
+
* refused with 401 — the feed spans all tables, so per-model
|
|
24
|
+
* `api: { public }` opt-outs deliberately do NOT apply to it.
|
|
25
|
+
* - **Tenant scoping** follows the active tenant context through the same
|
|
26
|
+
* dependency-inversion hook the DispatchBus uses: with tenancy enabled, a
|
|
27
|
+
* request only ever sees its own tenant's changes plus global rows; with
|
|
28
|
+
* tenancy enabled but no active tenant, only global rows (fail-closed).
|
|
29
|
+
*/
|
|
30
|
+
var logger = createLogger({ level: "info" });
|
|
31
|
+
/**
|
|
32
|
+
* Pseudo object name passed to the auth middleware for the feed route, so
|
|
33
|
+
* middlewares can recognize and specially authorize it if they want to.
|
|
34
|
+
*/
|
|
35
|
+
var CHANGES_ROUTE_OBJECT_NAME = "_changes";
|
|
36
|
+
var resolvedInstanceDbs = /* @__PURE__ */ new WeakMap();
|
|
37
|
+
var resolvedUrlDbs = /* @__PURE__ */ new Map();
|
|
38
|
+
function resolveChangesDb(dbOption) {
|
|
39
|
+
if (dbOption && typeof dbOption === "object") {
|
|
40
|
+
if ("query" in dbOption && typeof dbOption.query === "function") return Promise.resolve(dbOption);
|
|
41
|
+
let resolved = resolvedInstanceDbs.get(dbOption);
|
|
42
|
+
if (!resolved) {
|
|
43
|
+
resolved = getDatabase(dbOption);
|
|
44
|
+
resolvedInstanceDbs.set(dbOption, resolved);
|
|
45
|
+
}
|
|
46
|
+
return resolved;
|
|
47
|
+
}
|
|
48
|
+
if (typeof dbOption === "string" && dbOption) {
|
|
49
|
+
let resolved = resolvedUrlDbs.get(dbOption);
|
|
50
|
+
if (!resolved) {
|
|
51
|
+
resolved = getDatabase({
|
|
52
|
+
url: dbOption,
|
|
53
|
+
...dbOption === ":memory:" ? {} : { dbid: `smrt:${dbOption}` }
|
|
54
|
+
});
|
|
55
|
+
resolvedUrlDbs.set(dbOption, resolved);
|
|
56
|
+
}
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
return Promise.reject(/* @__PURE__ */ new Error("The _changes route requires a database in APIContext"));
|
|
60
|
+
}
|
|
61
|
+
function jsonResponse(data, status = 200) {
|
|
62
|
+
return new Response(JSON.stringify(data), {
|
|
63
|
+
status,
|
|
64
|
+
headers: { "Content-Type": "application/json" }
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function errorResponse(status, message) {
|
|
68
|
+
return jsonResponse({ error: message }, status);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Handle a request against the generated `_changes` route.
|
|
72
|
+
*
|
|
73
|
+
* Returns 401 when no auth middleware is configured (fail-closed) or the
|
|
74
|
+
* middleware rejects; 405 for non-GET methods; 400 for malformed `since`/
|
|
75
|
+
* `limit`; 503 when the generator has no database; otherwise a
|
|
76
|
+
* `{ changes, cursor }` page scoped to the active tenant context.
|
|
77
|
+
*/
|
|
78
|
+
async function handleChangesRoute(req, options) {
|
|
79
|
+
if (req.method !== "GET") return errorResponse(405, "Method not allowed");
|
|
80
|
+
if (!options.authMiddleware) return errorResponse(401, "Authentication required");
|
|
81
|
+
const authResult = await options.authMiddleware(CHANGES_ROUTE_OBJECT_NAME, req.method.toLowerCase())(req);
|
|
82
|
+
if (authResult instanceof Response) return authResult;
|
|
83
|
+
if (options.db == null) return errorResponse(503, "Change feed unavailable: no database configured for the API generator");
|
|
84
|
+
const url = new URL(authResult.url);
|
|
85
|
+
const since = Number(url.searchParams.get("since") ?? "0");
|
|
86
|
+
if (!Number.isFinite(since) || since < 0) return errorResponse(400, "'since' must be a non-negative number");
|
|
87
|
+
let limit;
|
|
88
|
+
const limitParam = url.searchParams.get("limit");
|
|
89
|
+
if (limitParam !== null) {
|
|
90
|
+
limit = Number(limitParam);
|
|
91
|
+
if (!Number.isFinite(limit) || limit < 1) return errorResponse(400, "'limit' must be a positive number");
|
|
92
|
+
}
|
|
93
|
+
const tablesParam = url.searchParams.get("tables");
|
|
94
|
+
const tables = tablesParam ? tablesParam.split(",").map((table) => table.trim()).filter(Boolean) : void 0;
|
|
95
|
+
try {
|
|
96
|
+
const db = await resolveChangesDb(options.db);
|
|
97
|
+
await ensureChangeFeedTable(db);
|
|
98
|
+
return jsonResponse(await getTenantScopedChangesSince(db, {
|
|
99
|
+
since,
|
|
100
|
+
tables,
|
|
101
|
+
limit
|
|
102
|
+
}));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.error("Change feed route failed", { error: error instanceof Error ? error.message : String(error) });
|
|
105
|
+
return errorResponse(500, "Internal server error");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
export { CHANGES_ROUTE_OBJECT_NAME, handleChangesRoute };
|
|
110
|
+
|
|
111
|
+
//# sourceMappingURL=changes-route.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"changes-route.js","names":[],"sources":["../../src/generators/changes-route.ts"],"sourcesContent":["/**\n * Generated `_changes` HTTP route for the change feed (issue #1758).\n *\n * Handles `GET {basePath}/_changes` in the REST generator: an auth-guarded,\n * tenant-scoped cursor read over the `_smrt_changes` log. This module keeps\n * the feed logic out of `rest.ts` (which only registers the path) so\n * sibling generator changes stay conflict-free.\n *\n * Contract (part of the client/mobile sync contract, PRD #1755):\n * - `GET {basePath}/_changes?since=<cursor>&tables=<a,b>&limit=<n>` returns\n * `{ changes, cursor, resyncRequired? }` — see `getChangesSince` for the\n * exact cursor guarantee (strictly monotonic; reads miss no committed\n * changes under concurrent writers). `resyncRequired: true` — served as\n * HTTP 200, it is protocol state rather than an error — means the cursor\n * cannot be served incrementally (pruned out of the retained window, or\n * foreign/reset) and the client must re-fetch its data in full.\n * - **Auth is fail-closed** (#1540 posture): the route requires the\n * generator's `authMiddleware`. Without one configured, every request is\n * refused with 401 — the feed spans all tables, so per-model\n * `api: { public }` opt-outs deliberately do NOT apply to it.\n * - **Tenant scoping** follows the active tenant context through the same\n * dependency-inversion hook the DispatchBus uses: with tenancy enabled, a\n * request only ever sees its own tenant's changes plus global rows; with\n * tenancy enabled but no active tenant, only global rows (fail-closed).\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { getDatabase } from '@happyvertical/sql';\nimport {\n ensureChangeFeedTable,\n getTenantScopedChangesSince,\n} from '../change-feed.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Structural copy of `APIConfig['authMiddleware']` (from `rest.ts`) so this\n * module never imports the generator back (keeps the dependency one-way).\n */\ntype ChangesAuthMiddleware = (\n objectName: string,\n action: string,\n) => (req: Request) => Promise<Request | Response>;\n\nexport interface ChangesRouteOptions {\n /** The generator's configured auth middleware, if any. */\n authMiddleware?: ChangesAuthMiddleware;\n /** The generator's `APIContext.db` (instance, config object, or URL string). */\n db?: unknown;\n}\n\n/**\n * Pseudo object name passed to the auth middleware for the feed route, so\n * middlewares can recognize and specially authorize it if they want to.\n */\nexport const CHANGES_ROUTE_OBJECT_NAME = '_changes';\n\n// Resolve the APIContext db option once per distinct option value so every\n// request reads the same underlying database (getDatabase() would otherwise\n// mint a fresh instance per call for config objects / `:memory:` URLs).\nconst resolvedInstanceDbs = new WeakMap<object, Promise<DatabaseInterface>>();\nconst resolvedUrlDbs = new Map<string, Promise<DatabaseInterface>>();\n\nfunction resolveChangesDb(dbOption: unknown): Promise<DatabaseInterface> {\n if (dbOption && typeof dbOption === 'object') {\n if (\n 'query' in dbOption &&\n typeof (dbOption as { query?: unknown }).query === 'function'\n ) {\n return Promise.resolve(dbOption as DatabaseInterface);\n }\n let resolved = resolvedInstanceDbs.get(dbOption);\n if (!resolved) {\n resolved = getDatabase(\n dbOption as Parameters<typeof getDatabase>[0],\n ) as Promise<DatabaseInterface>;\n resolvedInstanceDbs.set(dbOption, resolved);\n }\n return resolved;\n }\n\n if (typeof dbOption === 'string' && dbOption) {\n let resolved = resolvedUrlDbs.get(dbOption);\n if (!resolved) {\n // Match SmrtClass's connection-sharing convention for file-backed URLs.\n const isMemoryDb = dbOption === ':memory:';\n resolved = getDatabase({\n url: dbOption,\n ...(isMemoryDb ? {} : { dbid: `smrt:${dbOption}` }),\n }) as Promise<DatabaseInterface>;\n resolvedUrlDbs.set(dbOption, resolved);\n }\n return resolved;\n }\n\n return Promise.reject(\n new Error('The _changes route requires a database in APIContext'),\n );\n}\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n });\n}\n\nfunction errorResponse(status: number, message: string): Response {\n return jsonResponse({ error: message }, status);\n}\n\n/**\n * Handle a request against the generated `_changes` route.\n *\n * Returns 401 when no auth middleware is configured (fail-closed) or the\n * middleware rejects; 405 for non-GET methods; 400 for malformed `since`/\n * `limit`; 503 when the generator has no database; otherwise a\n * `{ changes, cursor }` page scoped to the active tenant context.\n */\nexport async function handleChangesRoute(\n req: Request,\n options: ChangesRouteOptions,\n): Promise<Response> {\n if (req.method !== 'GET') {\n return errorResponse(405, 'Method not allowed');\n }\n\n // Fail-closed (#1540): the change feed spans every table, so it is never\n // public — an auth middleware must be configured and must pass. The action\n // is the lowercased HTTP method, matching the generator's other call sites.\n if (!options.authMiddleware) {\n return errorResponse(401, 'Authentication required');\n }\n const authCheck = options.authMiddleware(\n CHANGES_ROUTE_OBJECT_NAME,\n req.method.toLowerCase(),\n );\n const authResult = await authCheck(req);\n if (authResult instanceof Response) {\n return authResult;\n }\n\n if (options.db == null) {\n return errorResponse(\n 503,\n 'Change feed unavailable: no database configured for the API generator',\n );\n }\n\n const url = new URL(authResult.url);\n const since = Number(url.searchParams.get('since') ?? '0');\n if (!Number.isFinite(since) || since < 0) {\n return errorResponse(400, \"'since' must be a non-negative number\");\n }\n\n let limit: number | undefined;\n const limitParam = url.searchParams.get('limit');\n if (limitParam !== null) {\n limit = Number(limitParam);\n if (!Number.isFinite(limit) || limit < 1) {\n return errorResponse(400, \"'limit' must be a positive number\");\n }\n }\n\n const tablesParam = url.searchParams.get('tables');\n const tables = tablesParam\n ? tablesParam\n .split(',')\n .map((table) => table.trim())\n .filter(Boolean)\n : undefined;\n\n try {\n const db = await resolveChangesDb(options.db);\n // System tables are runtime-ensured; a raw handle passed straight to the\n // generator may not have gone through framework init yet.\n await ensureChangeFeedTable(db);\n const page = await getTenantScopedChangesSince(db, {\n since,\n tables,\n limit,\n });\n return jsonResponse(page);\n } catch (error) {\n logger.error('Change feed route failed', {\n error: error instanceof Error ? error.message : String(error),\n });\n return errorResponse(500, 'Internal server error');\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,IAAM,SAAS,aAAa,EAAE,OAAO,OAAO,CAAC;;;;;AAsB7C,IAAa,4BAA4B;AAKzC,IAAM,sCAAsB,IAAI,QAA4C;AAC5E,IAAM,iCAAiB,IAAI,IAAwC;AAEnE,SAAS,iBAAiB,UAA+C;CACvE,IAAI,YAAY,OAAO,aAAa,UAAU;EAC5C,IACE,WAAW,YACX,OAAQ,SAAiC,UAAU,YAEnD,OAAO,QAAQ,QAAQ,QAA6B;EAEtD,IAAI,WAAW,oBAAoB,IAAI,QAAQ;EAC/C,IAAI,CAAC,UAAU;GACb,WAAW,YACT,QACF;GACA,oBAAoB,IAAI,UAAU,QAAQ;EAC5C;EACA,OAAO;CACT;CAEA,IAAI,OAAO,aAAa,YAAY,UAAU;EAC5C,IAAI,WAAW,eAAe,IAAI,QAAQ;EAC1C,IAAI,CAAC,UAAU;GAGb,WAAW,YAAY;IACrB,KAAK;IACL,GAHiB,aAAa,aAGb,CAAC,IAAI,EAAE,MAAM,QAAQ,WAAW;GACnD,CAAC;GACD,eAAe,IAAI,UAAU,QAAQ;EACvC;EACA,OAAO;CACT;CAEA,OAAO,QAAQ,uBACb,IAAI,MAAM,sDAAsD,CAClE;AACF;AAEA,SAAS,aAAa,MAAe,SAAS,KAAe;CAC3D,OAAO,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;EACxC;EACA,SAAS,EAAE,gBAAgB,mBAAmB;CAChD,CAAC;AACH;AAEA,SAAS,cAAc,QAAgB,SAA2B;CAChE,OAAO,aAAa,EAAE,OAAO,QAAQ,GAAG,MAAM;AAChD;;;;;;;;;AAUA,eAAsB,mBACpB,KACA,SACmB;CACnB,IAAI,IAAI,WAAW,OACjB,OAAO,cAAc,KAAK,oBAAoB;CAMhD,IAAI,CAAC,QAAQ,gBACX,OAAO,cAAc,KAAK,yBAAyB;CAMrD,MAAM,aAAa,MAJD,QAAQ,eACxB,2BACA,IAAI,OAAO,YAAY,CAEA,CAAA,CAAU,GAAG;CACtC,IAAI,sBAAsB,UACxB,OAAO;CAGT,IAAI,QAAQ,MAAM,MAChB,OAAO,cACL,KACA,uEACF;CAGF,MAAM,MAAM,IAAI,IAAI,WAAW,GAAG;CAClC,MAAM,QAAQ,OAAO,IAAI,aAAa,IAAI,OAAO,KAAK,GAAG;CACzD,IAAI,CAAC,OAAO,SAAS,KAAK,KAAK,QAAQ,GACrC,OAAO,cAAc,KAAK,uCAAuC;CAGnE,IAAI;CACJ,MAAM,aAAa,IAAI,aAAa,IAAI,OAAO;CAC/C,IAAI,eAAe,MAAM;EACvB,QAAQ,OAAO,UAAU;EACzB,IAAI,CAAC,OAAO,SAAS,KAAK,KAAK,QAAQ,GACrC,OAAO,cAAc,KAAK,mCAAmC;CAEjE;CAEA,MAAM,cAAc,IAAI,aAAa,IAAI,QAAQ;CACjD,MAAM,SAAS,cACX,YACG,MAAM,GAAG,CAAC,CACV,KAAK,UAAU,MAAM,KAAK,CAAC,CAAC,CAC5B,OAAO,OAAO,IACjB,KAAA;CAEJ,IAAI;EACF,MAAM,KAAK,MAAM,iBAAiB,QAAQ,EAAE;EAG5C,MAAM,sBAAsB,EAAE;EAM9B,OAAO,aAAa,MALD,4BAA4B,IAAI;GACjD;GACA;GACA;EACF,CAAC,CACuB;CAC1B,SAAS,OAAO;EACd,OAAO,MAAM,4BAA4B,EACvC,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,EAC9D,CAAC;EACD,OAAO,cAAc,KAAK,uBAAuB;CACnD;AACF"}
|
|
@@ -39,6 +39,17 @@ export declare class APIGenerator {
|
|
|
39
39
|
* @param collection - Pre-initialized SmrtCollection instance
|
|
40
40
|
*/
|
|
41
41
|
registerCollection(name: string, collection: SmrtCollection<SmrtObject>): void;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the database for generator-level routes that are not object-scoped
|
|
44
|
+
* (currently the `_changes` feed, #1758).
|
|
45
|
+
*
|
|
46
|
+
* Prefers the explicit `APIContext.db`, but falls back to the first
|
|
47
|
+
* registered collection's live handle so the feed works under the documented
|
|
48
|
+
* `registerCollection()` wiring, where callers configure collections with a
|
|
49
|
+
* database but never populate `APIContext.db`. Without the fallback the feed
|
|
50
|
+
* would 503 even though the database is reachable.
|
|
51
|
+
*/
|
|
52
|
+
private resolveContextDb;
|
|
42
53
|
/**
|
|
43
54
|
* Create Node.js HTTP server with all routes
|
|
44
55
|
*/
|
|
@@ -198,6 +209,23 @@ export declare class APIGenerator {
|
|
|
198
209
|
* Add CORS headers to response
|
|
199
210
|
*/
|
|
200
211
|
private addCorsHeaders;
|
|
212
|
+
/**
|
|
213
|
+
* Resolve a URL/collection segment to a registered class via auto-discovery.
|
|
214
|
+
*
|
|
215
|
+
* The segment IS the canonical `collection` value from the manifest
|
|
216
|
+
* (`RegisteredClass.collection`, e.g. `products` for `Product`) — the same
|
|
217
|
+
* value the generated client, the SvelteKit route generator, and the registry
|
|
218
|
+
* all derive. Match it VERBATIM; do not re-pluralize. The previous
|
|
219
|
+
* implementation pluralized both the URL segment and the class name and
|
|
220
|
+
* compared them, turning an already-plural client URL (`/products`) into
|
|
221
|
+
* `productses`, so the generated client 404'd against auto-discovered routes
|
|
222
|
+
* (#1794). Shared by the CRUD path and the `sync/apply` batch path so they can
|
|
223
|
+
* never drift.
|
|
224
|
+
*
|
|
225
|
+
* @param segment - The URL/collection path segment (e.g. `products`).
|
|
226
|
+
* @returns The matching registered class, or null if none matches.
|
|
227
|
+
*/
|
|
228
|
+
private findClassByCollectionSegment;
|
|
201
229
|
/**
|
|
202
230
|
* Simple pluralization (basic implementation)
|
|
203
231
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAKpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
|
package/dist/generators/rest.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { ObjectRegistry } from "../registry.js";
|
|
2
1
|
import { isTenantScopedClassResolved, resolveDispatchTenantScope } from "../dispatch/tenant-resolver.js";
|
|
2
|
+
import { ObjectRegistry } from "../registry.js";
|
|
3
3
|
import "../dispatch/index.js";
|
|
4
4
|
import { conditionalJsonResponse, resolveReadCacheControl, warnIfSharedCacheNeutralized, warnIfTenantScopedPublicRead } from "./conditional-get.js";
|
|
5
5
|
import { SYNC_APPLY_ROUTE_SEGMENTS, processSyncApplyBatch } from "../sync/apply.js";
|
|
6
|
+
import { handleChangesRoute } from "./changes-route.js";
|
|
6
7
|
import http from "node:http";
|
|
7
8
|
//#region src/generators/rest.ts
|
|
8
9
|
/**
|
|
@@ -37,6 +38,19 @@ var APIGenerator = class {
|
|
|
37
38
|
this.collections.set(name, collection);
|
|
38
39
|
}
|
|
39
40
|
/**
|
|
41
|
+
* Resolve the database for generator-level routes that are not object-scoped
|
|
42
|
+
* (currently the `_changes` feed, #1758).
|
|
43
|
+
*
|
|
44
|
+
* Prefers the explicit `APIContext.db`, but falls back to the first
|
|
45
|
+
* registered collection's live handle so the feed works under the documented
|
|
46
|
+
* `registerCollection()` wiring, where callers configure collections with a
|
|
47
|
+
* database but never populate `APIContext.db`. Without the fallback the feed
|
|
48
|
+
* would 503 even though the database is reachable.
|
|
49
|
+
*/
|
|
50
|
+
resolveContextDb() {
|
|
51
|
+
return this.context.db ?? this.collections.values().next().value?.db ?? void 0;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
40
54
|
* Create Node.js HTTP server with all routes
|
|
41
55
|
*/
|
|
42
56
|
createServer() {
|
|
@@ -116,6 +130,13 @@ var APIGenerator = class {
|
|
|
116
130
|
return this.addCorsHeaders(response, req);
|
|
117
131
|
}
|
|
118
132
|
}
|
|
133
|
+
if (url.pathname === `${this.config.basePath}/_changes`) {
|
|
134
|
+
const response = await handleChangesRoute(req, {
|
|
135
|
+
authMiddleware: this.config.authMiddleware,
|
|
136
|
+
db: this.resolveContextDb()
|
|
137
|
+
});
|
|
138
|
+
return this.addCorsHeaders(response, req);
|
|
139
|
+
}
|
|
119
140
|
if (url.pathname.startsWith(this.config.basePath || "")) {
|
|
120
141
|
const response = await this.handleObjectRoute(req, url);
|
|
121
142
|
return this.addCorsHeaders(response, req);
|
|
@@ -145,20 +166,14 @@ var APIGenerator = class {
|
|
|
145
166
|
} else if (!this.isRoutePublic(objectName, req.method)) return this.createErrorResponse(401, "Authentication required");
|
|
146
167
|
return await this.executeCrudOperation(req, collection, objectId, url, objectName);
|
|
147
168
|
}
|
|
148
|
-
const
|
|
149
|
-
const pluralName = this.pluralize(objectType);
|
|
150
|
-
let classInfo = null;
|
|
151
|
-
for (const [_key, info] of registeredClasses) if (this.pluralize((info.name || _key).toLowerCase()) === pluralName) {
|
|
152
|
-
classInfo = info;
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
169
|
+
const classInfo = this.findClassByCollectionSegment(objectType);
|
|
155
170
|
if (!classInfo) return this.createErrorResponse(404, `Object type '${objectType}' not found`);
|
|
156
171
|
if (this.config.authMiddleware) {
|
|
157
172
|
const authResult = await this.config.authMiddleware(classInfo.name, req.method.toLowerCase())(req);
|
|
158
173
|
if (authResult instanceof Response) return authResult;
|
|
159
174
|
req = authResult;
|
|
160
175
|
} else if (!this.isRoutePublic(classInfo.name, req.method)) return this.createErrorResponse(401, "Authentication required");
|
|
161
|
-
const collection = this.getCollection(classInfo);
|
|
176
|
+
const collection = await this.getCollection(classInfo);
|
|
162
177
|
return await this.executeCrudOperation(req, collection, objectId, url, classInfo.name);
|
|
163
178
|
}
|
|
164
179
|
/**
|
|
@@ -403,7 +418,7 @@ var APIGenerator = class {
|
|
|
403
418
|
* resolves a CRUD URL segment (registered collections first, then registry
|
|
404
419
|
* auto-discovery), and wrap the generator's per-object machinery.
|
|
405
420
|
*/
|
|
406
|
-
resolveSyncApplyTarget(objectSegment, req, rawBody) {
|
|
421
|
+
async resolveSyncApplyTarget(objectSegment, req, rawBody) {
|
|
407
422
|
let collection = null;
|
|
408
423
|
let objectName = null;
|
|
409
424
|
const registered = this.collections.get(objectSegment);
|
|
@@ -411,11 +426,10 @@ var APIGenerator = class {
|
|
|
411
426
|
collection = registered;
|
|
412
427
|
objectName = this.getCollectionObjectName(registered) || objectSegment;
|
|
413
428
|
} else {
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
collection = this.getCollection(info);
|
|
429
|
+
const info = this.findClassByCollectionSegment(objectSegment);
|
|
430
|
+
if (info) {
|
|
431
|
+
collection = await this.getCollection(info);
|
|
417
432
|
objectName = info.name;
|
|
418
|
-
break;
|
|
419
433
|
}
|
|
420
434
|
}
|
|
421
435
|
if (!collection || !objectName) return null;
|
|
@@ -443,13 +457,14 @@ var APIGenerator = class {
|
|
|
443
457
|
/**
|
|
444
458
|
* Get or create collection instance
|
|
445
459
|
*/
|
|
446
|
-
getCollection(classInfo) {
|
|
460
|
+
async getCollection(classInfo) {
|
|
447
461
|
if (!this.collections.has(classInfo.name)) {
|
|
448
462
|
const ctor = classInfo.collectionConstructor;
|
|
449
463
|
const collection = new ctor({
|
|
450
464
|
ai: this.context.ai,
|
|
451
465
|
db: this.context.db
|
|
452
466
|
});
|
|
467
|
+
await collection.initialize();
|
|
453
468
|
this.collections.set(classInfo.name, collection);
|
|
454
469
|
}
|
|
455
470
|
const collection = this.collections.get(classInfo.name);
|
|
@@ -582,6 +597,29 @@ var APIGenerator = class {
|
|
|
582
597
|
});
|
|
583
598
|
}
|
|
584
599
|
/**
|
|
600
|
+
* Resolve a URL/collection segment to a registered class via auto-discovery.
|
|
601
|
+
*
|
|
602
|
+
* The segment IS the canonical `collection` value from the manifest
|
|
603
|
+
* (`RegisteredClass.collection`, e.g. `products` for `Product`) — the same
|
|
604
|
+
* value the generated client, the SvelteKit route generator, and the registry
|
|
605
|
+
* all derive. Match it VERBATIM; do not re-pluralize. The previous
|
|
606
|
+
* implementation pluralized both the URL segment and the class name and
|
|
607
|
+
* compared them, turning an already-plural client URL (`/products`) into
|
|
608
|
+
* `productses`, so the generated client 404'd against auto-discovered routes
|
|
609
|
+
* (#1794). Shared by the CRUD path and the `sync/apply` batch path so they can
|
|
610
|
+
* never drift.
|
|
611
|
+
*
|
|
612
|
+
* @param segment - The URL/collection path segment (e.g. `products`).
|
|
613
|
+
* @returns The matching registered class, or null if none matches.
|
|
614
|
+
*/
|
|
615
|
+
findClassByCollectionSegment(segment) {
|
|
616
|
+
for (const [key, info] of ObjectRegistry.getAllClasses()) {
|
|
617
|
+
if (info.collection === segment) return info;
|
|
618
|
+
if (!info.collection && this.pluralize((info.name || key).toLowerCase()) === segment) return info;
|
|
619
|
+
}
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
585
623
|
* Simple pluralization (basic implementation)
|
|
586
624
|
*/
|
|
587
625
|
pluralize(word) {
|