@happyvertical/smrt-core 0.37.5 → 0.37.7

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 (52) hide show
  1. package/AGENTS.md +2 -0
  2. package/dist/collection.d.ts +7 -0
  3. package/dist/collection.d.ts.map +1 -1
  4. package/dist/collection.js +2 -0
  5. package/dist/collection.js.map +1 -1
  6. package/dist/consumer-plugin/index.js +26 -3
  7. package/dist/consumer-plugin/index.js.map +1 -1
  8. package/dist/generators/conditional-get.d.ts +108 -0
  9. package/dist/generators/conditional-get.d.ts.map +1 -0
  10. package/dist/generators/conditional-get.js +189 -0
  11. package/dist/generators/conditional-get.js.map +1 -0
  12. package/dist/generators/index.d.ts +1 -0
  13. package/dist/generators/index.d.ts.map +1 -1
  14. package/dist/generators/index.js +2 -1
  15. package/dist/generators/rest.d.ts +22 -0
  16. package/dist/generators/rest.d.ts.map +1 -1
  17. package/dist/generators/rest.js +86 -5
  18. package/dist/generators/rest.js.map +1 -1
  19. package/dist/generators.js +2 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3 -1
  23. package/dist/manifest/static-manifest.d.ts.map +1 -1
  24. package/dist/manifest/static-manifest.js +10 -2
  25. package/dist/manifest/static-manifest.js.map +1 -1
  26. package/dist/manifest/store.js +1 -1
  27. package/dist/manifest/store.js.map +1 -1
  28. package/dist/manifest/test-manifest-stub.d.ts.map +1 -1
  29. package/dist/manifest/test-manifest-stub.js +1833 -223
  30. package/dist/manifest/test-manifest-stub.js.map +1 -1
  31. package/dist/manifest.json +10 -2
  32. package/dist/object.d.ts +19 -0
  33. package/dist/object.d.ts.map +1 -1
  34. package/dist/object.js +24 -2
  35. package/dist/object.js.map +1 -1
  36. package/dist/registry/index.d.ts +1 -1
  37. package/dist/registry/index.d.ts.map +1 -1
  38. package/dist/registry/types.d.ts +34 -0
  39. package/dist/registry/types.d.ts.map +1 -1
  40. package/dist/smrt-knowledge.json +7 -6
  41. package/dist/sync/apply.d.ts +234 -0
  42. package/dist/sync/apply.d.ts.map +1 -0
  43. package/dist/sync/apply.js +378 -0
  44. package/dist/sync/apply.js.map +1 -0
  45. package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
  46. package/dist/vite-plugin/sveltekit-generator.js +19 -10
  47. package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
  48. package/dist/vite-plugin/sync-apply-route.d.ts +40 -0
  49. package/dist/vite-plugin/sync-apply-route.d.ts.map +1 -0
  50. package/dist/vite-plugin/sync-apply-route.js +240 -0
  51. package/dist/vite-plugin/sync-apply-route.js.map +1 -0
  52. package/package.json +4 -4
@@ -0,0 +1,378 @@
1
+ import { ValidationError } from "../errors.js";
2
+ //#region src/sync/apply.ts
3
+ /**
4
+ * Idempotent sync-apply batch processing (#1759).
5
+ *
6
+ * The single shared write contract for the web offline outbox (#1762) and the
7
+ * KMP mobile write queue (#1739): an ordered batch of client-authored
8
+ * mutations carrying client-generated row UUIDs, applied FIFO through the
9
+ * host's full stack (authentication, tenant isolation, interceptors, writable
10
+ * policy), returning per-item results (`applied` / `conflict` / `rejected`).
11
+ *
12
+ * This module is transport-agnostic: the runtime REST generator
13
+ * (`generators/rest.ts`) and the generated SvelteKit route
14
+ * (`vite-plugin/sync-apply-route.ts`) both delegate here, supplying a
15
+ * {@link SyncApplyHost} that adapts their own collection resolution,
16
+ * authorization, and writable-policy machinery. Keeping the logic here keeps
17
+ * the two generators byte-minimal and the contract single-sourced.
18
+ *
19
+ * Contract documentation: docs/content/architecture/sync-apply-contract.md
20
+ *
21
+ * ## Why replay is idempotent
22
+ *
23
+ * - Row identity is the client-generated UUID (validated server-side), and
24
+ * ONLY that UUID: creates persist via a strict INSERT
25
+ * (`_insertOnly`, never natural-key dedup-upsert), so a create can never
26
+ * adopt or overwrite a different row, and a re-delivered create finds its
27
+ * own row via the id pre-check instead of duplicating it.
28
+ * - Before writing, each create/update is compared against the current row;
29
+ * when the row already reflects the item's payload the write is skipped and
30
+ * reported `applied` (a no-op re-apply). This is what makes N replays of an
31
+ * identical batch yield byte-identical database state (no `updated_at`
32
+ * churn) AND identical per-item results, without a server-side idempotency
33
+ * ledger.
34
+ * - Deletes of rows that are already gone report `applied` consistently.
35
+ *
36
+ * ## Conflict policy v1 (server-authoritative LWW, updated-at guard)
37
+ *
38
+ * The stale guard applies to mutations that would actually change server
39
+ * state: an update/delete whose `baseUpdatedAt` is older than the server
40
+ * row's `updated_at` AND whose effect is not already present is the LOSING
41
+ * write — it is **skipped** (the newer server state wins) and the item is
42
+ * reported `conflict` with the server's current `updatedAt` so the client can
43
+ * rebase. Two deliberate precedences soften "stale ⇒ conflict":
44
+ * no-op detection runs first (a stale update whose payload already matches
45
+ * the row is an idempotent replay of an applied write → `applied`), and a
46
+ * delete of a row that is already gone is a no-op → `applied`. Skipping
47
+ * (rather than clobbering) was chosen because it never destroys newer data,
48
+ * and it is replay-stable: a replayed conflicting item reports `conflict`
49
+ * again against unchanged state. Field-level merge is explicitly out of
50
+ * scope for v1.
51
+ */
52
+ /**
53
+ * URL segments of the generated batch apply endpoint, relative to the API
54
+ * base path: `POST {basePath}/sync/apply`. The `sync` segment is reserved —
55
+ * a collection named `sync` cannot expose an item route named `apply`.
56
+ */
57
+ var SYNC_APPLY_ROUTE_SEGMENTS = ["sync", "apply"];
58
+ /** Maximum items accepted in one batch; larger batches are rejected (400). */
59
+ var MAX_SYNC_APPLY_BATCH_SIZE = 1e3;
60
+ /**
61
+ * Client-generated row ids must be well-formed UUIDs. Same shape the ORM
62
+ * accepts as an id filter in `SmrtCollection.get()`.
63
+ */
64
+ var SYNC_APPLY_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
65
+ var SYNC_APPLY_OPS = [
66
+ "create",
67
+ "update",
68
+ "delete"
69
+ ];
70
+ /**
71
+ * Writable-policy helper shared with the generated SvelteKit sync route: the
72
+ * same stripping rules as the generated CRUD handlers (#1540) — server-managed
73
+ * fields, `_`-prefixed keys, `@field({ readonly: true })` fields, and (when
74
+ * configured) intersection with the `api.writable` allowlist.
75
+ */
76
+ function applySyncWritablePolicy(data, policy = {}) {
77
+ if (!data || typeof data !== "object") return {};
78
+ const serverManaged = /* @__PURE__ */ new Set([
79
+ "id",
80
+ "tenantId",
81
+ "tenant_id",
82
+ "createdAt",
83
+ "created_at",
84
+ "updatedAt",
85
+ "updated_at"
86
+ ]);
87
+ const readonly = new Set(policy.readonlyFields ?? []);
88
+ const writable = policy.writableAllowlist ?? null;
89
+ const result = {};
90
+ for (const [key, value] of Object.entries(data)) {
91
+ if (key.startsWith("_")) continue;
92
+ if (serverManaged.has(key)) continue;
93
+ if (readonly.has(key)) continue;
94
+ if (writable && !writable.includes(key)) continue;
95
+ result[key] = value;
96
+ }
97
+ return result;
98
+ }
99
+ /**
100
+ * Batch-level validation. Returns the raw items array or a 400-shaped error.
101
+ */
102
+ function parseSyncApplyBatch(body) {
103
+ if (!body || typeof body !== "object" || Array.isArray(body) || !Array.isArray(body.items)) return { error: {
104
+ code: "invalid_batch",
105
+ message: "Request body must be an object with an \"items\" array"
106
+ } };
107
+ const items = body.items;
108
+ if (items.length > 1e3) return { error: {
109
+ code: "batch_too_large",
110
+ message: `Batch exceeds the maximum of ${MAX_SYNC_APPLY_BATCH_SIZE} items`
111
+ } };
112
+ return { items };
113
+ }
114
+ /**
115
+ * Per-item shape validation. Never throws; malformed items become `rejected`
116
+ * results so one bad item cannot fail the batch.
117
+ */
118
+ function validateSyncApplyItem(raw) {
119
+ const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
120
+ const itemId = record && typeof record.itemId === "string" && record.itemId.length > 0 ? record.itemId : null;
121
+ const id = record && typeof record.id === "string" ? record.id : null;
122
+ const reject = (reason) => ({
123
+ ok: false,
124
+ result: {
125
+ itemId,
126
+ id,
127
+ status: "rejected",
128
+ reason
129
+ }
130
+ });
131
+ if (!record || !itemId) return reject("invalid_item");
132
+ const op = record.op;
133
+ if (typeof op !== "string" || !SYNC_APPLY_OPS.includes(op)) return reject("invalid_item");
134
+ const object = record.object;
135
+ if (typeof object !== "string" || object.length === 0) return reject("invalid_item");
136
+ const baseUpdatedAt = record.baseUpdatedAt;
137
+ if (baseUpdatedAt !== void 0) {
138
+ if (typeof baseUpdatedAt !== "string" || Number.isNaN(Date.parse(baseUpdatedAt))) return reject("invalid_item");
139
+ }
140
+ if (!id || !SYNC_APPLY_UUID_PATTERN.test(id)) return reject("invalid_id");
141
+ const payload = record.payload;
142
+ if (op === "create" || op === "update") {
143
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return reject("invalid_payload");
144
+ }
145
+ return {
146
+ ok: true,
147
+ item: {
148
+ itemId,
149
+ object,
150
+ op,
151
+ id,
152
+ payload,
153
+ baseUpdatedAt
154
+ }
155
+ };
156
+ }
157
+ function toEpochMillis(value) {
158
+ if (value instanceof Date) {
159
+ const time = value.getTime();
160
+ return Number.isNaN(time) ? null : time;
161
+ }
162
+ if (typeof value === "string") {
163
+ const time = Date.parse(value);
164
+ return Number.isNaN(time) ? null : time;
165
+ }
166
+ return null;
167
+ }
168
+ /**
169
+ * Updated-at conflict guard: true when the client's base is strictly older
170
+ * than the server row's `updated_at`. No base (client opted out) or no
171
+ * comparable server timestamp → not stale.
172
+ */
173
+ function isStaleWrite(baseUpdatedAt, serverUpdatedAt) {
174
+ const base = toEpochMillis(baseUpdatedAt ?? null);
175
+ if (base === null) return false;
176
+ const server = toEpochMillis(serverUpdatedAt);
177
+ if (server === null) return false;
178
+ return base < server;
179
+ }
180
+ function normalizeForComparison(value) {
181
+ if (value instanceof Date) return value.toISOString();
182
+ return value;
183
+ }
184
+ function valuesEquivalent(rowValue, payloadValue) {
185
+ if (rowValue instanceof Date) {
186
+ const candidate = typeof payloadValue === "string" || typeof payloadValue === "number" ? new Date(payloadValue).getTime() : payloadValue instanceof Date ? payloadValue.getTime() : NaN;
187
+ return !Number.isNaN(candidate) && candidate === rowValue.getTime();
188
+ }
189
+ if (rowValue === null || rowValue === void 0) return payloadValue === null || payloadValue === void 0;
190
+ if (typeof rowValue !== "object" && typeof payloadValue !== "object") return rowValue === payloadValue;
191
+ try {
192
+ return JSON.stringify(normalizeForComparison(rowValue)) === JSON.stringify(normalizeForComparison(payloadValue));
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+ /**
198
+ * No-op detection: true when the row already reflects every (policy-stripped)
199
+ * payload field. Payload keys that are not properties of the hydrated row are
200
+ * ignored — they would never persist, so they must not defeat idempotent
201
+ * replay. This check is what keeps replayed batches from churning
202
+ * `updated_at` (and keeps their per-item results identical).
203
+ */
204
+ function payloadMatchesRow(payload, row) {
205
+ const record = row;
206
+ for (const [key, value] of Object.entries(payload)) {
207
+ if (!(key in record)) continue;
208
+ if (!valuesEquivalent(record[key], value)) return false;
209
+ }
210
+ return true;
211
+ }
212
+ function rowUpdatedAtIso(row) {
213
+ const value = row.updated_at;
214
+ if (value instanceof Date) return value.toISOString();
215
+ if (typeof value === "string") {
216
+ const epoch = Date.parse(value);
217
+ return Number.isNaN(epoch) ? value : new Date(epoch).toISOString();
218
+ }
219
+ }
220
+ /**
221
+ * Detect a unique/primary-key constraint failure anywhere in an error's cause
222
+ * chain. `save()` classifies constraint errors into typed `ValidationError`s
223
+ * when the driver message reaches it directly, but adapter layers may wrap
224
+ * the driver error (e.g. "Failed to upsert record into table …" with the
225
+ * SQLite/PostgreSQL/DuckDB constraint text nested in `cause`), surfacing as a
226
+ * generic `DatabaseError` instead — so match both, walking the chain. The
227
+ * message patterns mirror `SmrtObject.classifyConstraintError`.
228
+ */
229
+ function isUniqueConstraintError(error) {
230
+ const seen = /* @__PURE__ */ new Set();
231
+ let current = error;
232
+ while (current instanceof Error && !seen.has(current)) {
233
+ seen.add(current);
234
+ if (current instanceof ValidationError && current.code === "VALIDATION_UNIQUE_CONSTRAINT") return true;
235
+ if (/UNIQUE constraint failed/i.test(current.message) || /violates unique constraint/i.test(current.message) || /violates primary key constraint/i.test(current.message)) return true;
236
+ current = current.cause;
237
+ }
238
+ return false;
239
+ }
240
+ async function applyValidatedItem(item, target) {
241
+ const base = {
242
+ itemId: item.itemId,
243
+ id: item.id
244
+ };
245
+ const decision = await target.authorize(item.op);
246
+ if (decision !== "ok") return {
247
+ ...base,
248
+ status: "rejected",
249
+ reason: decision
250
+ };
251
+ if (!target.isOpAllowed(item.op)) return {
252
+ ...base,
253
+ status: "rejected",
254
+ reason: "op_not_allowed"
255
+ };
256
+ const data = item.op === "delete" ? {} : target.prepare(item.payload ?? {});
257
+ const row = await target.collection.get(item.id);
258
+ switch (item.op) {
259
+ case "create":
260
+ if (row) {
261
+ if (payloadMatchesRow(data, row)) return {
262
+ ...base,
263
+ status: "applied",
264
+ updatedAt: rowUpdatedAtIso(row)
265
+ };
266
+ return {
267
+ ...base,
268
+ status: "conflict",
269
+ reason: "create_conflict",
270
+ updatedAt: rowUpdatedAtIso(row)
271
+ };
272
+ }
273
+ try {
274
+ const created = await target.collection.create({
275
+ ...data,
276
+ id: item.id,
277
+ _insertOnly: true
278
+ });
279
+ return {
280
+ ...base,
281
+ status: "applied",
282
+ updatedAt: rowUpdatedAtIso(created)
283
+ };
284
+ } catch (error) {
285
+ if (isUniqueConstraintError(error)) return {
286
+ ...base,
287
+ status: "rejected",
288
+ reason: "id_conflict"
289
+ };
290
+ throw error;
291
+ }
292
+ case "update":
293
+ if (!row) return {
294
+ ...base,
295
+ status: "rejected",
296
+ reason: "not_found"
297
+ };
298
+ if (payloadMatchesRow(data, row)) return {
299
+ ...base,
300
+ status: "applied",
301
+ updatedAt: rowUpdatedAtIso(row)
302
+ };
303
+ if (isStaleWrite(item.baseUpdatedAt, row.updated_at)) return {
304
+ ...base,
305
+ status: "conflict",
306
+ reason: "stale_write",
307
+ updatedAt: rowUpdatedAtIso(row)
308
+ };
309
+ Object.assign(row, data);
310
+ await row.save();
311
+ return {
312
+ ...base,
313
+ status: "applied",
314
+ updatedAt: rowUpdatedAtIso(row)
315
+ };
316
+ case "delete":
317
+ if (!row) return {
318
+ ...base,
319
+ status: "applied"
320
+ };
321
+ if (isStaleWrite(item.baseUpdatedAt, row.updated_at)) return {
322
+ ...base,
323
+ status: "conflict",
324
+ reason: "stale_write",
325
+ updatedAt: rowUpdatedAtIso(row)
326
+ };
327
+ await row.delete();
328
+ return {
329
+ ...base,
330
+ status: "applied"
331
+ };
332
+ }
333
+ }
334
+ async function processOneItem(raw, host) {
335
+ const validated = validateSyncApplyItem(raw);
336
+ if (!validated.ok) return validated.result;
337
+ const item = validated.item;
338
+ try {
339
+ const target = await host.resolveTarget(item.object);
340
+ if (!target) return {
341
+ itemId: item.itemId,
342
+ id: item.id,
343
+ status: "rejected",
344
+ reason: "unknown_object"
345
+ };
346
+ return await applyValidatedItem(item, target);
347
+ } catch {
348
+ return {
349
+ itemId: item.itemId,
350
+ id: item.id,
351
+ status: "rejected",
352
+ reason: "write_failed"
353
+ };
354
+ }
355
+ }
356
+ /**
357
+ * Process a sync-apply batch: validate the envelope, then apply items FIFO
358
+ * (strictly in order, awaiting each — items may depend on earlier items in
359
+ * the same batch). Always resolves; item-level failures surface as `rejected`
360
+ * results and only envelope-level problems produce a 400.
361
+ */
362
+ async function processSyncApplyBatch(body, host) {
363
+ const parsed = parseSyncApplyBatch(body);
364
+ if ("error" in parsed) return {
365
+ status: 400,
366
+ body: parsed
367
+ };
368
+ const results = [];
369
+ for (const raw of parsed.items) results.push(await processOneItem(raw, host));
370
+ return {
371
+ status: 200,
372
+ body: { results }
373
+ };
374
+ }
375
+ //#endregion
376
+ export { MAX_SYNC_APPLY_BATCH_SIZE, SYNC_APPLY_ROUTE_SEGMENTS, SYNC_APPLY_UUID_PATTERN, applySyncWritablePolicy, isStaleWrite, parseSyncApplyBatch, payloadMatchesRow, processSyncApplyBatch, validateSyncApplyItem };
377
+
378
+ //# sourceMappingURL=apply.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apply.js","names":[],"sources":["../../src/sync/apply.ts"],"sourcesContent":["/**\n * Idempotent sync-apply batch processing (#1759).\n *\n * The single shared write contract for the web offline outbox (#1762) and the\n * KMP mobile write queue (#1739): an ordered batch of client-authored\n * mutations carrying client-generated row UUIDs, applied FIFO through the\n * host's full stack (authentication, tenant isolation, interceptors, writable\n * policy), returning per-item results (`applied` / `conflict` / `rejected`).\n *\n * This module is transport-agnostic: the runtime REST generator\n * (`generators/rest.ts`) and the generated SvelteKit route\n * (`vite-plugin/sync-apply-route.ts`) both delegate here, supplying a\n * {@link SyncApplyHost} that adapts their own collection resolution,\n * authorization, and writable-policy machinery. Keeping the logic here keeps\n * the two generators byte-minimal and the contract single-sourced.\n *\n * Contract documentation: docs/content/architecture/sync-apply-contract.md\n *\n * ## Why replay is idempotent\n *\n * - Row identity is the client-generated UUID (validated server-side), and\n * ONLY that UUID: creates persist via a strict INSERT\n * (`_insertOnly`, never natural-key dedup-upsert), so a create can never\n * adopt or overwrite a different row, and a re-delivered create finds its\n * own row via the id pre-check instead of duplicating it.\n * - Before writing, each create/update is compared against the current row;\n * when the row already reflects the item's payload the write is skipped and\n * reported `applied` (a no-op re-apply). This is what makes N replays of an\n * identical batch yield byte-identical database state (no `updated_at`\n * churn) AND identical per-item results, without a server-side idempotency\n * ledger.\n * - Deletes of rows that are already gone report `applied` consistently.\n *\n * ## Conflict policy v1 (server-authoritative LWW, updated-at guard)\n *\n * The stale guard applies to mutations that would actually change server\n * state: an update/delete whose `baseUpdatedAt` is older than the server\n * row's `updated_at` AND whose effect is not already present is the LOSING\n * write — it is **skipped** (the newer server state wins) and the item is\n * reported `conflict` with the server's current `updatedAt` so the client can\n * rebase. Two deliberate precedences soften \"stale ⇒ conflict\":\n * no-op detection runs first (a stale update whose payload already matches\n * the row is an idempotent replay of an applied write → `applied`), and a\n * delete of a row that is already gone is a no-op → `applied`. Skipping\n * (rather than clobbering) was chosen because it never destroys newer data,\n * and it is replay-stable: a replayed conflicting item reports `conflict`\n * again against unchanged state. Field-level merge is explicitly out of\n * scope for v1.\n */\n\nimport { ValidationError } from '../errors.js';\n\n/**\n * URL segments of the generated batch apply endpoint, relative to the API\n * base path: `POST {basePath}/sync/apply`. The `sync` segment is reserved —\n * a collection named `sync` cannot expose an item route named `apply`.\n */\nexport const SYNC_APPLY_ROUTE_SEGMENTS = ['sync', 'apply'] as const;\n\n/** Maximum items accepted in one batch; larger batches are rejected (400). */\nexport const MAX_SYNC_APPLY_BATCH_SIZE = 1000;\n\n/**\n * Client-generated row ids must be well-formed UUIDs. Same shape the ORM\n * accepts as an id filter in `SmrtCollection.get()`.\n */\nexport const SYNC_APPLY_UUID_PATTERN =\n /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** Mutation kinds accepted by the batch apply endpoint. */\nexport type SyncApplyOp = 'create' | 'update' | 'delete';\n\nconst SYNC_APPLY_OPS: readonly SyncApplyOp[] = ['create', 'update', 'delete'];\n\n/** Per-item outcome status. */\nexport type SyncApplyStatus = 'applied' | 'conflict' | 'rejected';\n\n/** Machine-readable reasons for `rejected` items. */\nexport type SyncApplyRejectionReason =\n | 'invalid_item'\n | 'invalid_id'\n | 'invalid_payload'\n | 'unknown_object'\n | 'op_not_allowed'\n | 'auth_required'\n | 'forbidden'\n | 'not_found'\n | 'id_conflict'\n | 'write_failed';\n\n/** Machine-readable reasons for `conflict` items. */\nexport type SyncApplyConflictReason = 'stale_write' | 'create_conflict';\n\nexport type SyncApplyReason =\n | SyncApplyRejectionReason\n | SyncApplyConflictReason;\n\n/**\n * One client-authored mutation in a batch. See the contract doc for field\n * semantics; briefly:\n *\n * - `itemId` — the client's idempotency/correlation handle (the durable queue\n * row id). Echoed back verbatim; results are also positional.\n * - `object` — the collection route segment the model's CRUD routes use\n * (e.g. `products`).\n * - `id` — the client-generated row UUID. This is the ONLY channel for a\n * client-supplied id; payload `id` fields are stripped by the writable\n * policy exactly as on plain CRUD routes (#1540).\n * - `baseUpdatedAt` — the server `updated_at` the client last saw for this\n * row; drives the conflict guard on update/delete. Omit to opt out.\n */\nexport interface SyncApplyItem {\n itemId: string;\n object: string;\n op: SyncApplyOp;\n id: string;\n payload?: Record<string, unknown>;\n baseUpdatedAt?: string;\n}\n\n/** Request body of `POST …/sync/apply`. */\nexport interface SyncApplyBatchRequest {\n items: SyncApplyItem[];\n}\n\n/**\n * Per-item result. `results[i]` always corresponds to `items[i]` (positional);\n * `itemId` is echoed for convenience (`null` when the incoming item was too\n * malformed to carry one). `updatedAt` is the server row's `updated_at` after\n * processing — present on applied creates/updates and on conflicts (so clients\n * can rebase), absent on deletes and rejections.\n */\nexport interface SyncApplyItemResult {\n itemId: string | null;\n id: string | null;\n status: SyncApplyStatus;\n reason?: SyncApplyReason;\n updatedAt?: string;\n}\n\n/** Response body of `POST …/sync/apply` (HTTP 200). */\nexport interface SyncApplyBatchResponse {\n results: SyncApplyItemResult[];\n}\n\n/** Batch-level error body (HTTP 400). */\nexport interface SyncApplyBatchError {\n error: {\n code: 'invalid_batch' | 'batch_too_large';\n message: string;\n };\n}\n\n/** Outcome of {@link processSyncApplyBatch}: HTTP status + JSON body. */\nexport type SyncApplyOutcome =\n | { status: 200; body: SyncApplyBatchResponse }\n | { status: 400; body: SyncApplyBatchError };\n\n/**\n * Minimal structural view of a persisted row as the processor needs it.\n * Matches `SmrtObject` structurally; kept structural so hosts and tests can\n * exercise the processor without importing ORM classes.\n */\nexport interface SyncApplyRowLike {\n id?: string | null;\n updated_at?: Date | string | null;\n save(): Promise<unknown>;\n delete(): Promise<unknown>;\n}\n\n/**\n * Minimal structural view of a collection (matches `SmrtCollection`).\n * `create()` must honor the `_insertOnly` option (strict INSERT — see\n * `SmrtCreateInput`), which the processor sets on every sync create.\n */\nexport interface SyncApplyCollectionLike {\n get(id: string): Promise<SyncApplyRowLike | null>;\n create(options: Record<string, unknown>): Promise<SyncApplyRowLike>;\n}\n\n/** Per-item authorization decision from the host. */\nexport type SyncApplyAuthzDecision = 'ok' | 'auth_required' | 'forbidden';\n\n/**\n * A resolved mutation target: everything the processor needs to apply one\n * item through the host's full stack. Hosts build these from their existing\n * machinery so sync items follow the exact same authorization, action-gating,\n * and mass-assignment rules as the host's plain CRUD routes.\n */\nexport interface SyncApplyTarget {\n /** Registry/class name, for diagnostics. */\n objectName: string;\n /** Collection whose get/create/save/delete run the full interceptor stack. */\n collection: SyncApplyCollectionLike;\n /** Whether the object's API config exposes this op (mirrors CRUD gating). */\n isOpAllowed(op: SyncApplyOp): boolean;\n /** Per-item authorization (mutating semantics, fail-closed). */\n authorize(\n op: SyncApplyOp,\n ): Promise<SyncApplyAuthzDecision> | SyncApplyAuthzDecision;\n /**\n * Mass-assignment guard (#1540): strip server-managed/readonly/disallowed\n * fields from the item payload. The row UUID is injected from the validated\n * envelope `id` AFTER this runs — never from the body.\n */\n prepare(payload: Record<string, unknown>): Record<string, unknown>;\n}\n\n/** Host adapter handed to {@link processSyncApplyBatch}. */\nexport interface SyncApplyHost {\n /**\n * Resolve an item's `object` segment to a target, or `null` when unknown.\n * Called once per item so per-item auth context stays accurate.\n */\n resolveTarget(\n objectSegment: string,\n ): Promise<SyncApplyTarget | null> | SyncApplyTarget | null;\n}\n\n/**\n * Writable-policy helper shared with the generated SvelteKit sync route: the\n * same stripping rules as the generated CRUD handlers (#1540) — server-managed\n * fields, `_`-prefixed keys, `@field({ readonly: true })` fields, and (when\n * configured) intersection with the `api.writable` allowlist.\n */\nexport function applySyncWritablePolicy(\n data: unknown,\n policy: {\n readonlyFields?: readonly string[];\n writableAllowlist?: readonly string[] | null;\n } = {},\n): Record<string, unknown> {\n if (!data || typeof data !== 'object') {\n return {};\n }\n\n const serverManaged = new Set([\n 'id',\n 'tenantId',\n 'tenant_id',\n 'createdAt',\n 'created_at',\n 'updatedAt',\n 'updated_at',\n ]);\n const readonly = new Set(policy.readonlyFields ?? []);\n const writable = policy.writableAllowlist ?? null;\n\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(data)) {\n if (key.startsWith('_')) continue;\n if (serverManaged.has(key)) continue;\n if (readonly.has(key)) continue;\n if (writable && !writable.includes(key)) continue;\n result[key] = value;\n }\n return result;\n}\n\n/**\n * Batch-level validation. Returns the raw items array or a 400-shaped error.\n */\nexport function parseSyncApplyBatch(\n body: unknown,\n): { items: unknown[] } | SyncApplyBatchError {\n if (\n !body ||\n typeof body !== 'object' ||\n Array.isArray(body) ||\n !Array.isArray((body as { items?: unknown }).items)\n ) {\n return {\n error: {\n code: 'invalid_batch',\n message: 'Request body must be an object with an \"items\" array',\n },\n };\n }\n\n const items = (body as { items: unknown[] }).items;\n if (items.length > MAX_SYNC_APPLY_BATCH_SIZE) {\n return {\n error: {\n code: 'batch_too_large',\n message: `Batch exceeds the maximum of ${MAX_SYNC_APPLY_BATCH_SIZE} items`,\n },\n };\n }\n\n return { items };\n}\n\ninterface ValidatedItem {\n ok: true;\n item: SyncApplyItem;\n}\n\ninterface InvalidItem {\n ok: false;\n result: SyncApplyItemResult;\n}\n\n/**\n * Per-item shape validation. Never throws; malformed items become `rejected`\n * results so one bad item cannot fail the batch.\n */\nexport function validateSyncApplyItem(\n raw: unknown,\n): ValidatedItem | InvalidItem {\n const record =\n raw && typeof raw === 'object' && !Array.isArray(raw)\n ? (raw as Record<string, unknown>)\n : null;\n\n const itemId =\n record && typeof record.itemId === 'string' && record.itemId.length > 0\n ? record.itemId\n : null;\n const id = record && typeof record.id === 'string' ? record.id : null;\n\n const reject = (reason: SyncApplyRejectionReason): InvalidItem => ({\n ok: false,\n result: { itemId, id, status: 'rejected', reason },\n });\n\n if (!record || !itemId) {\n return reject('invalid_item');\n }\n\n const op = record.op;\n if (typeof op !== 'string' || !SYNC_APPLY_OPS.includes(op as SyncApplyOp)) {\n return reject('invalid_item');\n }\n\n const object = record.object;\n if (typeof object !== 'string' || object.length === 0) {\n return reject('invalid_item');\n }\n\n const baseUpdatedAt = record.baseUpdatedAt;\n if (baseUpdatedAt !== undefined) {\n if (\n typeof baseUpdatedAt !== 'string' ||\n Number.isNaN(Date.parse(baseUpdatedAt))\n ) {\n return reject('invalid_item');\n }\n }\n\n // Client UUID validation: the row id must be a well-formed UUID.\n if (!id || !SYNC_APPLY_UUID_PATTERN.test(id)) {\n return reject('invalid_id');\n }\n\n const payload = record.payload;\n if (op === 'create' || op === 'update') {\n if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {\n return reject('invalid_payload');\n }\n }\n\n return {\n ok: true,\n item: {\n itemId,\n object,\n op: op as SyncApplyOp,\n id,\n payload: payload as Record<string, unknown> | undefined,\n baseUpdatedAt: baseUpdatedAt as string | undefined,\n },\n };\n}\n\nfunction toEpochMillis(value: Date | string | null | undefined): number | null {\n if (value instanceof Date) {\n const time = value.getTime();\n return Number.isNaN(time) ? null : time;\n }\n if (typeof value === 'string') {\n const time = Date.parse(value);\n return Number.isNaN(time) ? null : time;\n }\n return null;\n}\n\n/**\n * Updated-at conflict guard: true when the client's base is strictly older\n * than the server row's `updated_at`. No base (client opted out) or no\n * comparable server timestamp → not stale.\n */\nexport function isStaleWrite(\n baseUpdatedAt: string | undefined,\n serverUpdatedAt: Date | string | null | undefined,\n): boolean {\n const base = toEpochMillis(baseUpdatedAt ?? null);\n if (base === null) return false;\n const server = toEpochMillis(serverUpdatedAt);\n if (server === null) return false;\n return base < server;\n}\n\nfunction normalizeForComparison(value: unknown): unknown {\n if (value instanceof Date) return value.toISOString();\n return value;\n}\n\nfunction valuesEquivalent(rowValue: unknown, payloadValue: unknown): boolean {\n // Datetime fields hydrate as Date; clients send ISO strings (or epoch ms).\n if (rowValue instanceof Date) {\n const candidate =\n typeof payloadValue === 'string' || typeof payloadValue === 'number'\n ? new Date(payloadValue).getTime()\n : payloadValue instanceof Date\n ? payloadValue.getTime()\n : Number.NaN;\n return !Number.isNaN(candidate) && candidate === rowValue.getTime();\n }\n\n if (rowValue === null || rowValue === undefined) {\n return payloadValue === null || payloadValue === undefined;\n }\n\n if (typeof rowValue !== 'object' && typeof payloadValue !== 'object') {\n return rowValue === payloadValue;\n }\n\n try {\n return (\n JSON.stringify(normalizeForComparison(rowValue)) ===\n JSON.stringify(normalizeForComparison(payloadValue))\n );\n } catch {\n return false;\n }\n}\n\n/**\n * No-op detection: true when the row already reflects every (policy-stripped)\n * payload field. Payload keys that are not properties of the hydrated row are\n * ignored — they would never persist, so they must not defeat idempotent\n * replay. This check is what keeps replayed batches from churning\n * `updated_at` (and keeps their per-item results identical).\n */\nexport function payloadMatchesRow(\n payload: Record<string, unknown>,\n row: SyncApplyRowLike,\n): boolean {\n const record = row as unknown as Record<string, unknown>;\n for (const [key, value] of Object.entries(payload)) {\n if (!(key in record)) continue;\n if (!valuesEquivalent(record[key], value)) return false;\n }\n return true;\n}\n\nfunction rowUpdatedAtIso(row: SyncApplyRowLike): string | undefined {\n const value = row.updated_at;\n if (value instanceof Date) return value.toISOString();\n if (typeof value === 'string') {\n const epoch = Date.parse(value);\n return Number.isNaN(epoch) ? value : new Date(epoch).toISOString();\n }\n return undefined;\n}\n\n/**\n * Detect a unique/primary-key constraint failure anywhere in an error's cause\n * chain. `save()` classifies constraint errors into typed `ValidationError`s\n * when the driver message reaches it directly, but adapter layers may wrap\n * the driver error (e.g. \"Failed to upsert record into table …\" with the\n * SQLite/PostgreSQL/DuckDB constraint text nested in `cause`), surfacing as a\n * generic `DatabaseError` instead — so match both, walking the chain. The\n * message patterns mirror `SmrtObject.classifyConstraintError`.\n */\nfunction isUniqueConstraintError(error: unknown): boolean {\n const seen = new Set<unknown>();\n let current: unknown = error;\n while (current instanceof Error && !seen.has(current)) {\n seen.add(current);\n if (\n current instanceof ValidationError &&\n current.code === 'VALIDATION_UNIQUE_CONSTRAINT'\n ) {\n return true;\n }\n if (\n /UNIQUE constraint failed/i.test(current.message) ||\n /violates unique constraint/i.test(current.message) ||\n /violates primary key constraint/i.test(current.message)\n ) {\n return true;\n }\n current = (current as { cause?: unknown }).cause;\n }\n return false;\n}\n\nasync function applyValidatedItem(\n item: SyncApplyItem,\n target: SyncApplyTarget,\n): Promise<SyncApplyItemResult> {\n const base = { itemId: item.itemId, id: item.id } as const;\n\n // Authorization first (mirrors CRUD: the auth middleware runs before the\n // per-action 405 gate), then action gating.\n const decision = await target.authorize(item.op);\n if (decision !== 'ok') {\n return { ...base, status: 'rejected', reason: decision };\n }\n\n if (!target.isOpAllowed(item.op)) {\n return { ...base, status: 'rejected', reason: 'op_not_allowed' };\n }\n\n // Payload passes through the host's writable policy; the row UUID comes\n // from the validated envelope `id`, never from the body — the #1540\n // mass-assignment guard stays intact on this path too.\n const data = item.op === 'delete' ? {} : target.prepare(item.payload ?? {});\n\n // Tenant isolation: collection.get runs the interceptor stack, so rows\n // outside the caller's tenant are simply not visible here.\n const row = await target.collection.get(item.id);\n\n switch (item.op) {\n case 'create': {\n if (row) {\n if (payloadMatchesRow(data, row)) {\n // Idempotent replay of an already-applied create.\n return {\n ...base,\n status: 'applied',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n // The id exists with different content — the server state wins (LWW\n // skip) and the client is told to rebase.\n return {\n ...base,\n status: 'conflict',\n reason: 'create_conflict',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n\n try {\n // Strict insert (`_insertOnly`): row identity is the envelope UUID\n // alone. Without it, `create()` saves new objects with natural-key\n // conflict resolution (slug/context or configured `conflictColumns`,\n // #1472's ingestion dedup), so a fresh-UUID create whose payload\n // derives an existing row's natural key would silently adopt and\n // rewrite that row — violating this contract.\n const created = await target.collection.create({\n ...data,\n id: item.id,\n _insertOnly: true,\n });\n return {\n ...base,\n status: 'applied',\n updatedAt: rowUpdatedAtIso(created),\n };\n } catch (error) {\n // A unique-constraint failure here means the id (or natural key)\n // belongs to a row this caller cannot see (e.g. another tenant's) or\n // a concurrent writer won the race. Never clobber it.\n if (isUniqueConstraintError(error)) {\n return { ...base, status: 'rejected', reason: 'id_conflict' };\n }\n throw error;\n }\n }\n\n case 'update': {\n if (!row) {\n return { ...base, status: 'rejected', reason: 'not_found' };\n }\n if (payloadMatchesRow(data, row)) {\n // Idempotent replay of an already-applied update (checked before the\n // stale guard: our own earlier apply advanced `updated_at`).\n return { ...base, status: 'applied', updatedAt: rowUpdatedAtIso(row) };\n }\n if (isStaleWrite(item.baseUpdatedAt, row.updated_at)) {\n return {\n ...base,\n status: 'conflict',\n reason: 'stale_write',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n Object.assign(row, data);\n await row.save();\n return { ...base, status: 'applied', updatedAt: rowUpdatedAtIso(row) };\n }\n\n case 'delete': {\n if (!row) {\n // Idempotent delete: already gone (or never visible) — reported\n // consistently on every replay.\n return { ...base, status: 'applied' };\n }\n if (isStaleWrite(item.baseUpdatedAt, row.updated_at)) {\n return {\n ...base,\n status: 'conflict',\n reason: 'stale_write',\n updatedAt: rowUpdatedAtIso(row),\n };\n }\n await row.delete();\n return { ...base, status: 'applied' };\n }\n }\n}\n\nasync function processOneItem(\n raw: unknown,\n host: SyncApplyHost,\n): Promise<SyncApplyItemResult> {\n const validated = validateSyncApplyItem(raw);\n if (!validated.ok) {\n return validated.result;\n }\n\n const item = validated.item;\n try {\n const target = await host.resolveTarget(item.object);\n if (!target) {\n return {\n itemId: item.itemId,\n id: item.id,\n status: 'rejected',\n reason: 'unknown_object',\n };\n }\n return await applyValidatedItem(item, target);\n } catch {\n // One item's failure must never fail the batch.\n return {\n itemId: item.itemId,\n id: item.id,\n status: 'rejected',\n reason: 'write_failed',\n };\n }\n}\n\n/**\n * Process a sync-apply batch: validate the envelope, then apply items FIFO\n * (strictly in order, awaiting each — items may depend on earlier items in\n * the same batch). Always resolves; item-level failures surface as `rejected`\n * results and only envelope-level problems produce a 400.\n */\nexport async function processSyncApplyBatch(\n body: unknown,\n host: SyncApplyHost,\n): Promise<SyncApplyOutcome> {\n const parsed = parseSyncApplyBatch(body);\n if ('error' in parsed) {\n return { status: 400, body: parsed };\n }\n\n const results: SyncApplyItemResult[] = [];\n for (const raw of parsed.items) {\n // Sequential on purpose: FIFO within the batch is part of the contract.\n results.push(await processOneItem(raw, host));\n }\n\n return { status: 200, body: { results } };\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"sveltekit-generator.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sveltekit-generator.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAOvE,OAAO,KAAK,EAEV,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,iFAAiF;IACjF,SAAS,CAAC,EAAE,qBAAqB,CAAC;CACnC;AAydD;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKtD;AAiUD;;GAEG;AACH,wBAAsB,uBAAuB,CAC3C,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,IAAI,CAAC,CAkDf;AAorBD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,qBAAqB,GAC/B,GAAG,CAAC,MAAM,CAAC,CAwCb;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,mBAAmB,GAC5B,wBAAwB,EAAE,CA+B5B;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,mBAAmB,GAC5B,IAAI,CAkBN"}
1
+ {"version":3,"file":"sveltekit-generator.d.ts","sourceRoot":"","sources":["../../src/vite-plugin/sveltekit-generator.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAWH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAQvE,OAAO,KAAK,EAEV,qBAAqB,EACrB,mBAAmB,EACpB,MAAM,kBAAkB,CAAC;AAG1B,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,iFAAiF;IACjF,SAAS,CAAC,EAAE,qBAAqB,CAAC;CACnC;AAydD;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKtD;AAiUD;;GAEG;AACH,wBAAsB,uBAAuB,CAC3C,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,gBAAgB,GACxB,OAAO,CAAC,IAAI,CAAC,CAqDf;AAorBD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,SAAS,EAAE,qBAAqB,GAC/B,GAAG,CAAC,MAAM,CAAC,CAwCb;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,EAAE,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAC3C,QAAQ,EAAE,mBAAmB,GAC5B,wBAAwB,EAAE,CA+B5B;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,QAAQ,EAAE,mBAAmB,GAC5B,IAAI,CAkBN"}
@@ -1,3 +1,5 @@
1
+ import { generateConditionalGetRouteHelper } from "../generators/conditional-get.js";
2
+ import { generateSyncApplyRoute } from "./sync-apply-route.js";
1
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
4
  import { join, relative } from "node:path";
3
5
  //#region src/vite-plugin/sveltekit-generator.ts
@@ -476,6 +478,7 @@ async function generateSvelteKitRoutes(projectRoot, manifest, options) {
476
478
  generatedCount++;
477
479
  }
478
480
  if (options.knowledge?.api?.enabled) generateKnowledgeRoute(projectRoot, options);
481
+ generateSyncApplyRoute(projectRoot, manifest, options);
479
482
  updateGitignore(projectRoot, options);
480
483
  const skippedMsg = skippedCollections > 0 ? ` (skipped ${skippedCollections} collection classes)` : "";
481
484
  console.log(`[smrt] Generated routes for ${generatedCount} SMRT objects${skippedMsg}`);
@@ -985,14 +988,17 @@ function generateCollectionRouteTemplate(projectRoot, className, objectDef, incl
985
988
  const imports = `${AUTO_GENERATED_ROUTE_HEADER}
986
989
  // DO NOT EDIT - changes will be overwritten
987
990
 
988
- import { error, json } from '@sveltejs/kit';
991
+ import { error${hasPost ? ", json" : ""} } from '@sveltejs/kit';
989
992
  ${serializerImports ? `${serializerImports}\n` : ""}import { getCollection } from '$lib/server/smrt';
990
993
  ${modelType.importStatement ? `${modelType.importStatement}\n` : ""}import type { RequestHandler } from './$types';
991
994
  // Note: ${className} is auto-registered by the Vite plugin scanner
992
- ${generateAuthGuardHelper(objectDef)}${isTenantScoped(objectDef) ? generateTenantContextHelper() : ""}${hasPost ? generateWritablePolicyHelper(objectDef) : ""}`;
995
+ ${generateAuthGuardHelper(objectDef)}${isTenantScoped(objectDef) ? generateTenantContextHelper() : ""}${hasPost ? generateWritablePolicyHelper(objectDef) : ""}${hasGet ? generateConditionalGetRouteHelper(objectDef.decoratorConfig?.api, {
996
+ tenantScoped: isTenantScoped(objectDef),
997
+ modelName: className
998
+ }) : ""}`;
993
999
  const getHandler = hasGet ? `
994
1000
  // List all ${className.toLowerCase()}s
995
- export const GET: RequestHandler = async ({ locals, url }) => {
1001
+ export const GET: RequestHandler = async ({ locals, url, request }) => {
996
1002
  ${routeGuardPreamble(objectDef, false)}
997
1003
  const limit = Number(url.searchParams.get('limit')) || 50;
998
1004
  const offset = Number(url.searchParams.get('offset')) || 0;
@@ -1005,9 +1011,9 @@ ${serializers.listItemSerializerName ? `
1005
1011
  items.map((item) => ${serializers.listItemSerializerName}(item)),
1006
1012
  );
1007
1013
 
1008
- return json({ items: serializedItems, count, limit, offset });` : `
1014
+ return conditionalJson(request, { items: serializedItems, count, limit, offset });` : `
1009
1015
  const items_public = items.map((item) => item.toPublicJSON());
1010
- return json({ items: items_public, count, limit, offset });`}
1016
+ return conditionalJson(request, { items: items_public, count, limit, offset });`}
1011
1017
  };
1012
1018
  ` : "";
1013
1019
  const postHandler = hasPost ? `
@@ -1079,13 +1085,16 @@ function generateItemRouteTemplate(projectRoot, className, objectDef, includedAc
1079
1085
  const imports = `${AUTO_GENERATED_ROUTE_HEADER}
1080
1086
  // DO NOT EDIT - changes will be overwritten
1081
1087
 
1082
- import { error, json } from '@sveltejs/kit';
1088
+ import { error${hasPut || hasDelete ? ", json" : ""} } from '@sveltejs/kit';
1083
1089
  ${serializerImports ? `${serializerImports}\n` : ""}import { getCollection } from '$lib/server/smrt';
1084
1090
  ${modelType.importStatement ? `${modelType.importStatement}\n` : ""}import type { RequestHandler } from './$types';
1085
- ${generateAuthGuardHelper(objectDef)}${isTenantScoped(objectDef) ? generateTenantContextHelper() : ""}${hasPut ? generateWritablePolicyHelper(objectDef) : ""}`;
1091
+ ${generateAuthGuardHelper(objectDef)}${isTenantScoped(objectDef) ? generateTenantContextHelper() : ""}${hasPut ? generateWritablePolicyHelper(objectDef) : ""}${hasGet ? generateConditionalGetRouteHelper(objectDef.decoratorConfig?.api, {
1092
+ tenantScoped: isTenantScoped(objectDef),
1093
+ modelName: className
1094
+ }) : ""}`;
1086
1095
  const getHandler = hasGet ? `
1087
1096
  // Get single ${simpleClassName.toLowerCase()}
1088
- export const GET: RequestHandler = async ({ locals, params }) => {
1097
+ export const GET: RequestHandler = async ({ locals, params, request }) => {
1089
1098
  ${routeGuardPreamble(objectDef, false)}
1090
1099
  ${generateCollectionLoad(className, { typeName: modelType.typeName })}
1091
1100
  const item = await collection.get(params.id);
@@ -1093,8 +1102,8 @@ ${generateNotFoundError(className)}
1093
1102
  ${serializers.itemSerializerName ? `
1094
1103
  const serializedItem = await ${serializers.itemSerializerName}(item);
1095
1104
 
1096
- return json(serializedItem);` : `
1097
- return json(item.toPublicJSON());`}
1105
+ return conditionalJson(request, serializedItem);` : `
1106
+ return conditionalJson(request, item.toPublicJSON());`}
1098
1107
  };
1099
1108
  ` : "";
1100
1109
  const putHandler = hasPut ? `