@aotter/mantle-runtime 0.1.2-alpha.1 → 0.1.2-alpha.3

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 (92) hide show
  1. package/dist/MantleRuntime.js +2 -2
  2. package/dist/MantleRuntime.js.map +1 -1
  3. package/dist/domain/model/EntryRow.d.ts +7 -16
  4. package/dist/domain/model/EntryRow.d.ts.map +1 -1
  5. package/dist/domain/model/EntryRow.js +15 -0
  6. package/dist/domain/model/EntryRow.js.map +1 -1
  7. package/dist/domain/port/DatabaseDriver.d.ts +2 -2
  8. package/dist/domain/port/DatabaseDriver.d.ts.map +1 -1
  9. package/dist/domain/port/EntryReader.d.ts +5 -4
  10. package/dist/domain/port/EntryReader.d.ts.map +1 -1
  11. package/dist/domain/port/EntryRepository.d.ts +8 -6
  12. package/dist/domain/port/EntryRepository.d.ts.map +1 -1
  13. package/dist/domain/port/index.d.ts +1 -1
  14. package/dist/domain/port/index.d.ts.map +1 -1
  15. package/dist/domain/port/index.js.map +1 -1
  16. package/dist/index.d.ts +5 -3
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +4 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/infrastructure/boot/SqliteMigrationRunner.d.ts +1 -0
  21. package/dist/infrastructure/boot/SqliteMigrationRunner.d.ts.map +1 -1
  22. package/dist/infrastructure/boot/SqliteMigrationRunner.js +71 -12
  23. package/dist/infrastructure/boot/SqliteMigrationRunner.js.map +1 -1
  24. package/dist/infrastructure/boot/bootState.d.ts +2 -0
  25. package/dist/infrastructure/boot/bootState.d.ts.map +1 -1
  26. package/dist/infrastructure/boot/bootState.js +3 -1
  27. package/dist/infrastructure/boot/bootState.js.map +1 -1
  28. package/dist/infrastructure/boot/canonicalMigrations.d.ts +4 -17
  29. package/dist/infrastructure/boot/canonicalMigrations.d.ts.map +1 -1
  30. package/dist/infrastructure/boot/canonicalMigrations.js +14 -207
  31. package/dist/infrastructure/boot/canonicalMigrations.js.map +1 -1
  32. package/dist/infrastructure/boot/index.d.ts +2 -2
  33. package/dist/infrastructure/boot/index.d.ts.map +1 -1
  34. package/dist/infrastructure/boot/index.js +2 -2
  35. package/dist/infrastructure/boot/index.js.map +1 -1
  36. package/dist/infrastructure/mcp/McpJsonRpcDispatcher.d.ts.map +1 -1
  37. package/dist/infrastructure/mcp/McpJsonRpcDispatcher.js +22 -24
  38. package/dist/infrastructure/mcp/McpJsonRpcDispatcher.js.map +1 -1
  39. package/dist/infrastructure/mcp/McpToolCatalog.js +8 -8
  40. package/dist/infrastructure/mcp/McpToolCatalog.js.map +1 -1
  41. package/dist/infrastructure/persistence/DatabaseEntryRepository.d.ts +14 -21
  42. package/dist/infrastructure/persistence/DatabaseEntryRepository.d.ts.map +1 -1
  43. package/dist/infrastructure/persistence/DatabaseEntryRepository.js +262 -423
  44. package/dist/infrastructure/persistence/DatabaseEntryRepository.js.map +1 -1
  45. package/dist/infrastructure/persistence/LifecycleHookingEntryRepository.d.ts +2 -2
  46. package/dist/infrastructure/persistence/LifecycleHookingEntryRepository.d.ts.map +1 -1
  47. package/dist/infrastructure/persistence/LifecycleHookingEntryRepository.js +5 -5
  48. package/dist/infrastructure/persistence/LifecycleHookingEntryRepository.js.map +1 -1
  49. package/dist/infrastructure/persistence/Pagination.d.ts +1 -1
  50. package/dist/infrastructure/persistence/Pagination.d.ts.map +1 -1
  51. package/dist/infrastructure/persistence/Pagination.js +1 -1
  52. package/dist/infrastructure/persistence/Pagination.js.map +1 -1
  53. package/dist/infrastructure/storage/SqliteMantleStorageAdapter.d.ts +4 -0
  54. package/dist/infrastructure/storage/SqliteMantleStorageAdapter.d.ts.map +1 -1
  55. package/dist/infrastructure/storage/SqliteMantleStorageAdapter.js +57 -6
  56. package/dist/infrastructure/storage/SqliteMantleStorageAdapter.js.map +1 -1
  57. package/dist/infrastructure/storage/SqliteMigrationArtifact.d.ts +19 -0
  58. package/dist/infrastructure/storage/SqliteMigrationArtifact.d.ts.map +1 -0
  59. package/dist/infrastructure/storage/SqliteMigrationArtifact.js +41 -0
  60. package/dist/infrastructure/storage/SqliteMigrationArtifact.js.map +1 -0
  61. package/dist/infrastructure/storage/SqliteSchemaTables.d.ts +23 -0
  62. package/dist/infrastructure/storage/SqliteSchemaTables.d.ts.map +1 -0
  63. package/dist/infrastructure/storage/SqliteSchemaTables.js +244 -0
  64. package/dist/infrastructure/storage/SqliteSchemaTables.js.map +1 -0
  65. package/dist/infrastructure/storage/SqliteViewCompiler.d.ts +3 -5
  66. package/dist/infrastructure/storage/SqliteViewCompiler.d.ts.map +1 -1
  67. package/dist/infrastructure/storage/SqliteViewCompiler.js +20 -51
  68. package/dist/infrastructure/storage/SqliteViewCompiler.js.map +1 -1
  69. package/dist/infrastructure/testing/IndexCoverageHarness.d.ts.map +1 -1
  70. package/dist/infrastructure/testing/IndexCoverageHarness.js +19 -31
  71. package/dist/infrastructure/testing/IndexCoverageHarness.js.map +1 -1
  72. package/dist/infrastructure/testing/StorageConformance.js +21 -20
  73. package/dist/infrastructure/testing/StorageConformance.js.map +1 -1
  74. package/dist/usecase/content/ArchiveUseCase.js +4 -4
  75. package/dist/usecase/content/ArchiveUseCase.js.map +1 -1
  76. package/dist/usecase/content/DeleteEntryUseCase.js +4 -4
  77. package/dist/usecase/content/DeleteEntryUseCase.js.map +1 -1
  78. package/dist/usecase/content/GetEntryUseCase.d.ts +1 -2
  79. package/dist/usecase/content/GetEntryUseCase.d.ts.map +1 -1
  80. package/dist/usecase/content/GetEntryUseCase.js +2 -6
  81. package/dist/usecase/content/GetEntryUseCase.js.map +1 -1
  82. package/dist/usecase/content/RequestPublishUseCase.js +4 -4
  83. package/dist/usecase/content/RequestPublishUseCase.js.map +1 -1
  84. package/dist/usecase/content/UnpublishUseCase.js +4 -4
  85. package/dist/usecase/content/UnpublishUseCase.js.map +1 -1
  86. package/dist/usecase/content/UpdateDraftUseCase.js +4 -4
  87. package/dist/usecase/content/UpdateDraftUseCase.js.map +1 -1
  88. package/dist/usecase/dto/content/index.d.ts +6 -5
  89. package/dist/usecase/dto/content/index.d.ts.map +1 -1
  90. package/dist/usecase/procedure/InvokeBuiltinUseCase.js +6 -6
  91. package/dist/usecase/procedure/InvokeBuiltinUseCase.js.map +1 -1
  92. package/package.json +2 -2
@@ -1,18 +1,9 @@
1
- import { schemaIndexedFieldSql, checkSchemaAdminUi, } from "@aotter/mantle-spec";
2
- import { clampLimit } from "../../domain/service/Pagination.js";
1
+ import { checkSchemaAdminUi, } from "@aotter/mantle-spec";
3
2
  import { EntryStatusConflict, EntryUniqueConflict, EntryVersionConflict, liftLocale, projectPublicEntry, } from "../../domain/model/EntryRow.js";
4
- import { decodeEntryCursor, encodeEntryCursor, publishedPageLimit, PUBLISHED_PAGE_DATA_BUDGET, decodeEntrySortCursor, encodeEntrySortCursor, escapeLikeTerm, } from "./Pagination.js";
5
- /**
6
- * `EntryRepository` impl backed by `DatabaseDriver`. Adapters that
7
- * implement the SQLite-shaped `DatabaseDriver` contract get this
8
- * repository for free.
9
- *
10
- * `UPDATE … RETURNING` collapses the post-write SELECT to one round
11
- * trip on SQLite ≥ 3.35 / Postgres.
12
- *
13
- * Lifts `data.locale` to `EntryRow.locale` at the rowFromDb boundary
14
- * — see ADR-0010 + `domain/model/EntryRow.ts`.
15
- */
3
+ import { clampLimit } from "../../domain/service/Pagination.js";
4
+ import { decodeEntrySortCursor, encodeEntrySortCursor, escapeLikeTerm, PUBLISHED_PAGE_DATA_BUDGET, publishedPageLimit, } from "./Pagination.js";
5
+ import { decodeField, encodeField, fieldColumn, fieldSql, isNullableJsonSchema, sqliteSchemaTable, } from "../storage/SqliteSchemaTables.js";
6
+ /** SQLite/D1 repository where each Schema is one physical table. */
16
7
  export class DatabaseEntryRepository {
17
8
  db;
18
9
  schemasByName;
@@ -21,42 +12,39 @@ export class DatabaseEntryRepository {
21
12
  this.schemasByName = schemasByName;
22
13
  }
23
14
  async readCreationStatistics(args) {
24
- const { collection, from, to, bucketMs } = args;
15
+ const { from, to, bucketMs } = args;
25
16
  if (![from, to, bucketMs].every(Number.isSafeInteger) || from < 0 || to <= from ||
26
17
  bucketMs <= 0 || to - from > 20 * 86_400_000 || Math.ceil((to - from) / bucketMs) > 480) {
27
18
  throw new RangeError("Statistics require a positive window <= 20 days and <= 480 buckets.");
28
19
  }
29
- const schema = this.schemasByName.get(collection);
30
- const filter = schema ? checkSchemaAdminUi(schema).filter : null;
31
- const field = filter && schema ? schemaIndexedFieldSql(schema, filter.field) : null;
32
- // Group unrecognized values together in SQL: corrupt/legacy rows cannot make
33
- // the result unbounded. All identifiers come from the prepared schema.
34
- const subtype = field
35
- ? `CASE WHEN ${field} IN (SELECT value FROM json_each(?)) THEN ${field} ELSE NULL END`
20
+ const table = this.table(args.collection);
21
+ const filter = checkSchemaAdminUi(table.schema).filter;
22
+ const subtypeField = filter ? requiredFieldSql(table.schema, filter.field) : null;
23
+ const subtype = filter && subtypeField
24
+ ? `CASE WHEN ${subtypeField} IN (${filter.values.map(() => "?").join(", ")}) THEN ${subtypeField} ELSE NULL END`
36
25
  : "NULL";
37
- const rows = await this.db.prepare(`
38
- SELECT -1 AS bucket, NULL AS subtype, COUNT(*) AS count FROM entries WHERE collection = ?
39
- UNION ALL
40
- SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, ${subtype} AS subtype, COUNT(*) AS count
41
- FROM entries WHERE collection = ? AND created_at >= ? AND created_at < ?
42
- GROUP BY bucket, subtype
43
- `).bind(collection, from, bucketMs, ...(field ? [JSON.stringify(filter.values)] : []), collection, from, to)
26
+ const total = await this.db.prepare(`SELECT COUNT(*) AS count FROM ${table.table}`)
27
+ .first();
28
+ const rows = await this.db.prepare(`SELECT CAST(("_mantle_created_at" - ?) / ? AS INTEGER) AS bucket,
29
+ ${subtype} AS subtype, COUNT(*) AS count FROM ${table.table}
30
+ WHERE "_mantle_created_at" >= ? AND "_mantle_created_at" < ? GROUP BY bucket, subtype`)
31
+ .bind(from, bucketMs, ...(filter ? filter.values : []), from, to)
44
32
  .all();
45
- return { total: rows[0].count, buckets: rows.slice(1) };
33
+ return { total: total?.count ?? 0, buckets: rows };
46
34
  }
47
35
  async create(args) {
36
+ const table = this.table(args.collection);
37
+ const columns = ["_mantle_id", "_mantle_status", "_mantle_version", "_mantle_author_id", "_mantle_created_at", "_mantle_updated_at", ...table.fields];
38
+ const values = [args.id, args.status, 1, args.authorId, args.now, args.now, ...this.encodedData(table, args.data)];
48
39
  try {
49
- await this.db
50
- .prepare(`INSERT INTO entries (id, collection, status, version, data, author_id, created_at, updated_at)
51
- VALUES (?, ?, ?, 1, ?, ?, ?, ?)`)
52
- .bind(args.id, args.collection, args.status, JSON.stringify(args.data), args.authorId, args.now, args.now)
53
- .run();
40
+ await this.db.prepare(`INSERT INTO ${table.table} (${columns.map(quote).join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`)
41
+ .bind(...values).run();
54
42
  }
55
- catch (err) {
56
- if (isDriverUniqueConstraintError(err)) {
57
- throw new EntryUniqueConflict(args.collection, args.data, err.message);
43
+ catch (error) {
44
+ if (isDriverUniqueConstraintError(error)) {
45
+ throw new EntryUniqueConflict(args.collection, args.data, error.message);
58
46
  }
59
- throw err;
47
+ throw error;
60
48
  }
61
49
  return {
62
50
  id: args.id,
@@ -70,497 +58,348 @@ export class DatabaseEntryRepository {
70
58
  updatedAt: args.now,
71
59
  };
72
60
  }
73
- async get(id) {
74
- const row = await this.db
75
- .prepare(`SELECT id, collection, status, version, data, author_id, created_at, updated_at
76
- FROM entries WHERE id = ?`)
77
- .bind(id)
78
- .first();
79
- return row ? rowFromDb(row) : null;
61
+ async get(args) {
62
+ const table = this.table(args.collection);
63
+ const row = await this.db.prepare(`SELECT ${table.selectColumns} FROM ${table.table} WHERE "_mantle_id" = ?`)
64
+ .bind(args.id).first();
65
+ return row ? rowFromDb(table, row) : null;
80
66
  }
81
67
  async update(args) {
82
- const newVersion = args.expectedVersion + 1;
68
+ const table = this.table(args.collection);
69
+ const version = args.expectedVersion + 1;
70
+ const assignments = [...table.fields.map((field) => `${quote(field)} = ?`), '"_mantle_version" = ?', '"_mantle_updated_at" = ?'];
83
71
  let row = null;
84
72
  try {
85
- row = await this.db
86
- .prepare(`UPDATE entries SET data = ?, version = ?, updated_at = ?
87
- WHERE id = ? AND version = ?
88
- RETURNING id, collection, status, version, data, author_id, created_at, updated_at`)
89
- .bind(JSON.stringify(args.data), newVersion, args.now, args.id, args.expectedVersion)
73
+ row = await this.db.prepare(`UPDATE ${table.table} SET ${assignments.join(", ")}
74
+ WHERE "_mantle_id" = ? AND "_mantle_version" = ? RETURNING ${table.selectColumns}`)
75
+ .bind(...this.encodedData(table, args.data), version, args.now, args.id, args.expectedVersion)
90
76
  .first();
91
77
  }
92
- catch (err) {
93
- if (isDriverUniqueConstraintError(err)) {
94
- throw new EntryUniqueConflict(args.collection, args.data, err.message);
78
+ catch (error) {
79
+ if (isDriverUniqueConstraintError(error)) {
80
+ throw new EntryUniqueConflict(args.collection, args.data, error.message);
95
81
  }
96
- throw err;
82
+ throw error;
97
83
  }
98
84
  if (!row)
99
- throw await this.versionConflict(args.id, args.expectedVersion);
100
- return rowFromDb(row);
85
+ throw await this.versionConflict(table, args.id, args.expectedVersion);
86
+ return rowFromDb(table, row);
101
87
  }
102
88
  async delete(args) {
103
- const parentMatches = `id = ? AND collection = ? AND status = ? AND version = ?`;
104
- const parentSnapshot = [
105
- args.id,
106
- args.collection,
107
- args.expectedStatus,
108
- args.expectedVersion,
109
- ];
110
- const result = await this.db
111
- .prepare(`DELETE FROM entries WHERE ${parentMatches}`)
112
- .bind(...parentSnapshot)
113
- .run();
89
+ const table = this.table(args.collection);
90
+ const result = await this.db.prepare(`DELETE FROM ${table.table} WHERE "_mantle_id" = ? AND "_mantle_status" = ? AND "_mantle_version" = ?`)
91
+ .bind(args.id, args.expectedStatus, args.expectedVersion).run();
114
92
  if (result.meta.changes > 0)
115
93
  return { removed: true };
116
- const after = await this.db
117
- .prepare(`SELECT collection, status, version FROM entries WHERE id = ?`)
118
- .bind(args.id)
119
- .first();
120
- if (!after || after.collection !== args.collection)
94
+ const after = await this.db.prepare(`SELECT "_mantle_status" AS "status", "_mantle_version" AS "version" FROM ${table.table} WHERE "_mantle_id" = ?`)
95
+ .bind(args.id).first();
96
+ if (!after)
121
97
  return { removed: false };
122
- if (after.version !== args.expectedVersion) {
98
+ if (after.version !== args.expectedVersion)
123
99
  throw new EntryVersionConflict(args.id, args.expectedVersion, after.version);
124
- }
125
100
  throw new EntryStatusConflict(args.id, args.expectedStatus, after.status);
126
101
  }
127
102
  async transitionStatus(args) {
128
- const { expectedStatus, expectedVersion } = args;
129
- const guards = [];
103
+ const table = this.table(args.collection);
104
+ const conditions = ['"_mantle_id" = ?'];
130
105
  const binds = [args.to, args.now, args.id];
131
- if (expectedStatus !== undefined) {
132
- guards.push(" AND status = ?");
133
- binds.push(expectedStatus);
106
+ if (args.expectedStatus !== undefined) {
107
+ conditions.push('"_mantle_status" = ?');
108
+ binds.push(args.expectedStatus);
134
109
  }
135
- if (expectedVersion !== undefined) {
136
- guards.push(" AND version = ?");
137
- binds.push(expectedVersion);
110
+ if (args.expectedVersion !== undefined) {
111
+ conditions.push('"_mantle_version" = ?');
112
+ binds.push(args.expectedVersion);
138
113
  }
139
- const row = await this.db
140
- .prepare(`UPDATE entries SET status = ?, version = version + 1, updated_at = ?
141
- WHERE id = ?${guards.join("")}
142
- RETURNING id, collection, status, version, data, author_id, created_at, updated_at`)
143
- .bind(...binds)
144
- .first();
114
+ const row = await this.db.prepare(`UPDATE ${table.table} SET "_mantle_status" = ?, "_mantle_version" = "_mantle_version" + 1, "_mantle_updated_at" = ?
115
+ WHERE ${conditions.join(" AND ")} RETURNING ${table.selectColumns}`).bind(...binds).first();
145
116
  if (row)
146
- return rowFromDb(row);
147
- // Disambiguate version- vs. status-conflict from a single SELECT —
148
- // splitting into two SELECTs leaves a TOCTOU window where a third
149
- // concurrent writer between the two reads can flip which guard
150
- // appears to have failed.
151
- const after = await this.db
152
- .prepare(`SELECT version, status FROM entries WHERE id = ?`)
153
- .bind(args.id)
154
- .first();
155
- if (expectedVersion !== undefined && after && after.version !== expectedVersion) {
156
- throw new EntryVersionConflict(args.id, expectedVersion, after.version);
117
+ return rowFromDb(table, row);
118
+ const after = await this.db.prepare(`SELECT "_mantle_status" AS "status", "_mantle_version" AS "version" FROM ${table.table} WHERE "_mantle_id" = ?`)
119
+ .bind(args.id).first();
120
+ if (args.expectedVersion !== undefined && after && after.version !== args.expectedVersion) {
121
+ throw new EntryVersionConflict(args.id, args.expectedVersion, after.version);
157
122
  }
158
- throw new EntryStatusConflict(args.id, expectedStatus ?? args.to, after?.status ?? args.to);
123
+ throw new EntryStatusConflict(args.id, args.expectedStatus ?? args.to, after?.status ?? args.to);
159
124
  }
160
125
  async list(args) {
161
- // Use the shared clamp so direct repo callers (tests, future
162
- // adapters that bypass the use case) get the same default page
163
- // size as ListEntriesUseCase — not a silently different 100.
126
+ const table = this.table(args.collection);
164
127
  const limit = clampLimit(args.limit);
165
128
  const sort = args.sort ?? { field: "updatedAt", direction: "desc" };
166
- const schema = this.schemasByName.get(args.collection);
167
- const sortSql = entrySortSql(schema, sort.field);
168
- if (!sortSql)
169
- throw new Error(`unavailable entry sort field: ${sort.field}`);
129
+ const sortSql = args.sort ? requiredFieldSql(table.schema, sort.field) : '"_mantle_updated_at"';
170
130
  const cursor = decodeEntrySortCursor(args.cursor, sort.field, sort.direction);
171
131
  const backward = args.cursorDirection === "backward" && cursor !== null;
172
- const queryDirection = backward
173
- ? (sort.direction === "asc" ? "DESC" : "ASC")
174
- : sort.direction.toUpperCase();
175
- // Fetch limit+1 to detect a next page without a second query —
176
- // the extra row never reaches the caller.
177
- const probe = limit + 1;
178
- const conditions = ["collection = ?"];
179
- const binds = [args.collection];
132
+ const direction = backward ? (sort.direction === "asc" ? "DESC" : "ASC") : sort.direction.toUpperCase();
133
+ const conditions = [];
134
+ const binds = [];
180
135
  if (args.status) {
181
- conditions.push("status = ?");
136
+ conditions.push('"_mantle_status" = ?');
182
137
  binds.push(args.status);
183
138
  }
184
139
  if (args.search) {
185
140
  const term = escapeLikeTerm(args.search);
186
- const searchConditions = ["id LIKE '%'||?||'%' ESCAPE '\\'"];
141
+ const search = ['"_mantle_id" LIKE \'%\'||?||\'%\' ESCAPE \'\\\''];
187
142
  binds.push(term);
188
143
  for (const field of args.searchFields ?? []) {
189
- searchConditions.push("json_extract(data, ?) LIKE '%'||?||'%' ESCAPE '\\'");
190
- binds.push(jsonPathForTopLevelField(field), term);
144
+ search.push(`${requiredFieldSql(table.schema, field)} LIKE '%'||?||'%' ESCAPE '\\'`);
145
+ binds.push(term);
191
146
  }
192
- conditions.push(`(${searchConditions.join(" OR ")})`);
147
+ conditions.push(`(${search.join(" OR ")})`);
193
148
  }
194
149
  if (args.filter) {
195
- const compiled = compileDataPredicates(schema, [{
196
- field: args.filter.field,
197
- kind: "equal",
198
- value: args.filter.value,
199
- }]);
200
- conditions.push(...compiled.conditions);
201
- binds.push(...compiled.binds);
150
+ conditions.push(`${requiredFieldSql(table.schema, args.filter.field)} = ?`);
151
+ binds.push(encodeScalar(table.schema, args.filter.field, args.filter.value));
202
152
  }
203
153
  if (args.scope) {
204
- const compiled = compileDataPredicates(schema, [{
205
- field: args.scope.field,
206
- kind: "equal",
207
- value: args.scope.value,
208
- }]);
209
- conditions.push(...compiled.conditions);
210
- binds.push(...compiled.binds);
154
+ conditions.push(`${requiredFieldSql(table.schema, args.scope.field)} = ?`);
155
+ binds.push(encodeScalar(table.schema, args.scope.field, args.scope.value));
211
156
  }
212
157
  if (cursor) {
213
- const comparison = backward
214
- ? (sort.direction === "asc" ? "<" : ">")
215
- : (sort.direction === "asc" ? ">" : "<");
216
- conditions.push(`(${sortSql}, id) ${comparison} (?, ?)`);
158
+ const comparison = backward ? (sort.direction === "asc" ? "<" : ">") : (sort.direction === "asc" ? ">" : "<");
159
+ conditions.push(`(${sortSql}, "_mantle_id") ${comparison} (?, ?)`);
217
160
  binds.push(...cursor);
218
161
  }
219
- binds.push(probe);
220
- const stmt = this.db
221
- .prepare(`SELECT id, collection, status, version, data, author_id, created_at, updated_at
222
- FROM entries WHERE ${conditions.join(" AND ")}
223
- ORDER BY ${sortSql} ${queryDirection}, id ${queryDirection} LIMIT ?`)
224
- .bind(...binds);
225
- const rows = await stmt.all();
162
+ binds.push(limit + 1);
163
+ const rows = await this.db.prepare(`SELECT ${table.selectColumns} FROM ${table.table}
164
+ ${conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""}
165
+ ORDER BY ${sortSql} ${direction}, "_mantle_id" ${direction} LIMIT ?`).bind(...binds).all();
226
166
  const hasMore = rows.length > limit;
227
167
  const page = [...(hasMore ? rows.slice(0, limit) : rows)];
228
168
  if (backward)
229
169
  page.reverse();
230
170
  const first = page[0];
231
- const last = page[page.length - 1];
171
+ const last = page.at(-1);
232
172
  return {
233
- rows: page.map(rowFromDb),
234
- previousCursor: first && (backward ? hasMore : cursor !== null)
235
- ? encodeEntrySortCursor(sort.field, sort.direction, entrySortValueFromDb(first, sort.field), first.id)
236
- : undefined,
237
- nextCursor: last && (backward ? cursor !== null : hasMore)
238
- ? encodeEntrySortCursor(sort.field, sort.direction, entrySortValueFromDb(last, sort.field), last.id)
239
- : undefined,
173
+ rows: page.map((row) => rowFromDb(table, row)),
174
+ previousCursor: first && (backward ? hasMore : cursor !== null) ? encodeEntrySortCursor(sort.field, sort.direction, sortValue(first, table.schema, sort.field), first._mantle_id) : undefined,
175
+ nextCursor: last && (backward ? cursor !== null : hasMore) ? encodeEntrySortCursor(sort.field, sort.direction, sortValue(last, table.schema, sort.field), last._mantle_id) : undefined,
240
176
  };
241
177
  }
242
178
  async findByDataField(args) {
243
- return this.findOneByDataFields({
244
- collection: args.collection,
245
- status: args.status,
246
- fields: { [args.field]: args.value },
247
- });
179
+ return this.findOne({ ...args, fields: { [args.field]: args.value } });
248
180
  }
249
181
  async findByDataFields(args) {
250
- return this.findOneByDataFields(args);
182
+ return this.findOne(args);
251
183
  }
252
- async readById(id) {
253
- const row = await this.get(id);
184
+ async readById(args) {
185
+ const row = await this.get(args);
254
186
  return row ? projectPublicEntry(row) : null;
255
187
  }
256
188
  async readBySlug(args) {
257
- return this.readByDataField({
258
- collection: args.collection,
259
- field: "slug",
260
- value: args.slug,
261
- locale: args.locale,
262
- status: args.status,
263
- });
189
+ return this.readByDataField({ ...args, field: "slug", value: args.slug });
264
190
  }
265
191
  async readByDataField(args) {
266
- const row = await this.findOneByDataFields({
267
- collection: args.collection,
268
- status: args.status,
269
- fields: { [args.field]: args.value },
270
- locale: args.locale,
271
- });
192
+ const row = await this.findOne({ ...args, fields: { [args.field]: args.value } });
272
193
  return row ? projectPublicEntry(row) : null;
273
194
  }
274
195
  async readByDataFieldIn(args) {
275
196
  const values = [...new Set(args.values)];
276
- if (values.length === 0)
197
+ if (!values.length)
277
198
  return [];
278
- const entries = [];
279
- for (let start = 0; start < values.length; start += ENTRY_READ_BATCH_SIZE) {
280
- const chunk = values.slice(start, start + ENTRY_READ_BATCH_SIZE);
281
- const conditions = ["collection = ?"];
282
- const binds = [args.collection];
283
- const schema = this.schemasByName.get(args.collection);
284
- const compiled = compileDataPredicates(schema, [
285
- { field: args.field, kind: "in", values: chunk },
286
- ...localePredicates(args.locale),
287
- ]);
288
- conditions.push(...compiled.conditions);
289
- binds.push(...compiled.binds);
290
- if (args.status) {
291
- conditions.push("status = ?");
292
- binds.push(args.status);
293
- }
199
+ const table = this.table(args.collection);
200
+ const field = requiredFieldSql(table.schema, args.field);
201
+ const output = [];
202
+ for (let start = 0; start < values.length; start += 95) {
203
+ const chunk = values.slice(start, start + 95);
204
+ const conditions = [`${field} IN (${chunk.map(() => "?").join(", ")})`];
205
+ const binds = chunk.map((value) => encodeScalar(table.schema, args.field, value));
206
+ this.addReadConditions(table, conditions, binds, args);
294
207
  const sql = args.latestPerValue
295
- ? `SELECT ${ENTRY_COLUMNS} FROM entries WHERE id IN (
296
- SELECT id FROM (
297
- SELECT id, ROW_NUMBER() OVER (PARTITION BY json_extract(data, ?) ORDER BY updated_at DESC, id DESC) AS parent_rank
298
- FROM entries WHERE ${conditions.join(" AND ")}
299
- ) WHERE parent_rank = 1
300
- ) ORDER BY updated_at DESC, id DESC`
301
- : `SELECT ${ENTRY_COLUMNS} FROM entries WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC`;
302
- if (args.latestPerValue)
303
- binds.unshift(jsonPathForTopLevelField(args.field));
208
+ ? `SELECT ${table.selectColumns} FROM ${table.table} WHERE "_mantle_id" IN (SELECT "_mantle_id" FROM (
209
+ SELECT "_mantle_id", ROW_NUMBER() OVER (PARTITION BY ${field} ORDER BY "_mantle_updated_at" DESC, "_mantle_id" DESC) AS rank
210
+ FROM ${table.table} WHERE ${conditions.join(" AND ")}) WHERE rank = 1) ORDER BY "_mantle_updated_at" DESC, "_mantle_id" DESC`
211
+ : `SELECT ${table.selectColumns} FROM ${table.table} WHERE ${conditions.join(" AND ")} ORDER BY "_mantle_updated_at" DESC`;
304
212
  const rows = await this.db.prepare(sql).bind(...binds).all();
305
- entries.push(...rows.map(rowFromDb).map(projectPublicEntry));
213
+ output.push(...rows.map((row) => projectPublicEntry(rowFromDb(table, row))));
306
214
  }
307
- entries.sort((a, b) => b.updatedAt - a.updatedAt);
308
- return entries;
215
+ return output.sort((a, b) => b.updatedAt - a.updatedAt);
309
216
  }
310
- async readPublished(args = {}) {
311
- const conditions = ["status = 'published'"];
217
+ async readPublished(args) {
218
+ const table = this.table(args.collection);
219
+ const conditions = ['"_mantle_status" = \'published\''];
312
220
  const binds = [];
313
- if (args.locale === null) {
314
- conditions.push("entry_locale IS NULL");
315
- }
316
- else if (args.locale !== undefined) {
317
- conditions.push("entry_locale = ?");
221
+ this.addLocaleCondition(table, conditions, binds, args.locale);
222
+ const limit = typeof args.limit === "number" && Number.isFinite(args.limit) && args.limit > 0 ? ` LIMIT ${Math.floor(args.limit)}` : "";
223
+ const rows = await this.db.prepare(`SELECT ${table.selectColumns} FROM ${table.table} WHERE ${conditions.join(" AND ")} ORDER BY "_mantle_updated_at" DESC${limit}`)
224
+ .bind(...binds).all();
225
+ return rows.map((row) => projectPublicEntry(rowFromDb(table, row)));
226
+ }
227
+ async readPublishedPage(args) {
228
+ const table = this.table(args.collection);
229
+ const limit = publishedPageLimit(args.limit);
230
+ const fields = args.dataFields
231
+ ? [...new Set([...args.dataFields.filter((field) => table.fields.includes(field)), ...(table.fields.includes("locale") ? ["locale"] : [])])]
232
+ : table.fields;
233
+ const columns = ['"_mantle_id"', '"_mantle_status"', '"_mantle_version"', '"_mantle_author_id"', '"_mantle_created_at"', '"_mantle_updated_at"', ...fields.map(quote)];
234
+ const conditions = ['"_mantle_status" = \'published\''];
235
+ const binds = [];
236
+ if (args.includeUnlocalized && typeof args.locale === "string") {
237
+ const locale = requiredFieldSql(table.schema, "locale");
238
+ conditions.push(`(${locale} = ? OR ${locale} IS NULL)`);
318
239
  binds.push(args.locale);
319
240
  }
320
- if (args.collection) {
321
- conditions.push("collection = ?");
322
- binds.push(args.collection);
241
+ else {
242
+ this.addLocaleCondition(table, conditions, binds, args.locale);
323
243
  }
324
- let sql = `SELECT ${ENTRY_COLUMNS} FROM entries ` +
325
- `WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC`;
326
- if (typeof args.limit === "number" &&
327
- Number.isFinite(args.limit) &&
328
- args.limit > 0) {
329
- sql += ` LIMIT ${Math.floor(args.limit)}`;
244
+ const cursor = decodeEntrySortCursor(args.cursor, "updatedAt", "desc");
245
+ if (cursor) {
246
+ conditions.push('("_mantle_updated_at", "_mantle_id") < (?, ?)');
247
+ binds.push(...cursor);
330
248
  }
331
- const rows = await this.db.prepare(sql).bind(...binds).all();
332
- return rows.map(rowFromDb).map(projectPublicEntry);
333
- }
334
- async readPublishedPage(args = {}) {
335
- const limit = publishedPageLimit(args.limit);
336
- const cursor = decodeEntryCursor(args.cursor);
337
- const binds = [];
338
- const candidateQuery = (locale) => {
339
- const conditions = ["status = 'published'"];
340
- // One bound JSON field list avoids D1's bind/function-argument ceilings.
341
- // -> retains JSON types (json_extract would turn true/false into 1/0).
342
- const data = args.dataFields
343
- ? `(SELECT json_group_object(json_extract(field.value, '$[0]'),
344
- json(entries.data -> json_extract(field.value, '$[1]')))
345
- FROM json_each(?) AS field)` : "data";
346
- if (args.dataFields)
347
- binds.push(JSON.stringify(args.dataFields.map((field) => [field, jsonPathForTopLevelField(field)])));
348
- if (args.collection !== undefined) {
349
- conditions.push("collection = ?");
350
- binds.push(args.collection);
351
- }
352
- if (locale === null)
353
- conditions.push("entry_locale IS NULL");
354
- else if (locale !== undefined) {
355
- conditions.push("entry_locale = ?");
356
- binds.push(locale);
357
- }
358
- if (cursor) {
359
- conditions.push("(updated_at, id) < (?, ?)");
360
- binds.push(...cursor);
361
- }
362
- return `SELECT id, collection, status, version, ${data} AS data, author_id,
363
- created_at, updated_at, entry_locale FROM entries
364
- WHERE ${conditions.join(" AND ")} ORDER BY updated_at DESC, id DESC LIMIT ${limit + 1}`;
365
- };
366
- const query = candidateQuery(args.locale);
367
- const candidates = args.includeUnlocalized && typeof args.locale === "string"
368
- ? `SELECT * FROM (${query}) UNION ALL SELECT * FROM (${candidateQuery(null)})`
369
- : query;
370
- // Window work is bounded to limit + 1 indexed candidates. Apply the byte
371
- // budget inside SQLite, before transferring/parsing JSON in the Worker.
249
+ const rowBytes = fields.length
250
+ ? fields.map((field) => `length(CAST(json_quote(${quote(field)}) AS BLOB)) + ${new TextEncoder().encode(field).byteLength + 3}`).join(" + ")
251
+ : "2";
372
252
  const rows = await this.db.prepare(`WITH candidates AS (
373
- SELECT * FROM (${candidates}) ORDER BY updated_at DESC, id DESC LIMIT ${limit + 1}
253
+ SELECT ${columns.join(", ")} FROM ${table.table} WHERE ${conditions.join(" AND ")}
254
+ ORDER BY "_mantle_updated_at" DESC, "_mantle_id" DESC LIMIT ${limit + 1}
374
255
  ), budget AS (
375
- SELECT *, LEAD(id) OVER (ORDER BY updated_at DESC, id DESC) AS next_id,
376
- SUM(length(CAST(data AS BLOB))) OVER (ORDER BY updated_at DESC, id DESC) AS data_bytes
377
- FROM candidates
378
- ) SELECT * FROM budget WHERE data_bytes = length(CAST(data AS BLOB)) OR data_bytes <= ${PUBLISHED_PAGE_DATA_BUDGET}
379
- ORDER BY updated_at DESC, id DESC`).bind(...binds)
380
- .all();
256
+ SELECT *, (${rowBytes}) AS "_row_bytes", LEAD("_mantle_id") OVER (ORDER BY "_mantle_updated_at" DESC, "_mantle_id" DESC) AS "_next_id",
257
+ SUM(${rowBytes}) OVER (ORDER BY "_mantle_updated_at" DESC, "_mantle_id" DESC) AS "_data_bytes" FROM candidates
258
+ ) SELECT ${columns.join(", ")}, "_next_id" FROM budget
259
+ WHERE "_data_bytes" = "_row_bytes" OR "_data_bytes" <= ${PUBLISHED_PAGE_DATA_BUDGET}
260
+ ORDER BY "_mantle_updated_at" DESC, "_mantle_id" DESC`)
261
+ .bind(...binds).all();
381
262
  const page = rows.slice(0, limit);
382
263
  const last = page.at(-1);
383
264
  return {
384
- rows: page.map(rowFromDb).map(projectPublicEntry),
385
- ...(last?.next_id != null
386
- ? { nextCursor: encodeEntryCursor(last.updated_at, last.id) } : {}),
265
+ rows: page.map((row) => projectPublicEntry(rowFromDb(table, row, args.dataFields))),
266
+ ...(last?._next_id != null ? { nextCursor: encodeEntrySortCursor("updatedAt", "desc", last._mantle_updated_at, last._mantle_id) } : {}),
387
267
  };
388
268
  }
389
269
  async findManyByDataField(args) {
390
- const conditions = ["collection = ?"];
391
- const binds = [args.collection];
392
- const compiled = compileDataPredicates(this.schemasByName.get(args.collection), [{ field: args.field, kind: "equal", value: args.value }]);
393
- conditions.push(...compiled.conditions);
394
- binds.push(...compiled.binds);
395
- const limit = Number.isFinite(args.limit) && args.limit > 0
396
- ? Math.floor(args.limit)
397
- : 1;
398
- const rows = await this.db
399
- .prepare(`SELECT ${ENTRY_COLUMNS} FROM entries
400
- WHERE ${conditions.join(" AND ")}
401
- ORDER BY updated_at DESC, id DESC LIMIT ${limit}`)
402
- .bind(...binds)
403
- .all();
404
- return rows.map(rowFromDb).map(projectPublicEntry);
270
+ const table = this.table(args.collection);
271
+ const field = requiredFieldSql(table.schema, args.field);
272
+ const limit = Number.isFinite(args.limit) && args.limit > 0 ? Math.floor(args.limit) : 1;
273
+ const rows = await this.db.prepare(`SELECT ${table.selectColumns} FROM ${table.table} WHERE ${field} = ? ORDER BY "_mantle_updated_at" DESC, "_mantle_id" DESC LIMIT ${limit}`)
274
+ .bind(encodeScalar(table.schema, args.field, args.value)).all();
275
+ return rows.map((row) => projectPublicEntry(rowFromDb(table, row)));
276
+ }
277
+ table(collection) {
278
+ const schema = this.schemasByName.get(collection);
279
+ if (!schema)
280
+ throw new Error(`unknown Schema table: ${collection}`);
281
+ return sqliteSchemaTable(schema);
282
+ }
283
+ encodedData(table, data) {
284
+ const properties = table.schema.spec.schema.properties ?? {};
285
+ const unknown = Object.keys(data).find((field) => !Object.hasOwn(properties, field));
286
+ if (unknown)
287
+ throw new Error(`Schema '${table.schema.metadata.name}' has no field '${unknown}'.`);
288
+ return table.fields.map((field) => encodeField(data[field], properties[field]));
405
289
  }
406
- async findOneByDataFields(args) {
407
- const entries = Object.entries(args.fields);
408
- if (entries.length === 0)
290
+ async findOne(args) {
291
+ const table = this.table(args.collection);
292
+ const conditions = [];
293
+ const binds = [];
294
+ for (const [field, value] of Object.entries(args.fields)) {
295
+ conditions.push(`${requiredFieldSql(table.schema, field)} = ?`);
296
+ binds.push(encodeScalar(table.schema, field, value));
297
+ }
298
+ if (!conditions.length)
409
299
  return null;
410
- const conditions = ["collection = ?"];
411
- const binds = [args.collection];
412
- const schema = this.schemasByName.get(args.collection);
413
300
  if (args.status) {
414
- conditions.push("status = ?");
301
+ conditions.push('"_mantle_status" = ?');
415
302
  binds.push(args.status);
416
303
  }
417
- const compiled = compileDataPredicates(schema, [
418
- ...entries.map(([field, value]) => ({
419
- field,
420
- kind: "equal",
421
- value,
422
- })),
423
- ...localePredicates(args.locale),
424
- ]);
425
- conditions.push(...compiled.conditions);
426
- binds.push(...compiled.binds);
304
+ this.addLocaleCondition(table, conditions, binds, args.locale);
427
305
  if (args.excludeId) {
428
- conditions.push("id <> ?");
306
+ conditions.push('"_mantle_id" <> ?');
429
307
  binds.push(args.excludeId);
430
308
  }
431
- const row = await this.db
432
- .prepare(`SELECT id, collection, status, version, data, author_id, created_at, updated_at
433
- FROM entries
434
- WHERE ${conditions.join(" AND ")}
435
- ORDER BY updated_at DESC LIMIT 1`)
436
- .bind(...binds)
437
- .first();
438
- return row ? rowFromDb(row) : null;
439
- }
440
- async versionConflict(id, expected) {
441
- const after = await this.db
442
- .prepare(`SELECT version FROM entries WHERE id = ?`)
443
- .bind(id)
444
- .first();
445
- return new EntryVersionConflict(id, expected, after?.version ?? -1);
309
+ const row = await this.db.prepare(`SELECT ${table.selectColumns} FROM ${table.table} WHERE ${conditions.join(" AND ")} ORDER BY "_mantle_updated_at" DESC LIMIT 1`)
310
+ .bind(...binds).first();
311
+ return row ? rowFromDb(table, row) : null;
446
312
  }
447
- }
448
- const ENTRY_COLUMNS = "id, collection, status, version, data, author_id, created_at, updated_at";
449
- // D1 accepts at most 100 bound parameters. The worst fallback shape uses
450
- // five fixed binds (collection, two JSON paths, locale, status), leaving 95
451
- // values for the parent `IN` predicate.
452
- const ENTRY_READ_BATCH_SIZE = 95;
453
- function localePredicates(locale) {
454
- if (locale === undefined)
455
- return [];
456
- return locale === null
457
- ? [{ field: "locale", kind: "null" }]
458
- : [{ field: "locale", kind: "equal", value: locale }];
459
- }
460
- function compileDataPredicates(schema, predicates) {
461
- const conditions = [];
462
- const binds = [];
463
- const indexed = usableIndexedFields(schema, predicates);
464
- for (const predicate of predicates) {
465
- const generated = schema && indexed.has(predicate.field)
466
- ? schemaIndexedFieldSql(schema, predicate.field)
467
- : null;
468
- const reference = generated ?? "json_extract(data, ?)";
469
- if (!generated)
470
- binds.push(jsonPathForTopLevelField(predicate.field));
471
- if (predicate.kind === "null") {
472
- conditions.push(`${reference} IS NULL`);
313
+ addReadConditions(table, conditions, binds, args) {
314
+ if (args.status) {
315
+ conditions.push('"_mantle_status" = ?');
316
+ binds.push(args.status);
473
317
  }
474
- else if (predicate.kind === "in") {
475
- conditions.push(`${reference} IN (${predicate.values.map(() => "?").join(", ")})`);
476
- binds.push(...predicate.values);
318
+ this.addLocaleCondition(table, conditions, binds, args.locale);
319
+ }
320
+ addLocaleCondition(table, conditions, binds, locale) {
321
+ if (locale === undefined)
322
+ return;
323
+ const field = fieldSql(table.schema, "locale");
324
+ if (!field) {
325
+ if (locale !== null)
326
+ conditions.push("0 = 1");
327
+ return;
477
328
  }
329
+ if (locale === null)
330
+ conditions.push(`${field} IS NULL`);
478
331
  else {
479
- conditions.push(`${reference} = ?`);
480
- binds.push(predicate.value);
332
+ conditions.push(`${field} = ?`);
333
+ binds.push(locale);
481
334
  }
482
335
  }
483
- return { conditions, binds };
484
- }
485
- function usableIndexedFields(schema, predicates) {
486
- if (!schema)
487
- return new Set();
488
- const byField = new Map(predicates.map((predicate) => [predicate.field, predicate]));
489
- const usable = new Set();
490
- const declarations = [
491
- ...(schema.spec.uniqueIndexes ?? []),
492
- ...(schema.spec.indexes ?? []),
493
- ];
494
- for (const declaration of declarations) {
495
- for (let index = 0; index < declaration.length; index += 1) {
496
- const field = declaration[index];
497
- const predicate = byField.get(field);
498
- if (!predicate)
499
- break;
500
- if (index === 0 &&
501
- (predicate.kind === "null" ||
502
- (predicate.kind === "equal" && predicate.value === null))) {
503
- break;
504
- }
505
- usable.add(field);
506
- }
336
+ async versionConflict(table, id, expected) {
337
+ const after = await this.db.prepare(`SELECT "_mantle_version" AS "version" FROM ${table.table} WHERE "_mantle_id" = ?`).bind(id).first();
338
+ return new EntryVersionConflict(id, expected, after?.version ?? -1);
507
339
  }
508
- return usable;
509
- }
510
- function entrySortSql(schema, field) {
511
- if (field === "id")
512
- return "id";
513
- if (field === "status")
514
- return "status";
515
- if (field === "updatedAt")
516
- return "updated_at";
517
- return schema ? schemaIndexedFieldSql(schema, field) : null;
518
340
  }
519
- function entrySortValueFromDb(row, field) {
520
- if (field === "id")
521
- return row.id;
522
- if (field === "status")
523
- return row.status;
524
- if (field === "updatedAt")
525
- return row.updated_at;
526
- const value = JSON.parse(row.data)[field];
527
- if (typeof value === "boolean")
528
- return Number(value);
529
- if (typeof value !== "string" && typeof value !== "number") {
530
- throw new Error(`non-scalar sort value for ${field}`);
341
+ function rowFromDb(table, row, dataFields) {
342
+ const properties = table.schema.spec.schema.properties ?? {};
343
+ const data = {};
344
+ let locale;
345
+ for (const field of table.fields) {
346
+ if (!Object.hasOwn(row, field))
347
+ continue;
348
+ const value = decodeField(row[field], properties[field]);
349
+ if (field === "locale" && typeof value === "string")
350
+ locale = value;
351
+ if ((!dataFields || dataFields.includes(field)) && value !== undefined)
352
+ data[field] = value;
353
+ }
354
+ for (const field of dataFields ?? []) {
355
+ const property = properties[field];
356
+ if ((!property || isNullableJsonSchema(property)) && !Object.hasOwn(data, field))
357
+ data[field] = null;
531
358
  }
532
- return value;
533
- }
534
- function rowFromDb(row) {
535
- const data = JSON.parse(row.data);
536
359
  return {
537
- id: row.id,
538
- collection: row.collection,
539
- locale: row.entry_locale === undefined ? liftLocale(data) : row.entry_locale ?? undefined,
540
- status: row.status,
541
- version: row.version,
360
+ id: row._mantle_id,
361
+ collection: table.schema.metadata.name,
362
+ locale: locale ?? liftLocale(data),
363
+ status: row._mantle_status,
364
+ version: row._mantle_version,
542
365
  data,
543
- authorId: row.author_id,
544
- createdAt: row.created_at,
545
- updatedAt: row.updated_at,
366
+ authorId: row._mantle_author_id,
367
+ createdAt: row._mantle_created_at,
368
+ updatedAt: row._mantle_updated_at,
546
369
  };
547
370
  }
548
- function jsonPathForTopLevelField(field) {
549
- return `$."${field.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
371
+ function requiredFieldSql(schema, field) {
372
+ const sql = fieldSql(schema, field);
373
+ if (!sql)
374
+ throw new Error(`Schema '${schema.metadata.name}' has no field '${field}'.`);
375
+ return sql;
376
+ }
377
+ function encodeScalar(schema, field, value) {
378
+ const property = schema.spec.schema.properties?.[field];
379
+ return property ? encodeField(value, property) : value;
380
+ }
381
+ function sortValue(row, schema, field) {
382
+ const physical = fieldColumn(schema, field);
383
+ if (!physical)
384
+ throw new Error(`Schema '${schema.metadata.name}' has no field '${field}'.`);
385
+ const value = row[physical];
386
+ if (typeof value !== "string" && typeof value !== "number")
387
+ throw new Error(`non-scalar sort value for ${field}`);
388
+ return value;
389
+ }
390
+ function quote(value) {
391
+ return `"${value.replace(/"/g, '""')}"`;
550
392
  }
551
- /** Public compatibility helper. Without a Schema map it intentionally uses
552
- * the safe JSON fallback; in-repo callers use `MantleRuntime.entries`. */
553
- export async function readEntryBySlug(db, args) {
554
- return new DatabaseEntryRepository(db).readBySlug(args);
393
+ /** Compatibility helper now requires the Schema that owns the table. */
394
+ export async function readEntryBySlug(db, schema, args) {
395
+ return new DatabaseEntryRepository(db, new Map([[schema.metadata.name, schema]])).readBySlug(args);
555
396
  }
556
- function isDriverUniqueConstraintError(err) {
557
- if (!(err instanceof Error))
397
+ function isDriverUniqueConstraintError(error) {
398
+ if (!(error instanceof Error))
558
399
  return false;
559
- const msg = err.message || "";
560
- return (msg.includes("UNIQUE constraint failed") ||
561
- msg.includes("unique constraint") ||
562
- msg.includes("duplicate key") ||
563
- err.code === "SQLITE_CONSTRAINT_UNIQUE" ||
564
- err.code === "23505");
400
+ const message = error.message || "";
401
+ return message.includes("UNIQUE constraint failed") || message.includes("unique constraint") ||
402
+ message.includes("duplicate key") || error.code === "SQLITE_CONSTRAINT_UNIQUE" ||
403
+ error.code === "23505";
565
404
  }
566
405
  //# sourceMappingURL=DatabaseEntryRepository.js.map