@happyvertical/smrt-core 0.37.8 → 0.37.10

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 (71) hide show
  1. package/AGENTS.md +10 -0
  2. package/dist/change-feed.d.ts +261 -0
  3. package/dist/change-feed.d.ts.map +1 -0
  4. package/dist/change-feed.js +482 -0
  5. package/dist/change-feed.js.map +1 -0
  6. package/dist/class.d.ts.map +1 -1
  7. package/dist/class.js +4 -2
  8. package/dist/class.js.map +1 -1
  9. package/dist/collection.js +2 -2
  10. package/dist/consumer-plugin/index.d.ts.map +1 -1
  11. package/dist/consumer-plugin/index.js +24 -19
  12. package/dist/consumer-plugin/index.js.map +1 -1
  13. package/dist/dispatch/bus.js +2 -2
  14. package/dist/dispatch/index.js +1 -1
  15. package/dist/generated-client-runtime.d.ts +26 -0
  16. package/dist/generated-client-runtime.d.ts.map +1 -0
  17. package/dist/generated-client-runtime.js +88 -0
  18. package/dist/generated-client-runtime.js.map +1 -0
  19. package/dist/generators/changes-route.d.ts +52 -0
  20. package/dist/generators/changes-route.d.ts.map +1 -0
  21. package/dist/generators/changes-route.js +111 -0
  22. package/dist/generators/changes-route.js.map +1 -0
  23. package/dist/generators/rest.d.ts +28 -0
  24. package/dist/generators/rest.d.ts.map +1 -1
  25. package/dist/generators/rest.js +53 -15
  26. package/dist/generators/rest.js.map +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +6 -5
  30. package/dist/manifest/static-manifest.js +2 -2
  31. package/dist/manifest/static-manifest.js.map +1 -1
  32. package/dist/manifest/store.js +1 -1
  33. package/dist/manifest/store.js.map +1 -1
  34. package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
  35. package/dist/manifest/test-manifest-stub.js +502 -2
  36. package/dist/manifest/test-manifest-stub.js.map +1 -1
  37. package/dist/manifest.json +2 -2
  38. package/dist/object.js +2 -2
  39. package/dist/prebuild/index.d.ts.map +1 -1
  40. package/dist/prebuild/index.js +66 -7
  41. package/dist/prebuild/index.js.map +1 -1
  42. package/dist/smrt-knowledge.json +6 -6
  43. package/dist/system/schema.d.ts +24 -1
  44. package/dist/system/schema.d.ts.map +1 -1
  45. package/dist/system/schema.js +45 -3
  46. package/dist/system/schema.js.map +1 -1
  47. package/dist/testing/database.js +1 -1
  48. package/dist/vite-plugin/changes-route.d.ts +10 -0
  49. package/dist/vite-plugin/changes-route.d.ts.map +1 -0
  50. package/dist/vite-plugin/changes-route.js +174 -0
  51. package/dist/vite-plugin/changes-route.js.map +1 -0
  52. package/dist/vite-plugin/index.d.ts +7 -0
  53. package/dist/vite-plugin/index.d.ts.map +1 -1
  54. package/dist/vite-plugin/index.js +148 -21
  55. package/dist/vite-plugin/index.js.map +1 -1
  56. package/dist/vite-plugin/route-header.d.ts +10 -0
  57. package/dist/vite-plugin/route-header.d.ts.map +1 -0
  58. package/dist/vite-plugin/route-header.js +14 -0
  59. package/dist/vite-plugin/route-header.js.map +1 -0
  60. package/dist/vite-plugin/sveltekit-generator.d.ts +7 -0
  61. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  62. package/dist/vite-plugin/sveltekit-generator.js +5 -3
  63. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  64. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -1
  65. package/dist/vite-plugin/sync-apply-route.js +2 -2
  66. package/dist/vite-plugin/sync-apply-route.js.map +1 -1
  67. package/dist/vite-plugin/web-collections.d.ts +73 -0
  68. package/dist/vite-plugin/web-collections.d.ts.map +1 -0
  69. package/dist/vite-plugin/web-collections.js +174 -0
  70. package/dist/vite-plugin/web-collections.js.map +1 -0
  71. 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":"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"}
1
+ {"version":3,"file":"rest.d.ts","sourceRoot":"","sources":["../../src/generators/rest.ts"],"names":[],"mappings":"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"}
@@ -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 registeredClasses = ObjectRegistry.getAllClasses();
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 pluralName = this.pluralize(objectSegment);
415
- for (const [key, info] of ObjectRegistry.getAllClasses()) if (this.pluralize((info.name || key).toLowerCase()) === pluralName) {
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) {