@ganju/utils 0.0.14 → 0.0.16

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 (111) hide show
  1. package/dist/channelCustomer.d.ts +99 -0
  2. package/dist/channelCustomer.d.ts.map +1 -0
  3. package/dist/channelCustomer.js +533 -0
  4. package/dist/channelDebounce.d.ts +7 -0
  5. package/dist/channelDebounce.d.ts.map +1 -1
  6. package/dist/chunkOverlap.d.ts +2 -0
  7. package/dist/chunkOverlap.d.ts.map +1 -0
  8. package/dist/chunkOverlap.js +9 -0
  9. package/dist/chunking.d.ts.map +1 -1
  10. package/dist/chunking.js +24 -4
  11. package/dist/citations.d.ts +28 -0
  12. package/dist/citations.d.ts.map +1 -0
  13. package/dist/citations.js +111 -0
  14. package/dist/constants.d.ts +49 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +202 -17
  17. package/dist/data.d.ts +9 -0
  18. package/dist/data.d.ts.map +1 -0
  19. package/dist/data.js +78 -0
  20. package/dist/dataApi.d.ts +511 -0
  21. package/dist/dataApi.d.ts.map +1 -0
  22. package/dist/dataApi.js +355 -0
  23. package/dist/dataCompiler.d.ts +68 -0
  24. package/dist/dataCompiler.d.ts.map +1 -0
  25. package/dist/dataCompiler.js +726 -0
  26. package/dist/dataDdl.d.ts +29 -0
  27. package/dist/dataDdl.d.ts.map +1 -0
  28. package/dist/dataDdl.js +354 -0
  29. package/dist/dataEngine.d.ts +143 -0
  30. package/dist/dataEngine.d.ts.map +1 -0
  31. package/dist/dataEngine.js +2022 -0
  32. package/dist/dataIdentifiers.d.ts +8 -0
  33. package/dist/dataIdentifiers.d.ts.map +1 -0
  34. package/dist/dataIdentifiers.js +55 -0
  35. package/dist/dataImport.d.ts +77 -0
  36. package/dist/dataImport.d.ts.map +1 -0
  37. package/dist/dataImport.js +450 -0
  38. package/dist/dataRpc.d.ts +131 -0
  39. package/dist/dataRpc.d.ts.map +1 -0
  40. package/dist/dataRpc.js +2 -0
  41. package/dist/dataTypes.d.ts +157 -0
  42. package/dist/dataTypes.d.ts.map +1 -0
  43. package/dist/dataTypes.js +160 -0
  44. package/dist/index.d.ts +570 -36
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +72 -0
  47. package/dist/jsonSchemaToZodShape.d.ts +5 -1
  48. package/dist/jsonSchemaToZodShape.d.ts.map +1 -1
  49. package/dist/jsonSchemaToZodShape.js +21 -9
  50. package/dist/oauth.d.ts +14 -0
  51. package/dist/oauth.d.ts.map +1 -1
  52. package/dist/oauth.js +42 -2
  53. package/dist/pagination.d.ts +47 -0
  54. package/dist/pagination.d.ts.map +1 -0
  55. package/dist/pagination.js +160 -0
  56. package/dist/processQueueBatch.d.ts +6 -1
  57. package/dist/processQueueBatch.d.ts.map +1 -1
  58. package/dist/processQueueBatch.js +23 -10
  59. package/dist/queueFailure.d.ts +14 -0
  60. package/dist/queueFailure.d.ts.map +1 -0
  61. package/dist/queueFailure.js +43 -0
  62. package/dist/resourceRerank.d.ts +3 -2
  63. package/dist/resourceRerank.d.ts.map +1 -1
  64. package/dist/resourceRerank.js +8 -3
  65. package/dist/schema.d.ts +374 -29
  66. package/dist/schema.d.ts.map +1 -1
  67. package/dist/schema.js +290 -6
  68. package/dist/ssrf.d.ts +1 -0
  69. package/dist/ssrf.d.ts.map +1 -1
  70. package/dist/ssrf.js +48 -12
  71. package/dist/toolConfirmation.d.ts +48 -0
  72. package/dist/toolConfirmation.d.ts.map +1 -1
  73. package/dist/toolConfirmation.js +75 -1
  74. package/dist/toolCount.d.ts +13 -0
  75. package/dist/toolCount.d.ts.map +1 -0
  76. package/dist/toolCount.js +45 -0
  77. package/dist/toolLint.d.ts +1 -1
  78. package/dist/toolLint.d.ts.map +1 -1
  79. package/dist/toolLint.js +20 -0
  80. package/dist/untrustedContent.d.ts +135 -0
  81. package/dist/untrustedContent.d.ts.map +1 -0
  82. package/dist/untrustedContent.js +402 -0
  83. package/package.json +17 -1
  84. package/src/channelCustomer.ts +604 -0
  85. package/src/channelDebounce.ts +17 -0
  86. package/src/chunkOverlap.ts +6 -0
  87. package/src/chunking.ts +25 -4
  88. package/src/citations.ts +127 -0
  89. package/src/constants.ts +219 -17
  90. package/src/data.ts +74 -0
  91. package/src/dataApi.ts +428 -0
  92. package/src/dataCompiler.ts +948 -0
  93. package/src/dataDdl.ts +419 -0
  94. package/src/dataEngine.ts +2753 -0
  95. package/src/dataIdentifiers.ts +63 -0
  96. package/src/dataImport.ts +554 -0
  97. package/src/dataRpc.ts +121 -0
  98. package/src/dataTypes.ts +312 -0
  99. package/src/index.ts +156 -2
  100. package/src/jsonSchemaToZodShape.ts +31 -13
  101. package/src/oauth.ts +42 -1
  102. package/src/pagination.ts +193 -0
  103. package/src/processQueueBatch.ts +42 -12
  104. package/src/queueFailure.ts +63 -0
  105. package/src/resourceRerank.ts +10 -3
  106. package/src/schema.ts +346 -4
  107. package/src/ssrf.ts +40 -11
  108. package/src/toolConfirmation.ts +123 -0
  109. package/src/toolCount.ts +53 -0
  110. package/src/toolLint.ts +27 -0
  111. package/src/untrustedContent.ts +476 -0
@@ -0,0 +1,2022 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.exportRows = exports.dropColumn = exports.updateColumn = exports.copyTableStep = exports.clearFailedTableCopy = exports.writeReachesCopy = exports.readTableCopy = exports.dropColumnNeedsRebuild = exports.columnChangeNeedsRebuild = exports.addColumn = exports.dropTable = exports.updateTable = exports.createTable = exports.pruneHistory = exports.rowHistory = exports.deleteImpact = exports.write = exports.assertWritable = exports.assertActor = exports.query = exports.describe = exports.translateSqliteError = exports.normaliseColumn = exports.toTableInput = exports.loadSchema = exports.migrateInternal = exports.setMeta = exports.getMeta = exports.hasInternalTables = exports.INTERNAL_SCHEMA_VERSION = exports.INTERNAL_MIGRATIONS = void 0;
4
+ // What a project's database does, written against a tiny SQL runner so the
5
+ // same code runs inside the Durable Object (apps/data) and on node:sqlite in
6
+ // the tests and the tool-design eval. The runner executes; everything about
7
+ // which SQL is allowed and how results look is decided here.
8
+ const dataIdentifiers_1 = require("./dataIdentifiers");
9
+ const dataDdl_1 = require("./dataDdl");
10
+ const dataCompiler_1 = require("./dataCompiler");
11
+ const dataTypes_1 = require("./dataTypes");
12
+ Object.defineProperty(exports, "toTableInput", { enumerable: true, get: function () { return dataTypes_1.toTableInput; } });
13
+ const first = (runner, sql, params) => runner.all(sql, params)[0];
14
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
15
+ // Internal tables
16
+ // Our own tables inside every database. Each entry is one version; the
17
+ // object runs the ones it hasn't on first use after a deploy, so they must
18
+ // be additive and fast.
19
+ exports.INTERNAL_MIGRATIONS = [
20
+ [
21
+ 'CREATE TABLE IF NOT EXISTS _ganju_meta (key TEXT PRIMARY KEY, value TEXT) WITHOUT ROWID',
22
+ `CREATE TABLE _ganju_tables (
23
+ id INTEGER PRIMARY KEY,
24
+ name TEXT NOT NULL UNIQUE,
25
+ label TEXT,
26
+ description TEXT,
27
+ display_column_id INTEGER,
28
+ access TEXT NOT NULL DEFAULT 'read',
29
+ customer_scoped INTEGER NOT NULL DEFAULT 0,
30
+ history INTEGER NOT NULL DEFAULT 1,
31
+ position INTEGER NOT NULL DEFAULT 0,
32
+ created_at TEXT NOT NULL DEFAULT (${dataDdl_1.DATA_NOW_SQL}),
33
+ updated_at TEXT NOT NULL DEFAULT (${dataDdl_1.DATA_NOW_SQL})
34
+ )`,
35
+ `CREATE TABLE _ganju_columns (
36
+ id INTEGER PRIMARY KEY,
37
+ table_id INTEGER NOT NULL REFERENCES _ganju_tables(id),
38
+ name TEXT NOT NULL,
39
+ label TEXT,
40
+ description TEXT,
41
+ type TEXT NOT NULL,
42
+ required INTEGER NOT NULL DEFAULT 0,
43
+ is_unique INTEGER NOT NULL DEFAULT 0,
44
+ filterable INTEGER NOT NULL DEFAULT 0,
45
+ searchable INTEGER NOT NULL DEFAULT 0,
46
+ personal INTEGER NOT NULL DEFAULT 0,
47
+ enum_values TEXT,
48
+ relation_table_id INTEGER REFERENCES _ganju_tables(id),
49
+ on_delete TEXT,
50
+ min REAL,
51
+ default_value TEXT,
52
+ position INTEGER NOT NULL DEFAULT 0,
53
+ created_at TEXT NOT NULL DEFAULT (${dataDdl_1.DATA_NOW_SQL}),
54
+ updated_at TEXT NOT NULL DEFAULT (${dataDdl_1.DATA_NOW_SQL}),
55
+ UNIQUE (table_id, name)
56
+ )`,
57
+ `CREATE TABLE _ganju_row_log (
58
+ id INTEGER PRIMARY KEY,
59
+ table_id INTEGER NOT NULL,
60
+ row_id INTEGER NOT NULL,
61
+ op TEXT NOT NULL,
62
+ actor TEXT,
63
+ at TEXT NOT NULL,
64
+ changes TEXT NOT NULL
65
+ )`,
66
+ 'CREATE INDEX _ganju_row_log_row ON _ganju_row_log(table_id, row_id)',
67
+ 'CREATE TABLE _ganju_context (id INTEGER PRIMARY KEY CHECK (id = 1), actor TEXT)'
68
+ ],
69
+ // Tables made before a new table got its first text column as the display
70
+ // column show ids in relations; give them that column now. A table made
71
+ // since with no display column asked for the id.
72
+ [
73
+ `UPDATE _ganju_tables SET display_column_id = (
74
+ SELECT c.id FROM _ganju_columns c
75
+ WHERE c.table_id = _ganju_tables.id AND c.type = 'text'
76
+ ORDER BY c.position, c.id LIMIT 1
77
+ ) WHERE display_column_id IS NULL AND created_at < '2026-10-24T00:00:00Z'`
78
+ ]
79
+ ];
80
+ exports.INTERNAL_SCHEMA_VERSION = exports.INTERNAL_MIGRATIONS.length;
81
+ const hasInternalTables = (runner) => runner.all("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = '_ganju_meta'").length > 0;
82
+ exports.hasInternalTables = hasInternalTables;
83
+ const getMeta = (runner, key) => {
84
+ const row = first(runner, 'SELECT value FROM _ganju_meta WHERE key = ?', [
85
+ key
86
+ ]);
87
+ return row ? row.value : null;
88
+ };
89
+ exports.getMeta = getMeta;
90
+ const setMeta = (runner, key, value) => {
91
+ if (value === null) {
92
+ runner.all('DELETE FROM _ganju_meta WHERE key = ?', [key]);
93
+ return;
94
+ }
95
+ runner.all('INSERT INTO _ganju_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value]);
96
+ };
97
+ exports.setMeta = setMeta;
98
+ // Brings our own tables up to date. Returns the version now in force.
99
+ const migrateInternal = (runner) => runner.transaction(() => {
100
+ const current = (0, exports.hasInternalTables)(runner)
101
+ ? Number((0, exports.getMeta)(runner, 'schema_version') ?? 0)
102
+ : 0;
103
+ for (let v = current; v < exports.INTERNAL_MIGRATIONS.length; v++) {
104
+ for (const statement of exports.INTERNAL_MIGRATIONS[v])
105
+ runner.all(statement);
106
+ }
107
+ if (current < exports.INTERNAL_MIGRATIONS.length) {
108
+ (0, exports.setMeta)(runner, 'schema_version', String(exports.INTERNAL_MIGRATIONS.length));
109
+ }
110
+ return exports.INTERNAL_MIGRATIONS.length;
111
+ });
112
+ exports.migrateInternal = migrateInternal;
113
+ // Schema
114
+ const parseJson = (value) => {
115
+ if (value === null)
116
+ return null;
117
+ try {
118
+ return JSON.parse(String(value));
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ };
124
+ const loadSchema = (runner) => {
125
+ const tables = runner.all('SELECT t.*, d.name AS display_column FROM _ganju_tables t LEFT JOIN _ganju_columns d ON d.id = t.display_column_id ORDER BY t.position, t.id');
126
+ const columns = runner.all('SELECT c.*, r.name AS relation_table FROM _ganju_columns c LEFT JOIN _ganju_tables r ON r.id = c.relation_table_id ORDER BY c.table_id, c.position, c.id');
127
+ return {
128
+ tables: tables.map(row => ({
129
+ id: Number(row.id),
130
+ name: String(row.name),
131
+ label: row.label ?? null,
132
+ description: row.description ?? null,
133
+ displayColumn: row.display_column ?? null,
134
+ access: row.access ?? 'read',
135
+ customerScoped: row.customer_scoped === 1,
136
+ history: row.history === 1,
137
+ position: Number(row.position),
138
+ columns: columns
139
+ .filter(c => c.table_id === row.id)
140
+ .map(c => {
141
+ const stored = parseJson(c.default_value);
142
+ return {
143
+ id: Number(c.id),
144
+ name: String(c.name),
145
+ type: c.type,
146
+ label: c.label ?? null,
147
+ description: c.description ?? null,
148
+ required: c.required === 1,
149
+ unique: c.is_unique === 1,
150
+ filterable: c.filterable === 1,
151
+ searchable: c.searchable === 1,
152
+ personal: c.personal === 1,
153
+ values: parseJson(c.enum_values),
154
+ table: c.relation_table ?? null,
155
+ onDelete: c.on_delete ?? null,
156
+ min: c.min === null ? null : Number(c.min),
157
+ default: stored ? stored.v : undefined,
158
+ position: Number(c.position)
159
+ };
160
+ })
161
+ }))
162
+ };
163
+ };
164
+ exports.loadSchema = loadSchema;
165
+ const tableNamed = (schema, name) => {
166
+ const table = schema.tables.find(t => t.name === name);
167
+ if (!table) {
168
+ throw new dataTypes_1.DataError(`Unknown table "${String(name)}". Tables: ${schema.tables.map(t => t.name).join(', ') || '(none)'}.`, 'not_found');
169
+ }
170
+ return table;
171
+ };
172
+ const columnNamed = (table, name) => {
173
+ const column = table.columns.find(c => c.name === name);
174
+ if (!column)
175
+ throw (0, dataCompiler_1.unknownColumn)(table, String(name));
176
+ return column;
177
+ };
178
+ // Validation of definitions
179
+ const cleanText = (value, max, label) => {
180
+ if (value === undefined || value === null)
181
+ return null;
182
+ if (typeof value !== 'string')
183
+ throw new dataTypes_1.DataError(`${label} is text.`);
184
+ const text = value.trim();
185
+ if (text.length > max) {
186
+ throw new dataTypes_1.DataError(`${label} is up to ${max} characters.`);
187
+ }
188
+ return text || null;
189
+ };
190
+ const flag = (value, label) => {
191
+ if (value === undefined || value === null)
192
+ return false;
193
+ if (typeof value !== 'boolean') {
194
+ throw new dataTypes_1.DataError(`${label} is true or false.`);
195
+ }
196
+ return value;
197
+ };
198
+ // Checks one column definition against the schema it will join. `selfTable`
199
+ // is the table the column belongs to, so a relation can point at it.
200
+ const normaliseColumn = (input, schema, selfTable) => {
201
+ if (!isPlainObject(input)) {
202
+ throw new dataTypes_1.DataError('A column is an object: { "name", "type", ... }.');
203
+ }
204
+ const name = input.name;
205
+ const nameIssue = (0, dataIdentifiers_1.identifierIssue)(name, 'column');
206
+ if (nameIssue)
207
+ throw new dataTypes_1.DataError(nameIssue);
208
+ const label = `${selfTable}.${name}`;
209
+ const type = input.type;
210
+ if (!dataTypes_1.DATA_COLUMN_TYPES.includes(type)) {
211
+ throw new dataTypes_1.DataError(`${label}: "type" is one of ${dataTypes_1.DATA_COLUMN_TYPES.join(', ')}.`);
212
+ }
213
+ const column = {
214
+ name: name,
215
+ type: type,
216
+ label: cleanText(input.label, dataTypes_1.DATA_MAX_LABEL, `${label} label`),
217
+ description: cleanText(input.description, dataTypes_1.DATA_MAX_DESCRIPTION, `${label} description`),
218
+ required: flag(input.required, `${label} required`),
219
+ unique: flag(input.unique, `${label} unique`),
220
+ filterable: flag(input.filterable, `${label} filterable`),
221
+ searchable: flag(input.searchable, `${label} searchable`),
222
+ personal: flag(input.personal, `${label} personal`),
223
+ values: null,
224
+ table: null,
225
+ onDelete: null,
226
+ min: null,
227
+ default: undefined
228
+ };
229
+ if (column.type === 'enum') {
230
+ const values = input.values;
231
+ if (!Array.isArray(values) ||
232
+ values.length === 0 ||
233
+ values.length > dataTypes_1.DATA_MAX_ENUM_VALUES) {
234
+ throw new dataTypes_1.DataError(`${label}: an enum lists 1 to ${dataTypes_1.DATA_MAX_ENUM_VALUES} "values".`);
235
+ }
236
+ const seen = new Set();
237
+ column.values = values.map(value => {
238
+ if (typeof value !== 'string' || !value.trim() || value.length > 100) {
239
+ throw new dataTypes_1.DataError(`${label}: each value is text, 1 to 100 characters.`);
240
+ }
241
+ const key = value.trim().toLowerCase();
242
+ if (seen.has(key))
243
+ throw new dataTypes_1.DataError(`${label}: "${value}" is listed twice.`);
244
+ seen.add(key);
245
+ return value.trim();
246
+ });
247
+ }
248
+ else if (input.values !== undefined && input.values !== null) {
249
+ throw new dataTypes_1.DataError(`${label}: only enum columns take "values".`);
250
+ }
251
+ if (column.type === 'relation') {
252
+ const target = input.table;
253
+ if (typeof target !== 'string' ||
254
+ (target !== selfTable && !schema.tables.some(t => t.name === target))) {
255
+ throw new dataTypes_1.DataError(`${label}: a relation names the table it points at in "table". Tables: ${schema.tables.map(t => t.name).join(', ') || '(none)'}.`);
256
+ }
257
+ const onDelete = input.onDelete ?? 'restrict';
258
+ if (!dataTypes_1.DATA_ON_DELETE.includes(onDelete)) {
259
+ throw new dataTypes_1.DataError(`${label}: "onDelete" is one of ${dataTypes_1.DATA_ON_DELETE.join(', ')}.`);
260
+ }
261
+ if (onDelete === 'set_null' && column.required) {
262
+ throw new dataTypes_1.DataError(`${label} is required, so deleting the row it points at can't clear it; use restrict or cascade.`);
263
+ }
264
+ column.table = target;
265
+ column.onDelete = onDelete;
266
+ }
267
+ else if (input.table !== undefined && input.table !== null) {
268
+ throw new dataTypes_1.DataError(`${label}: only relation columns take "table".`);
269
+ }
270
+ if (input.min !== undefined && input.min !== null) {
271
+ if (!dataDdl_1.NUMERIC_TYPES.has(column.type)) {
272
+ throw new dataTypes_1.DataError(`${label}: only integer and decimal columns take "min".`);
273
+ }
274
+ if (typeof input.min !== 'number' || !Number.isFinite(input.min)) {
275
+ throw new dataTypes_1.DataError(`${label}: "min" is a number.`);
276
+ }
277
+ column.min = input.min;
278
+ }
279
+ if (column.unique && !dataDdl_1.UNIQUE_TYPES.has(column.type)) {
280
+ throw new dataTypes_1.DataError(`${label}: a ${column.type} column can't be unique.`);
281
+ }
282
+ if (column.searchable && !dataDdl_1.SEARCHABLE_TYPES.has(column.type)) {
283
+ throw new dataTypes_1.DataError(`${label}: only text columns can be searchable (text, long_text, email, url, phone, enum).`);
284
+ }
285
+ // The search table has a column of its own called rank.
286
+ if (column.searchable && column.name === 'rank') {
287
+ throw new dataTypes_1.DataError(`${label}: a column named "rank" can't be searchable; rename it (e.g. "ranking").`);
288
+ }
289
+ if (column.filterable && (column.type === 'json' || column.type === 'long_text')) {
290
+ throw new dataTypes_1.DataError(`${label}: a ${column.type} column can't be filterable; mark it searchable instead.`);
291
+ }
292
+ if (input.default !== undefined && input.default !== null) {
293
+ if (column.type === 'relation' || column.type === 'file') {
294
+ throw new dataTypes_1.DataError(`${label}: a ${column.type} column can't have a default.`);
295
+ }
296
+ if (column.unique) {
297
+ throw new dataTypes_1.DataError(`${label}: a unique column can't have a default.`);
298
+ }
299
+ const fake = {
300
+ id: 0,
301
+ name: selfTable,
302
+ label: null,
303
+ description: null,
304
+ displayColumn: null,
305
+ access: 'read',
306
+ customerScoped: false,
307
+ history: false,
308
+ position: 0,
309
+ columns: []
310
+ };
311
+ const stored = (0, dataCompiler_1.toStored)(fake, { ...column, id: 0, position: 0 }, input.default);
312
+ if (column.min !== null && typeof stored === 'number' && stored < column.min) {
313
+ throw new dataTypes_1.DataError(`${label}: the default is below the minimum, ${column.min}.`);
314
+ }
315
+ column.default =
316
+ column.type === 'boolean'
317
+ ? stored === 1
318
+ : column.type === 'json'
319
+ ? input.default
320
+ : stored;
321
+ }
322
+ return column;
323
+ };
324
+ exports.normaliseColumn = normaliseColumn;
325
+ const normaliseAccess = (value, fallback) => {
326
+ if (value === undefined || value === null)
327
+ return fallback;
328
+ if (!dataTypes_1.DATA_ACCESS_LEVELS.includes(value)) {
329
+ throw new dataTypes_1.DataError(`"access" is one of ${dataTypes_1.DATA_ACCESS_LEVELS.join(', ')}.`);
330
+ }
331
+ return value;
332
+ };
333
+ // Keeping indexes, triggers and the search table in line with the metadata
334
+ const tableObjects = (runner, type, table) => runner
335
+ .all('SELECT name, sql FROM sqlite_master WHERE type = ? AND tbl_name = ?', [type, table])
336
+ .map(row => ({ name: String(row.name), sql: row.sql }));
337
+ const dropTriggers = (runner, table) => {
338
+ const prefix = (0, dataDdl_1.triggerPrefix)(table);
339
+ for (const row of runner.all("SELECT name FROM sqlite_master WHERE type = 'trigger' AND substr(name, 1, ?) = ?", [prefix.length, prefix])) {
340
+ runner.all(`DROP TRIGGER IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)(String(row.name))}`);
341
+ }
342
+ };
343
+ const duplicateValues = (runner, table, column) => runner
344
+ .all(`SELECT ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} AS value, count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} IS NOT NULL GROUP BY ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} HAVING count(*) > 1 ORDER BY n DESC LIMIT 10`)
345
+ .map(row => `${JSON.stringify(row.value)} (${row.n} rows)`);
346
+ // Makes the table's indexes, triggers and search table match `table`.
347
+ // `previousName` is the name before a rename, whose search table goes.
348
+ const syncTableObjects = (runner, table, previousName) => {
349
+ const wanted = (0, dataDdl_1.desiredIndexes)(table);
350
+ const wantedNames = new Set(wanted.map(i => i.name));
351
+ const existing = tableObjects(runner, 'index', table.name).filter(i => i.name.startsWith('ix_') || i.name.startsWith('ux_'));
352
+ for (const index of existing) {
353
+ if (!wantedNames.has(index.name)) {
354
+ runner.all(`DROP INDEX IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)(index.name)}`);
355
+ }
356
+ }
357
+ const have = new Set(existing.map(i => i.name));
358
+ for (const index of wanted) {
359
+ if (have.has(index.name))
360
+ continue;
361
+ try {
362
+ runner.all(index.sql);
363
+ }
364
+ catch (error) {
365
+ const column = table.columns.find(c => index.name.endsWith(`_${c.id}`));
366
+ if (column && /UNIQUE/i.test(String(error?.message))) {
367
+ throw new dataTypes_1.DataError(`${table.name}.${column.name} can't be unique: these values repeat: ${duplicateValues(runner, table, column).join(', ')}.`, 'constraint');
368
+ }
369
+ throw error;
370
+ }
371
+ }
372
+ dropTriggers(runner, table);
373
+ const wantedFts = (0, dataDdl_1.ftsCreateSql)(table);
374
+ const ftsName = (0, dataDdl_1.ftsTableName)(table);
375
+ const currentFts = first(runner, "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", [ftsName]);
376
+ if (previousName && previousName !== table.name) {
377
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)(`${previousName}__fts`)}`);
378
+ }
379
+ if ((currentFts?.sql ?? null) !== wantedFts) {
380
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)(ftsName)}`);
381
+ if (wantedFts) {
382
+ runner.all(wantedFts);
383
+ runner.all(`INSERT INTO ${(0, dataIdentifiers_1.quoteIdentifier)(ftsName)}(${(0, dataIdentifiers_1.quoteIdentifier)(ftsName)}) VALUES ('rebuild')`);
384
+ }
385
+ }
386
+ for (const trigger of (0, dataDdl_1.desiredTriggers)(table))
387
+ runner.all(trigger.sql);
388
+ };
389
+ // Errors
390
+ // SQLite's message, for the person or model that made the call.
391
+ const translateSqliteError = (schema, table, error) => {
392
+ if (error instanceof dataTypes_1.DataError)
393
+ return error;
394
+ const text = String(error?.message ?? error);
395
+ const columnById = (id) => {
396
+ for (const t of schema.tables) {
397
+ const column = t.columns.find(c => String(c.id) === id);
398
+ if (column)
399
+ return { table: t, column };
400
+ }
401
+ return undefined;
402
+ };
403
+ let m = /NOT NULL constraint failed: [\w"]+\.(\w+)/.exec(text);
404
+ if (m)
405
+ return new dataTypes_1.DataError(`${table.name}.${m[1]} is required.`, 'constraint');
406
+ m = /UNIQUE constraint failed: [\w"]+\.(\w+)/.exec(text);
407
+ if (m) {
408
+ return new dataTypes_1.DataError(`Another row in ${table.name} already has this ${m[1]}.`, 'constraint');
409
+ }
410
+ m = /CHECK constraint failed: min_(\d+)/.exec(text);
411
+ if (m) {
412
+ const found = columnById(m[1]);
413
+ if (found) {
414
+ return new dataTypes_1.DataError(`${found.table.name}.${found.column.name} can't go below ${found.column.min ?? 0}.`, 'constraint');
415
+ }
416
+ }
417
+ m = /CHECK constraint failed: chk_(\d+)/.exec(text);
418
+ if (m) {
419
+ const found = columnById(m[1]);
420
+ if (found) {
421
+ return new dataTypes_1.DataError(`Invalid value for ${found.table.name}.${found.column.name} (${found.column.type}).`, 'constraint');
422
+ }
423
+ }
424
+ if (/FOREIGN KEY constraint failed/.test(text)) {
425
+ return new dataTypes_1.DataError(`A relation in ${table.name} points at a row that doesn't exist, or other rows still point at this one.`, 'constraint');
426
+ }
427
+ if (/SQLITE_FULL|database or disk is full/i.test(text)) {
428
+ return new dataTypes_1.DataError('The database is full: delete or archive rows to make room.', 'full');
429
+ }
430
+ if (/too many SQL variables/i.test(text)) {
431
+ return new dataTypes_1.DataError('This call uses too many values at once.', 'too_large');
432
+ }
433
+ // Never SQLite's own text: it names our internal tables and constraints.
434
+ console.error('data-sqlite-error', text);
435
+ return new dataTypes_1.DataError(`The database refused this change to ${table.name}; check the values against the table's columns.`, 'invalid');
436
+ };
437
+ exports.translateSqliteError = translateSqliteError;
438
+ // Rows out
439
+ const estimateRows = (runner, table) => Number(first(runner, `SELECT max(id) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)}`)?.n ?? 0);
440
+ const assertBounded = (runner, table, what, column) => {
441
+ const rows = estimateRows(runner, table);
442
+ if (rows <= dataTypes_1.DATA_LARGE_TABLE_ROWS)
443
+ return;
444
+ throw new dataTypes_1.DataError(`${table.name} has about ${rows.toLocaleString('en-US')} rows, so ${what} needs a filter on an indexed column (id, or a column marked filterable, unique or a relation)${column ? `, or "${column}" marked filterable so it is indexed` : ''}.`, 'too_large', { suggestIndex: column ?? null });
445
+ };
446
+ const relationLabels = (schema, table) => table.columns
447
+ .filter(column => column.type === 'relation' && column.table)
448
+ .map(column => {
449
+ const target = schema.tables.find(t => t.name === column.table);
450
+ return { column, target, display: target?.displayColumn ?? 'id' };
451
+ })
452
+ .filter((x) => Boolean(x.target));
453
+ // The columns a row comes back with, plus each relation's label read from
454
+ // the related table as the caller may see it: none from a table hidden from
455
+ // it, and only its own customer's rows from a scoped one.
456
+ const selectList = (schema, caller, table, joins) => {
457
+ const list = ['t.id', 't.created_at', 't.updated_at', 't.version'];
458
+ for (const column of table.columns)
459
+ list.push(`t.${(0, dataIdentifiers_1.quoteIdentifier)(column.name)}`);
460
+ for (const { column, target, display } of relationLabels(schema, table)) {
461
+ const label = (0, dataIdentifiers_1.quoteIdentifier)(`__label_${column.name}`);
462
+ if (!(0, dataCompiler_1.canRead)(caller, target)) {
463
+ list.push(`NULL AS ${label}`);
464
+ continue;
465
+ }
466
+ const alias = `l_${column.name}`;
467
+ joins.set(alias, (0, dataCompiler_1.relationJoin)(caller, target, alias, `t.${(0, dataIdentifiers_1.quoteIdentifier)(column.name)}`));
468
+ list.push(`${alias}.${(0, dataIdentifiers_1.quoteIdentifier)(display)} AS ${label}`);
469
+ }
470
+ return list;
471
+ };
472
+ // `version` comes back to the dashboard, the CLI and the owner's sessions,
473
+ // for edits that must not overwrite someone else's; never to a bot.
474
+ const shapeRow = (schema, table, raw, caller) => {
475
+ const timeZone = caller.timeZone;
476
+ const row = { id: Number(raw.id) };
477
+ for (const column of table.columns) {
478
+ const value = (0, dataCompiler_1.fromStored)(column, raw[column.name], timeZone);
479
+ if (column.type === 'relation') {
480
+ const target = schema.tables.find(t => t.name === column.table);
481
+ const display = target
482
+ ? (0, dataCompiler_1.columnFor)(target, target.displayColumn ?? 'id')
483
+ : undefined;
484
+ const label = raw[`__label_${column.name}`] ?? null;
485
+ row[column.name] =
486
+ value === null
487
+ ? null
488
+ : {
489
+ id: value,
490
+ label: display && label !== null
491
+ ? (0, dataCompiler_1.fromStored)(display, label, timeZone)
492
+ : label
493
+ };
494
+ }
495
+ else {
496
+ row[column.name] = value;
497
+ }
498
+ }
499
+ row.created_at = (0, dataCompiler_1.zoned)(String(raw.created_at), timeZone);
500
+ row.updated_at = (0, dataCompiler_1.zoned)(String(raw.updated_at), timeZone);
501
+ if (!caller.restricted && raw.version !== undefined) {
502
+ row.version = Number(raw.version);
503
+ }
504
+ return row;
505
+ };
506
+ const SCOPE_NOTE = "only this customer's own rows; other customers' rows are not visible";
507
+ // describe
508
+ const describe = (runner, schema, caller, rawArgs = {}) => {
509
+ const args = (0, dataCompiler_1.tidyArgs)(rawArgs);
510
+ const tables = args.table !== undefined && args.table !== null
511
+ ? [(0, dataCompiler_1.tableFor)(schema, caller, args.table)]
512
+ : (0, dataCompiler_1.visibleTables)(schema, caller);
513
+ return {
514
+ tables: tables.map(table => {
515
+ // A scoped bot counts only its customer's rows (served by the
516
+ // participant index), never the table's size.
517
+ const scoped = (0, dataCompiler_1.isScoped)(caller, table);
518
+ const estimate = scoped ? 0 : estimateRows(runner, table);
519
+ const rows = scoped
520
+ ? Number(first(runner, `SELECT count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE participant_id = ?`, [(0, dataCompiler_1.participantOf)(caller)])?.n ?? 0)
521
+ : estimate <= dataTypes_1.DATA_LARGE_TABLE_ROWS
522
+ ? Number(first(runner, `SELECT count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)}`)?.n ?? 0)
523
+ : undefined;
524
+ return {
525
+ name: table.name,
526
+ ...(table.label ? { label: table.label } : {}),
527
+ description: table.description,
528
+ access: (0, dataCompiler_1.accessOf)(caller, table),
529
+ ...((0, dataCompiler_1.isScoped)(caller, table) ? { onlyThisCustomersRows: true } : {}),
530
+ ...(rows === undefined ? { rowsAbout: estimate } : { rows }),
531
+ displayColumn: table.displayColumn ?? 'id',
532
+ searchable: (0, dataDdl_1.searchableColumns)(table).map(c => c.name),
533
+ columns: [
534
+ { name: 'id', type: 'integer' },
535
+ ...table.columns.map(column => ({
536
+ name: column.name,
537
+ type: column.type,
538
+ ...(column.label ? { label: column.label } : {}),
539
+ ...(column.required ? { required: true } : {}),
540
+ ...(column.unique ? { unique: true } : {}),
541
+ ...(column.values ? { values: column.values } : {}),
542
+ ...(column.type === 'relation' ? { references: column.table } : {}),
543
+ ...(column.default !== undefined ? { default: column.default } : {}),
544
+ ...(column.min !== null ? { min: column.min } : {}),
545
+ ...(column.description ? { description: column.description } : {})
546
+ })),
547
+ { name: 'created_at', type: 'datetime' },
548
+ { name: 'updated_at', type: 'datetime' }
549
+ ],
550
+ referencedBy: (0, dataCompiler_1.visibleTables)(schema, caller).flatMap(other => other.columns
551
+ .filter(c => c.type === 'relation' && c.table === table.name)
552
+ .map(c => `${other.name}.${c.name}`))
553
+ };
554
+ })
555
+ };
556
+ };
557
+ exports.describe = describe;
558
+ // query
559
+ const searchJoinSql = (table) => {
560
+ const fts = (0, dataIdentifiers_1.quoteIdentifier)((0, dataDdl_1.ftsTableName)(table));
561
+ return `JOIN (SELECT rowid AS _r, bm25(${fts}) AS _rank FROM ${fts} WHERE ${fts} MATCH ?) s ON s._r = t.id`;
562
+ };
563
+ const query = (runner, schema, caller, rawArgs) => {
564
+ if (!isPlainObject(rawArgs))
565
+ throw new dataTypes_1.DataError('Arguments are an object.');
566
+ const args = (0, dataCompiler_1.tidyArgs)(rawArgs);
567
+ const table = (0, dataCompiler_1.tableFor)(schema, caller, args.table);
568
+ const ctx = {
569
+ schema,
570
+ caller,
571
+ table,
572
+ joins: new Map(),
573
+ params: []
574
+ };
575
+ const indexed = new Set();
576
+ const where = (0, dataCompiler_1.compileWhere)(ctx, args.where, indexed);
577
+ if ((0, dataCompiler_1.isScoped)(caller, table)) {
578
+ where.push('t.participant_id = ?');
579
+ ctx.params.push((0, dataCompiler_1.participantOf)(caller));
580
+ }
581
+ const searching = args.search !== undefined && args.search !== null && args.search !== '';
582
+ if (searching && (0, dataDdl_1.searchableColumns)(table).length === 0) {
583
+ throw new dataTypes_1.DataError(`"${table.name}" has no searchable columns; filter with "where" instead (e.g. { "name": { "contains": "..." } }).`);
584
+ }
585
+ if (args.aggregate !== undefined && args.aggregate !== null) {
586
+ return aggregate(runner, ctx, args, where, searching, indexed.size > 0);
587
+ }
588
+ const limit = args.limit === undefined || args.limit === null ? dataTypes_1.DATA_DEFAULT_PAGE : args.limit;
589
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > dataTypes_1.DATA_MAX_PAGE) {
590
+ throw new dataTypes_1.DataError(`"limit" is 1 to ${dataTypes_1.DATA_MAX_PAGE}.`);
591
+ }
592
+ const sortList = args.sort === undefined || args.sort === null
593
+ ? []
594
+ : Array.isArray(args.sort)
595
+ ? args.sort
596
+ : [args.sort];
597
+ if (sortList.length > dataTypes_1.DATA_MAX_SORT_KEYS) {
598
+ throw new dataTypes_1.DataError(`"sort" takes up to ${dataTypes_1.DATA_MAX_SORT_KEYS} columns.`);
599
+ }
600
+ const keys = [];
601
+ let endsUnique = false;
602
+ for (const entry of sortList) {
603
+ if (typeof entry !== 'string' || entry === '' || entry === '-') {
604
+ throw new dataTypes_1.DataError('"sort" is a list of column names; prefix with "-" for descending, e.g. ["-created_at"].');
605
+ }
606
+ const descending = entry.startsWith('-');
607
+ const resolved = (0, dataCompiler_1.resolvePath)(ctx, descending ? entry.slice(1) : entry);
608
+ if (resolved.column.type === 'json') {
609
+ throw new dataTypes_1.DataError(`${resolved.column.name} is json and can't be sorted.`);
610
+ }
611
+ const servedByIndex = resolved.own &&
612
+ (resolved.column.name === 'id' ||
613
+ (!(0, dataCompiler_1.isSystemColumn)(resolved.column) && (0, dataDdl_1.isIndexedColumn)(resolved.column)));
614
+ keys.push({
615
+ sql: resolved.sql,
616
+ descending,
617
+ ...(servedByIndex ? {} : { unindexed: resolved.column.name })
618
+ });
619
+ if (resolved.own && resolved.column.name === 'id') {
620
+ endsUnique = true;
621
+ break;
622
+ }
623
+ }
624
+ if (keys.length === 0 && searching)
625
+ keys.push({ sql: 's._rank', descending: false });
626
+ if (!endsUnique)
627
+ keys.push({ sql: 't.id', descending: false });
628
+ const unindexedSort = keys.find(k => k.unindexed);
629
+ if (unindexedSort && indexed.size === 0 && !searching) {
630
+ assertBounded(runner, table, 'sorting it', unindexedSort.unindexed);
631
+ }
632
+ const signature = JSON.stringify([
633
+ table.name,
634
+ keys.map(k => `${k.descending ? '-' : ''}${k.sql}`)
635
+ ]);
636
+ const cursor = args.cursor !== undefined && args.cursor !== null && args.cursor !== ''
637
+ ? (0, dataCompiler_1.decodeCursor)(args.cursor, signature)
638
+ : null;
639
+ if (cursor && cursor.v.length !== keys.length) {
640
+ throw new dataTypes_1.DataError('"cursor" is not one this query returned.');
641
+ }
642
+ const labelJoins = new Map();
643
+ const select = selectList(schema, caller, table, labelJoins);
644
+ keys.forEach((key, i) => select.push(`${key.sql} AS ${(0, dataIdentifiers_1.quoteIdentifier)(`__s${i}`)}`));
645
+ // Rows are read one at a time and the page stops at 1 MB, so a page of
646
+ // large rows is never held in memory whole.
647
+ const run = (match) => {
648
+ const fromParams = match ? [match] : [];
649
+ const searchJoin = match ? searchJoinSql(table) : '';
650
+ const conditions = [...where];
651
+ const countSql = `SELECT count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t ${searchJoin} ${(0, dataCompiler_1.joinsSql)(ctx.joins)} ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}`;
652
+ const pageParams = [];
653
+ if (cursor)
654
+ conditions.push((0, dataCompiler_1.keysetCondition)(keys, cursor.v, pageParams));
655
+ const joins = new Map([...ctx.joins, ...labelJoins]);
656
+ const rowsSql = `SELECT ${select.join(', ')} FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t ${searchJoin} ${(0, dataCompiler_1.joinsSql)(joins)} ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''} ORDER BY ${keys.map(k => `${k.sql} ${k.descending ? 'DESC' : 'ASC'}`).join(', ')} LIMIT ?`;
657
+ const params = [
658
+ ...fromParams,
659
+ ...(0, dataCompiler_1.joinsParams)(joins),
660
+ ...ctx.params,
661
+ ...pageParams,
662
+ limit + 1
663
+ ];
664
+ (0, dataCompiler_1.assertParamCount)(params);
665
+ const rows = [];
666
+ let bytes = 0;
667
+ let last;
668
+ let more = false;
669
+ for (const raw of runner.iterate(rowsSql, params)) {
670
+ if (rows.length === limit) {
671
+ more = true;
672
+ break;
673
+ }
674
+ const row = shapeRow(schema, table, raw, caller);
675
+ const size = JSON.stringify(row).length;
676
+ if (rows.length > 0 && bytes + size > dataTypes_1.DATA_MAX_RESULT_BYTES) {
677
+ more = true;
678
+ break;
679
+ }
680
+ bytes += size;
681
+ rows.push(row);
682
+ last = raw;
683
+ }
684
+ const countParams = [...fromParams, ...(0, dataCompiler_1.joinsParams)(ctx.joins), ...ctx.params];
685
+ const total = cursor
686
+ ? undefined
687
+ : Number(first(runner, countSql, countParams)?.n ?? 0);
688
+ return { rows, bytes, last, more, total };
689
+ };
690
+ let matched;
691
+ let result;
692
+ try {
693
+ if (searching) {
694
+ const any = cursor?.m === 'any';
695
+ result = run((0, dataCompiler_1.ftsMatch)(args.search, any));
696
+ matched = any ? 'any word' : 'all words';
697
+ if (!cursor && result.rows.length === 0) {
698
+ result = run((0, dataCompiler_1.ftsMatch)(args.search, true));
699
+ matched = 'any word';
700
+ }
701
+ }
702
+ else {
703
+ result = run(null);
704
+ }
705
+ }
706
+ catch (error) {
707
+ throw (0, exports.translateSqliteError)(schema, table, error);
708
+ }
709
+ const { rows: out, last, more } = result;
710
+ const includeTruncated = args.include !== undefined && args.include !== null
711
+ ? includeRelations(runner, schema, caller, table, out, args.include, dataTypes_1.DATA_MAX_RESULT_BYTES - result.bytes)
712
+ : false;
713
+ return {
714
+ table: table.name,
715
+ ...((0, dataCompiler_1.isScoped)(caller, table) ? { scope: SCOPE_NOTE } : {}),
716
+ ...(result.total !== undefined ? { total: result.total } : {}),
717
+ ...(matched ? { searchMatched: matched } : {}),
718
+ rows: out,
719
+ ...(includeTruncated
720
+ ? {
721
+ includeTruncated: 'some related rows were left as { id, label } to keep the result under 1 MB; ask for fewer rows'
722
+ }
723
+ : {}),
724
+ ...(more && last
725
+ ? {
726
+ nextCursor: (0, dataCompiler_1.encodeCursor)({
727
+ k: signature,
728
+ v: keys.map((_, i) => last[`__s${i}`] ?? null),
729
+ ...(matched === 'any word' ? { m: 'any' } : {})
730
+ })
731
+ }
732
+ : {})
733
+ };
734
+ };
735
+ exports.query = query;
736
+ // Replaces each relation in `rows` named in `include` with the related row,
737
+ // read as the caller may see it, within `budget` bytes. Returns whether any
738
+ // was left as { id, label } to stay inside it.
739
+ const includeRelations = (runner, schema, caller, table, rows, include, budget) => {
740
+ if (!Array.isArray(include)) {
741
+ throw new dataTypes_1.DataError('"include" is a list of relation columns, e.g. ["customer"].');
742
+ }
743
+ let truncated = false;
744
+ for (const name of include) {
745
+ const column = typeof name === 'string' ? (0, dataCompiler_1.columnFor)(table, name) : undefined;
746
+ if (!column)
747
+ throw (0, dataCompiler_1.unknownColumn)(table, String(name));
748
+ if ((0, dataCompiler_1.isSystemColumn)(column) || column.type !== 'relation' || !column.table) {
749
+ throw new dataTypes_1.DataError(`"${name}" is not a relation column; include takes relation columns.`);
750
+ }
751
+ const target = (0, dataCompiler_1.tableFor)(schema, caller, column.table);
752
+ const ids = [
753
+ ...new Set(rows
754
+ .map(r => r[name]?.id)
755
+ .filter((id) => typeof id === 'number'))
756
+ ];
757
+ if (ids.length === 0)
758
+ continue;
759
+ const scoped = (0, dataCompiler_1.isScoped)(caller, target);
760
+ const joins = new Map();
761
+ const select = selectList(schema, caller, target, joins);
762
+ const fixed = [...(0, dataCompiler_1.joinsParams)(joins), ...(scoped ? [(0, dataCompiler_1.participantOf)(caller)] : [])];
763
+ // Up to 500 ids, so they go in pieces that each stay under 100 values.
764
+ const size = dataTypes_1.DATA_MAX_PARAMS - fixed.length;
765
+ const byId = new Map();
766
+ let spent = 0;
767
+ fetch: for (let i = 0; i < ids.length; i += size) {
768
+ const slice = ids.slice(i, i + size);
769
+ const params = [...(0, dataCompiler_1.joinsParams)(joins), ...slice, ...(scoped ? [(0, dataCompiler_1.participantOf)(caller)] : [])];
770
+ (0, dataCompiler_1.assertParamCount)(params);
771
+ for (const raw of runner.iterate(`SELECT ${select.join(', ')} FROM ${(0, dataIdentifiers_1.quoteIdentifier)(target.name)} t ${(0, dataCompiler_1.joinsSql)(joins)} WHERE t.id IN (${slice
772
+ .map(() => '?')
773
+ .join(', ')})${scoped ? ' AND t.participant_id = ?' : ''}`, params)) {
774
+ const related = shapeRow(schema, target, raw, caller);
775
+ spent += JSON.stringify(related).length;
776
+ if (spent > budget) {
777
+ truncated = true;
778
+ break fetch;
779
+ }
780
+ byId.set(related.id, related);
781
+ }
782
+ }
783
+ for (const row of rows) {
784
+ const ref = row[name];
785
+ if (ref?.id === undefined)
786
+ continue;
787
+ const related = byId.get(ref.id);
788
+ if (!related)
789
+ continue;
790
+ const cost = JSON.stringify(related).length;
791
+ if (cost > budget) {
792
+ truncated = true;
793
+ continue;
794
+ }
795
+ budget -= cost;
796
+ row[name] = related;
797
+ }
798
+ }
799
+ return truncated;
800
+ };
801
+ const AGGREGATE_FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'];
802
+ const aggregate = (runner, ctx, args, where, searching, indexedFilter) => {
803
+ const { schema, caller, table } = ctx;
804
+ const list = Array.isArray(args.aggregate) ? args.aggregate : [args.aggregate];
805
+ if (list.length === 0 || list.length > dataTypes_1.DATA_MAX_AGGREGATES) {
806
+ throw new dataTypes_1.DataError(`"aggregate" is a list of 1 to ${dataTypes_1.DATA_MAX_AGGREGATES} { "fn": ..., "column": ... }.`);
807
+ }
808
+ const selects = [];
809
+ const names = [];
810
+ for (const item of list) {
811
+ if (!isPlainObject(item) ||
812
+ !AGGREGATE_FUNCTIONS.includes(item.fn)) {
813
+ throw new dataTypes_1.DataError(`Each aggregate is { "fn": one of ${AGGREGATE_FUNCTIONS.join(', ')}, "column": ... }.`);
814
+ }
815
+ const fn = item.fn;
816
+ if (fn === 'count' && !item.column) {
817
+ selects.push('count(*)');
818
+ names.push('count');
819
+ continue;
820
+ }
821
+ if (typeof item.column !== 'string') {
822
+ throw new dataTypes_1.DataError(`"${fn}" needs a "column".`);
823
+ }
824
+ const resolved = (0, dataCompiler_1.resolvePath)(ctx, item.column);
825
+ if (fn !== 'count' &&
826
+ fn !== 'min' &&
827
+ fn !== 'max' &&
828
+ !dataDdl_1.NUMERIC_TYPES.has(resolved.column.type)) {
829
+ throw new dataTypes_1.DataError(`${fn} needs a number column; "${item.column}" is ${resolved.column.type}.`);
830
+ }
831
+ // { "fn": "sum", "column": "stock", "times": "unit_cost" }: the sum of a
832
+ // product, e.g. stock value, which rows alone can't give in one call.
833
+ if (item.times !== undefined && item.times !== null) {
834
+ const other = (0, dataCompiler_1.resolvePath)(ctx, item.times);
835
+ if (!dataDdl_1.NUMERIC_TYPES.has(resolved.column.type) ||
836
+ !dataDdl_1.NUMERIC_TYPES.has(other.column.type)) {
837
+ throw new dataTypes_1.DataError('"times" multiplies two number columns.');
838
+ }
839
+ selects.push(`${fn}(${resolved.sql} * ${other.sql})`);
840
+ names.push(`${fn}_${item.column.replace('.', '_')}_times_${String(item.times).replace('.', '_')}`);
841
+ continue;
842
+ }
843
+ selects.push(`${fn}(${resolved.sql})`);
844
+ names.push(`${fn}_${item.column.replace('.', '_')}`);
845
+ }
846
+ const groupBy = args.groupBy === undefined || args.groupBy === null
847
+ ? []
848
+ : Array.isArray(args.groupBy)
849
+ ? args.groupBy
850
+ : [args.groupBy];
851
+ if (groupBy.length > dataTypes_1.DATA_MAX_GROUP_BY) {
852
+ throw new dataTypes_1.DataError(`"groupBy" takes up to ${dataTypes_1.DATA_MAX_GROUP_BY} columns.`);
853
+ }
854
+ const groups = groupBy.map(path => ({
855
+ path: String(path),
856
+ ...(0, dataCompiler_1.resolvePath)(ctx, path)
857
+ }));
858
+ if (!indexedFilter && !searching) {
859
+ assertBounded(runner, table, 'an aggregate over it', groups.find(g => g.own && !(0, dataCompiler_1.isSystemColumn)(g.column))?.column.name);
860
+ }
861
+ const groupSelects = [];
862
+ groups.forEach((g, i) => {
863
+ groupSelects.push(`${g.sql} AS ${(0, dataIdentifiers_1.quoteIdentifier)(`__g${i}`)}`);
864
+ if (g.column.type === 'relation' && !(0, dataCompiler_1.isSystemColumn)(g.column) && g.column.table) {
865
+ const target = schema.tables.find(t => t.name === g.column.table);
866
+ if (target && (0, dataCompiler_1.canRead)(caller, target)) {
867
+ const alias = `g_${i}`;
868
+ ctx.joins.set(alias, (0, dataCompiler_1.relationJoin)(caller, target, alias, g.sql));
869
+ groupSelects.push(`${alias}.${(0, dataIdentifiers_1.quoteIdentifier)(target.displayColumn ?? 'id')} AS ${(0, dataIdentifiers_1.quoteIdentifier)(`__gl${i}`)}`);
870
+ }
871
+ }
872
+ });
873
+ const match = searching ? (0, dataCompiler_1.ftsMatch)(args.search, false) : null;
874
+ const from = `FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t ${match ? searchJoinSql(table) : ''} ${(0, dataCompiler_1.joinsSql)(ctx.joins)}`;
875
+ const params = [...(match ? [match] : []), ...(0, dataCompiler_1.joinsParams)(ctx.joins), ...ctx.params];
876
+ (0, dataCompiler_1.assertParamCount)(params);
877
+ const sql = `SELECT ${[
878
+ ...groupSelects,
879
+ ...selects.map((s, i) => `${s} AS ${(0, dataIdentifiers_1.quoteIdentifier)(`__a${i}`)}`)
880
+ ].join(', ')} ${from} ${where.length ? `WHERE ${where.join(' AND ')}` : ''}${groups.length
881
+ ? ` GROUP BY ${groups.map((_, i) => (0, dataIdentifiers_1.quoteIdentifier)(`__g${i}`)).join(', ')} ORDER BY ${(0, dataIdentifiers_1.quoteIdentifier)('__a0')} DESC`
882
+ : ''} LIMIT ${dataTypes_1.DATA_MAX_GROUPS + 1}`;
883
+ let raw;
884
+ try {
885
+ raw = runner.all(sql, params);
886
+ }
887
+ catch (error) {
888
+ throw (0, exports.translateSqliteError)(schema, table, error);
889
+ }
890
+ const truncated = raw.length > dataTypes_1.DATA_MAX_GROUPS;
891
+ const rows = raw.slice(0, dataTypes_1.DATA_MAX_GROUPS).map(r => {
892
+ const out = {};
893
+ groups.forEach((g, i) => {
894
+ const value = (0, dataCompiler_1.fromStored)(g.column, r[`__g${i}`], caller.timeZone);
895
+ out[g.path] =
896
+ g.column.type === 'relation' && value !== null
897
+ ? { id: value, label: r[`__gl${i}`] ?? null }
898
+ : value;
899
+ });
900
+ names.forEach((name, i) => {
901
+ const value = r[`__a${i}`];
902
+ out[name] =
903
+ typeof value === 'number' ? Math.round(value * 100) / 100 : value;
904
+ });
905
+ return out;
906
+ });
907
+ return {
908
+ table: table.name,
909
+ ...((0, dataCompiler_1.isScoped)(caller, table) ? { scope: SCOPE_NOTE } : {}),
910
+ ...(groups.length ? { groups: rows } : (rows[0] ?? {})),
911
+ ...(truncated
912
+ ? {
913
+ groupsTruncated: `only the ${dataTypes_1.DATA_MAX_GROUPS} largest groups by the first aggregate; filter to see the rest`
914
+ }
915
+ : {})
916
+ };
917
+ };
918
+ // write
919
+ const ACTOR_PATTERN = /^(user|cli|mcp|channel|function|trigger|system):[^\s]{1,200}$/;
920
+ const assertActor = (caller) => {
921
+ if (typeof caller?.actor !== 'string' || !ACTOR_PATTERN.test(caller.actor)) {
922
+ throw new dataTypes_1.DataError('A write needs an actor (user:, cli:, mcp:, channel:, function:, trigger: or system: followed by an id).', 'forbidden');
923
+ }
924
+ };
925
+ exports.assertActor = assertActor;
926
+ const assertWritable = (runner, caller, addsData) => {
927
+ (0, exports.assertActor)(caller);
928
+ if (caller.limits?.readOnly) {
929
+ throw new dataTypes_1.DataError("This organization's plan no longer includes Data, so the database is read-only. Upgrade to Pro to change it.", 'read_only');
930
+ }
931
+ const max = caller.limits?.maxBytes;
932
+ if (addsData && typeof max === 'number' && runner.databaseSize() >= max) {
933
+ throw new dataTypes_1.DataError('The database is full: delete or archive rows to make room. Reads, deletes and exports still work.', 'full', { size: runner.databaseSize(), limit: max });
934
+ }
935
+ };
936
+ exports.assertWritable = assertWritable;
937
+ // Every write names its actor here first, so row history (kept by triggers)
938
+ // records who made each change, cascades included.
939
+ const setContextActor = (runner, actor) => {
940
+ runner.all('INSERT INTO _ganju_context (id, actor) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET actor = excluded.actor WHERE actor IS NOT excluded.actor', [actor]);
941
+ };
942
+ const resolveRefs = (value, results, index) => {
943
+ if (isPlainObject(value) && Object.keys(value).length === 1 && '$ref' in value) {
944
+ const ref = value.$ref;
945
+ const target = typeof ref === 'number' && Number.isInteger(ref) && ref >= 0 && ref < index
946
+ ? results[ref]
947
+ : undefined;
948
+ if (!target || target.op !== 'insert' || !('id' in target)) {
949
+ throw new dataTypes_1.DataError(`{ "$ref": ${JSON.stringify(ref)} } must point at an earlier single-row insert in this list (0-based).`);
950
+ }
951
+ return target.id;
952
+ }
953
+ return value;
954
+ };
955
+ const insertValues = (table, values, results, index, timeZone) => {
956
+ if (!isPlainObject(values)) {
957
+ throw new dataTypes_1.DataError('An insert takes "values", an object of column: value.');
958
+ }
959
+ const out = new Map();
960
+ for (const [name, raw] of Object.entries(values)) {
961
+ if (name === 'id' || name === 'created_at' || name === 'updated_at') {
962
+ throw new dataTypes_1.DataError(`${table.name}.${name} is set by the database; leave it out.`);
963
+ }
964
+ const column = table.columns.find(c => c.name === name);
965
+ if (!column)
966
+ throw (0, dataCompiler_1.unknownColumn)(table, name);
967
+ out.set(name, (0, dataCompiler_1.toStored)(table, column, resolveRefs(raw, results, index), timeZone));
968
+ }
969
+ const missing = table.columns.filter(c => c.required &&
970
+ (c.default === undefined || c.default === null) &&
971
+ !out.has(c.name));
972
+ if (missing.length) {
973
+ throw new dataTypes_1.DataError(`Insert into ${table.name} is missing required ${missing.map(c => c.name).join(', ')}.`);
974
+ }
975
+ return out;
976
+ };
977
+ // A bot may only point a relation at a row it could read: none in a table
978
+ // hidden from it, and only its own customer's in a scoped one. Otherwise it
979
+ // could link an order to another customer and read their name back as the
980
+ // label.
981
+ const assertRelationsVisible = (runner, schema, caller, table, values) => {
982
+ if (!caller.restricted)
983
+ return;
984
+ for (const [name, value] of values) {
985
+ if (value === null)
986
+ continue;
987
+ const column = table.columns.find(c => c.name === name);
988
+ if (column?.type !== 'relation' || !column.table)
989
+ continue;
990
+ const target = schema.tables.find(t => t.name === column.table);
991
+ if (!target)
992
+ continue;
993
+ if (!(0, dataCompiler_1.canRead)(caller, target)) {
994
+ throw new dataTypes_1.DataError(`${table.name}.${name} points at a table the assistant can't use.`, 'forbidden');
995
+ }
996
+ if (!(0, dataCompiler_1.isScoped)(caller, target))
997
+ continue;
998
+ const found = first(runner, `SELECT 1 AS found FROM ${(0, dataIdentifiers_1.quoteIdentifier)(target.name)} WHERE id = ? AND participant_id = ?`, [value, (0, dataCompiler_1.participantOf)(caller)]);
999
+ if (!found) {
1000
+ throw new dataTypes_1.DataError(`${table.name}.${name}: there is no row ${value} in ${target.name}.`, 'not_found');
1001
+ }
1002
+ }
1003
+ };
1004
+ const targetWhere = (ctx, op) => {
1005
+ const { table, caller } = ctx;
1006
+ if (op.id === undefined && op.where === undefined) {
1007
+ throw new dataTypes_1.DataError(`${op.op} on ${table.name} needs "id" or "where".`);
1008
+ }
1009
+ const clauses = [];
1010
+ if (op.id !== undefined) {
1011
+ if (typeof op.id !== 'number' || !Number.isInteger(op.id)) {
1012
+ throw new dataTypes_1.DataError('"id" is a whole number.');
1013
+ }
1014
+ clauses.push('t.id = ?');
1015
+ ctx.params.push(op.id);
1016
+ }
1017
+ clauses.push(...(0, dataCompiler_1.compileWhere)(ctx, op.where));
1018
+ if (clauses.length === 0) {
1019
+ throw new dataTypes_1.DataError(`${op.op} on ${table.name} needs a non-empty "where".`);
1020
+ }
1021
+ if (ctx.joins.size > 0) {
1022
+ throw new dataTypes_1.DataError(`${op.op}: "where" on a write can't use relation paths; filter on this table's own columns.`);
1023
+ }
1024
+ if (op.version !== undefined) {
1025
+ if (typeof op.version !== 'number' || !Number.isInteger(op.version)) {
1026
+ throw new dataTypes_1.DataError('"version" is the whole number the row was read with.');
1027
+ }
1028
+ clauses.push('t.version = ?');
1029
+ ctx.params.push(op.version);
1030
+ }
1031
+ if ((0, dataCompiler_1.isScoped)(caller, table)) {
1032
+ clauses.push('t.participant_id = ?');
1033
+ ctx.params.push((0, dataCompiler_1.participantOf)(caller));
1034
+ }
1035
+ return clauses.join(' AND ');
1036
+ };
1037
+ // The current values of the columns a guard named, for the row an id named,
1038
+ // so "not enough stock" says how much there is.
1039
+ const explainGuard = (runner, ctx, op, names) => {
1040
+ if (typeof op.id !== 'number')
1041
+ return '';
1042
+ const { table, caller } = ctx;
1043
+ const scoped = (0, dataCompiler_1.isScoped)(caller, table);
1044
+ const row = first(runner, `SELECT * FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE id = ?${scoped ? ' AND participant_id = ?' : ''}`, [op.id, ...(scoped ? [(0, dataCompiler_1.participantOf)(caller)] : [])]);
1045
+ if (!row)
1046
+ return ` Row ${op.id} doesn't exist in ${table.name}.`;
1047
+ const shown = names.filter(n => n in row && table.columns.some(c => c.name === n));
1048
+ if (shown.length === 0)
1049
+ return '';
1050
+ return ` Current values: ${shown.map(n => `${n} = ${JSON.stringify(row[n])}`).join(', ')}.`;
1051
+ };
1052
+ const versionConflict = (runner, schema, ctx, op) => {
1053
+ if (op.version === undefined || typeof op.id !== 'number')
1054
+ return null;
1055
+ const { table, caller } = ctx;
1056
+ // The row as this caller may read it: a bot sending a stale version for
1057
+ // another customer's id learns nothing.
1058
+ const scoped = (0, dataCompiler_1.isScoped)(caller, table);
1059
+ const joins = new Map();
1060
+ const select = selectList(schema, caller, table, joins);
1061
+ const row = first(runner, `SELECT ${select.join(', ')}, t.updated_by FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t ${(0, dataCompiler_1.joinsSql)(joins)} WHERE t.id = ?${scoped ? ' AND t.participant_id = ?' : ''}`, [...(0, dataCompiler_1.joinsParams)(joins), op.id, ...(scoped ? [(0, dataCompiler_1.participantOf)(caller)] : [])]);
1062
+ if (!row || Number(row.version) === op.version)
1063
+ return null;
1064
+ return new dataTypes_1.DataError(`Row ${op.id} in ${table.name} was changed since it was read (now version ${row.version}).`, 'conflict', {
1065
+ current: shapeRow(schema, table, row, caller),
1066
+ version: Number(row.version),
1067
+ updatedBy: row.updated_by
1068
+ });
1069
+ };
1070
+ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1071
+ if (!isPlainObject(rawOp))
1072
+ throw new dataTypes_1.DataError('An operation is an object.');
1073
+ const kind = rawOp.op;
1074
+ if (kind !== 'insert' && kind !== 'update' && kind !== 'delete') {
1075
+ throw new dataTypes_1.DataError('"op" is insert, update or delete.');
1076
+ }
1077
+ // An update or delete can name a row inserted earlier in the same write,
1078
+ // into the same table. In values a $ref fills a relation to another table;
1079
+ // as the row's own id, another table's id would quietly hit an unrelated
1080
+ // row.
1081
+ let op = rawOp;
1082
+ if (kind !== 'insert' && rawOp.id !== undefined) {
1083
+ const id = resolveRefs(rawOp.id, results, index);
1084
+ if (id !== rawOp.id) {
1085
+ const source = results[rawOp.id.$ref];
1086
+ if (source.table !== rawOp.table) {
1087
+ throw new dataTypes_1.DataError(`"id": { "$ref": ${rawOp.id.$ref} } points at a row inserted into ${source.table}; a $ref as the id must name an insert into ${String(rawOp.table)}.`);
1088
+ }
1089
+ }
1090
+ op = { ...rawOp, id };
1091
+ }
1092
+ const table = (0, dataCompiler_1.tableFor)(schema, caller, op.table, kind === 'insert' ? 'add' : 'full');
1093
+ const actor = caller.actor;
1094
+ const scoped = (0, dataCompiler_1.isScoped)(caller, table);
1095
+ if (kind === 'insert') {
1096
+ const many = op.rows !== undefined;
1097
+ if (many && op.values !== undefined) {
1098
+ throw new dataTypes_1.DataError('An insert takes "values" (one row) or "rows" (a list), not both.');
1099
+ }
1100
+ const list = many ? op.rows : [op.values];
1101
+ if (!Array.isArray(list) || list.length === 0 || list.length > dataTypes_1.DATA_MAX_INSERT_ROWS) {
1102
+ throw new dataTypes_1.DataError(`"rows" is a list of 1 to ${dataTypes_1.DATA_MAX_INSERT_ROWS} rows.`);
1103
+ }
1104
+ const prepared = list.map((values, i) => {
1105
+ try {
1106
+ const stored = insertValues(table, values, results, index, caller.timeZone);
1107
+ assertRelationsVisible(runner, schema, caller, table, stored);
1108
+ return stored;
1109
+ }
1110
+ catch (error) {
1111
+ if (many && error instanceof dataTypes_1.DataError) {
1112
+ throw new dataTypes_1.DataError(`Row ${i}: ${error.message}`, error.code, error.details);
1113
+ }
1114
+ throw error;
1115
+ }
1116
+ });
1117
+ // One column list for the whole batch, so rows that leave a column out
1118
+ // get NULL there rather than its default only when another row sets it.
1119
+ const names = [...new Set(prepared.flatMap(values => [...values.keys()]))];
1120
+ const columns = [...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1121
+ const ids = [];
1122
+ try {
1123
+ for (const chunk of (0, dataCompiler_1.chunkRows)(prepared, columns.length)) {
1124
+ const usesDefaults = chunk.some(values => names.some(n => !values.has(n)));
1125
+ if (usesDefaults && chunk.length > 1) {
1126
+ // Rows with different columns go one by one, so each missing
1127
+ // column takes its default.
1128
+ for (const values of chunk)
1129
+ ids.push(insertOne(runner, table, values, actor, scoped, caller));
1130
+ continue;
1131
+ }
1132
+ const params = [];
1133
+ for (const values of chunk) {
1134
+ for (const name of names)
1135
+ params.push(values.get(name) ?? null);
1136
+ params.push(actor, actor);
1137
+ if (scoped)
1138
+ params.push((0, dataCompiler_1.participantOf)(caller));
1139
+ }
1140
+ const rows = runner.all(`INSERT INTO ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} (${columns.map(dataIdentifiers_1.quoteIdentifier).join(', ')}) VALUES ${chunk
1141
+ .map(() => `(${columns.map(() => '?').join(', ')})`)
1142
+ .join(', ')} RETURNING id`, params);
1143
+ ids.push(...rows.map(r => Number(r.id)));
1144
+ }
1145
+ }
1146
+ catch (error) {
1147
+ throw (0, exports.translateSqliteError)(schema, table, error);
1148
+ }
1149
+ return many
1150
+ ? { op: 'insert', table: table.name, ids }
1151
+ : { op: 'insert', table: table.name, id: ids[0] };
1152
+ }
1153
+ const ctx = { schema, caller, table, joins: new Map(), params: [] };
1154
+ const where = targetWhere(ctx, op);
1155
+ const expect = op.expect === undefined ? (op.id !== undefined ? 1 : undefined) : op.expect;
1156
+ if (expect !== undefined &&
1157
+ (typeof expect !== 'number' || !Number.isInteger(expect) || expect < 0)) {
1158
+ throw new dataTypes_1.DataError('"expect" is the number of rows that must change.');
1159
+ }
1160
+ // An update or delete by "where" counts what it would touch first, so a
1161
+ // runaway one is refused before it writes (and bills) anything.
1162
+ const assertBoundedTarget = () => {
1163
+ if (op.id !== undefined)
1164
+ return [];
1165
+ (0, dataCompiler_1.assertParamCount)(ctx.params);
1166
+ const ids = runner
1167
+ .all(`SELECT t.id FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t WHERE ${where} LIMIT ${dataTypes_1.DATA_MAX_ROWS_PER_WRITE + 1}`, ctx.params)
1168
+ .map(r => Number(r.id));
1169
+ if (ids.length > dataTypes_1.DATA_MAX_ROWS_PER_WRITE) {
1170
+ throw new dataTypes_1.DataError(`This ${kind} would ${kind === 'update' ? 'change' : 'remove'} more than ${dataTypes_1.DATA_MAX_ROWS_PER_WRITE.toLocaleString('en-US')} rows; narrow the "where".`, 'too_large');
1171
+ }
1172
+ return ids;
1173
+ };
1174
+ let changed;
1175
+ if (kind === 'update') {
1176
+ if (!isPlainObject(op.set) || Object.keys(op.set).length === 0) {
1177
+ throw new dataTypes_1.DataError('An update takes "set", an object of column: value.');
1178
+ }
1179
+ const sets = [];
1180
+ const setParams = [];
1181
+ const absolute = new Map();
1182
+ for (const [name, raw] of Object.entries(op.set)) {
1183
+ const column = table.columns.find(c => c.name === name);
1184
+ if (!column) {
1185
+ throw (0, dataCompiler_1.columnFor)(table, name)
1186
+ ? new dataTypes_1.DataError(`${table.name}.${name} is set by the database.`)
1187
+ : (0, dataCompiler_1.unknownColumn)(table, name);
1188
+ }
1189
+ const value = resolveRefs(raw, results, index);
1190
+ if (isPlainObject(value) && column.type !== 'json') {
1191
+ const entries = Object.entries(value);
1192
+ const [how, amount] = entries[0] ?? [];
1193
+ if (entries.length !== 1 || (how !== 'increment' && how !== 'decrement')) {
1194
+ throw new dataTypes_1.DataError(`${table.name}.${name}: set a value, or { "increment": n } / { "decrement": n }.`);
1195
+ }
1196
+ if (!dataDdl_1.NUMERIC_TYPES.has(column.type) || typeof amount !== 'number' || !Number.isFinite(amount)) {
1197
+ throw new dataTypes_1.DataError(`${how} needs a number column and a number.`);
1198
+ }
1199
+ if (column.type === 'integer' && !Number.isInteger(amount)) {
1200
+ throw new dataTypes_1.DataError(`${table.name}.${name} is a whole number; ${how} by a whole number.`);
1201
+ }
1202
+ sets.push(`${(0, dataIdentifiers_1.quoteIdentifier)(name)} = ${(0, dataIdentifiers_1.quoteIdentifier)(name)} ${how === 'increment' ? '+' : '-'} ?`);
1203
+ setParams.push(amount);
1204
+ }
1205
+ else {
1206
+ const stored = (0, dataCompiler_1.toStored)(table, column, value, caller.timeZone);
1207
+ sets.push(`${(0, dataIdentifiers_1.quoteIdentifier)(name)} = ?`);
1208
+ setParams.push(stored);
1209
+ absolute.set(name, stored);
1210
+ }
1211
+ }
1212
+ assertRelationsVisible(runner, schema, caller, table, absolute);
1213
+ assertBoundedTarget();
1214
+ sets.push('version = version + 1', `updated_at = ${dataDdl_1.DATA_NOW_SQL}`, 'updated_by = ?');
1215
+ setParams.push(caller.actor);
1216
+ const params = [...setParams, ...ctx.params];
1217
+ (0, dataCompiler_1.assertParamCount)(params);
1218
+ try {
1219
+ changed = runner.all(`UPDATE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} AS t SET ${sets.join(', ')} WHERE ${where} RETURNING id`, params).length;
1220
+ }
1221
+ catch (error) {
1222
+ const translated = (0, exports.translateSqliteError)(schema, table, error);
1223
+ if (translated.message.includes("can't go below")) {
1224
+ throw new dataTypes_1.DataError(`${translated.message}${explainGuard(runner, ctx, op, Object.keys(op.set))}`, 'guard');
1225
+ }
1226
+ throw translated;
1227
+ }
1228
+ }
1229
+ else {
1230
+ (0, dataCompiler_1.assertParamCount)(ctx.params);
1231
+ const ids = op.id !== undefined
1232
+ ? runner
1233
+ .all(`SELECT t.id FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t WHERE ${where}`, ctx.params)
1234
+ .map(r => Number(r.id))
1235
+ : assertBoundedTarget();
1236
+ // A readable "3 rows in order_lines still point at this order" before
1237
+ // SQLite's bare foreign key error. A row of the same table counts even
1238
+ // when this delete removes it too: SQLite checks a restrict as each row
1239
+ // goes, so a child still there when its parent is deleted refuses it.
1240
+ for (const other of schema.tables) {
1241
+ for (const c of other.columns) {
1242
+ if (c.type !== 'relation' || c.table !== table.name)
1243
+ continue;
1244
+ if ((c.onDelete ?? 'restrict') !== 'restrict' || ids.length === 0)
1245
+ continue;
1246
+ let n = 0;
1247
+ for (let i = 0; i < ids.length; i += dataTypes_1.DATA_MAX_PARAMS) {
1248
+ const slice = ids.slice(i, i + dataTypes_1.DATA_MAX_PARAMS);
1249
+ n += Number(first(runner, `SELECT count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(other.name)} WHERE ${(0, dataIdentifiers_1.quoteIdentifier)(c.name)} IN (${slice.map(() => '?').join(', ')})`, slice)?.n ?? 0);
1250
+ }
1251
+ if (n > 0) {
1252
+ throw new dataTypes_1.DataError(`Can't delete: ${n} row${n === 1 ? '' : 's'} in ${other.name} still point${n === 1 ? 's' : ''} at ${ids.length === 1 ? 'this row' : 'these rows'} (${other.name}.${c.name}).`, 'constraint', { table: other.name, column: c.name, count: n });
1253
+ }
1254
+ }
1255
+ }
1256
+ try {
1257
+ changed = runner.all(`DELETE FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} AS t WHERE ${where} RETURNING id`, ctx.params).length;
1258
+ }
1259
+ catch (error) {
1260
+ throw (0, exports.translateSqliteError)(schema, table, error);
1261
+ }
1262
+ }
1263
+ if (kind === 'update' && changed > dataTypes_1.DATA_MAX_ROWS_PER_WRITE) {
1264
+ throw new dataTypes_1.DataError(`This update would change more than ${dataTypes_1.DATA_MAX_ROWS_PER_WRITE.toLocaleString('en-US')} rows; narrow the "where".`, 'too_large');
1265
+ }
1266
+ if (expect !== undefined && changed !== expect) {
1267
+ const conflict = versionConflict(runner, schema, ctx, op);
1268
+ if (conflict)
1269
+ throw conflict;
1270
+ throw new dataTypes_1.DataError(`${kind} on ${table.name} changed ${changed} row${changed === 1 ? '' : 's'}, expected ${expect}; nothing was written.${explainGuard(runner, ctx, op, Object.keys(isPlainObject(op.where) ? op.where : {}))}`, 'guard');
1271
+ }
1272
+ return { op: kind, table: table.name, changed };
1273
+ };
1274
+ const insertOne = (runner, table, values, actor, scoped, caller) => {
1275
+ const names = [...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1276
+ const params = [...values.values(), actor, actor, ...(scoped ? [(0, dataCompiler_1.participantOf)(caller)] : [])];
1277
+ return Number(runner.all(`INSERT INTO ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} (${names.map(dataIdentifiers_1.quoteIdentifier).join(', ')}) VALUES (${names.map(() => '?').join(', ')}) RETURNING id`, params)[0]?.id);
1278
+ };
1279
+ // A list of inserts, updates and deletes across tables, applied together or
1280
+ // not at all. A guard that fails rolls every operation back.
1281
+ const write = (runner, schema, caller, rawArgs) => {
1282
+ const operations = (0, dataCompiler_1.tidyArgs)(rawArgs).operations;
1283
+ if (!Array.isArray(operations) ||
1284
+ operations.length === 0 ||
1285
+ operations.length > dataTypes_1.DATA_MAX_WRITE_OPERATIONS) {
1286
+ throw new dataTypes_1.DataError(`"operations" is a list of 1 to ${dataTypes_1.DATA_MAX_WRITE_OPERATIONS} insert, update or delete operations.`);
1287
+ }
1288
+ const addsData = operations.some(op => !isPlainObject(op) || op.op !== 'delete');
1289
+ (0, exports.assertWritable)(runner, caller, addsData);
1290
+ return runner.transaction(() => {
1291
+ setContextActor(runner, caller.actor);
1292
+ const results = [];
1293
+ operations.forEach((op, index) => {
1294
+ try {
1295
+ results.push(runOperation(runner, schema, caller, op, index, results));
1296
+ }
1297
+ catch (error) {
1298
+ if (error instanceof dataTypes_1.DataError) {
1299
+ const label = isPlainObject(op) ? `${String(op.op)} ${String(op.table)}` : '?';
1300
+ throw new dataTypes_1.DataError(`Operation ${index} (${label}): ${error.message}`, error.code, { ...error.details, operation: index });
1301
+ }
1302
+ throw error;
1303
+ }
1304
+ });
1305
+ return { ok: true, results };
1306
+ });
1307
+ };
1308
+ exports.write = write;
1309
+ // Delete preview
1310
+ // What deleting `ids` of a table, or all its rows, would do to the rows that
1311
+ // point at them, counted the way SQLite applies it: cascades are followed to
1312
+ // the end in one recursive query, which stops at DATA_IMPACT_MAX_ROWS rows.
1313
+ // Without ids the table's own rows are never read: every non-empty relation
1314
+ // to it points at a deleted row, and the cascades start from those. `rows`
1315
+ // is then an estimate. For the owner: a bot's deletes go through the
1316
+ // confirmation rules instead.
1317
+ const deleteImpact = (runner, schema, caller, rawArgs) => {
1318
+ (0, exports.assertActor)(caller);
1319
+ if (caller.restricted) {
1320
+ throw new dataTypes_1.DataError('A delete preview is for the owner.', 'forbidden');
1321
+ }
1322
+ const args = (0, dataCompiler_1.tidyArgs)(rawArgs);
1323
+ const table = (0, dataCompiler_1.tableFor)(schema, caller, args.table);
1324
+ let ids = null;
1325
+ if (args.ids !== undefined && args.ids !== null) {
1326
+ if (!Array.isArray(args.ids) ||
1327
+ !args.ids.every(id => Number.isSafeInteger(id) && id > 0)) {
1328
+ throw new dataTypes_1.DataError('"ids" lists row ids; leave it out for every row.');
1329
+ }
1330
+ if (args.ids.length > dataTypes_1.DATA_IMPACT_MAX_IDS) {
1331
+ throw new dataTypes_1.DataError(`A delete preview takes up to ${dataTypes_1.DATA_IMPACT_MAX_IDS} ids.`, 'too_large');
1332
+ }
1333
+ ids = [...new Set(args.ids)];
1334
+ }
1335
+ const incoming = (name) => schema.tables.flatMap(other => other.columns
1336
+ .filter(c => c.type === 'relation' && c.table === name)
1337
+ .map(column => ({ other, column, onDelete: column.onDelete ?? 'restrict' })));
1338
+ // The tables a cascade reaches, each at its nearest depth.
1339
+ const depth = new Map([[table.id, 0]]);
1340
+ const reached = [table];
1341
+ for (let i = 0; i < reached.length; i++) {
1342
+ for (const edge of incoming(reached[i].name)) {
1343
+ if (edge.onDelete !== 'cascade' || depth.has(edge.other.id))
1344
+ continue;
1345
+ depth.set(edge.other.id, depth.get(reached[i].id) + 1);
1346
+ reached.push(edge.other);
1347
+ }
1348
+ }
1349
+ const doomedIn = (t) => `(SELECT id FROM doomed WHERE t = ${t.id})`;
1350
+ // The rows of `other` whose `column` points at a deleted row of `parent`.
1351
+ const pointing = (parent, column) => !ids && parent.id === table.id
1352
+ ? `c.${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} IS NOT NULL`
1353
+ : `c.${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} IN ${doomedIn(parent)}`;
1354
+ const base = ids
1355
+ ? [`SELECT ${table.id}, id FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE id IN (SELECT value FROM json_each(?))`]
1356
+ : incoming(table.name)
1357
+ .filter(edge => edge.onDelete === 'cascade' && edge.other.id !== table.id)
1358
+ .map(({ other, column }) => `SELECT ${other.id}, c.id FROM ${(0, dataIdentifiers_1.quoteIdentifier)(other.name)} c WHERE ${pointing(table, column)}`);
1359
+ const recursive = reached.flatMap(parent => incoming(parent.name)
1360
+ .filter(edge => edge.onDelete === 'cascade')
1361
+ .map(({ other, column }) => `SELECT ${other.id}, c.id FROM ${(0, dataIdentifiers_1.quoteIdentifier)(other.name)} c JOIN doomed d ON d.t = ${parent.id} AND c.${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} = d.id`));
1362
+ // Without ids, a cascade or a set null on the table's own rows doesn't
1363
+ // matter: they all go. A restrict to itself still refuses the delete.
1364
+ const edges = reached.flatMap(parent => incoming(parent.name)
1365
+ .filter(edge => ids || edge.other.id !== table.id || edge.onDelete === 'restrict')
1366
+ .map(edge => ({ ...edge, parent })));
1367
+ const counts = [
1368
+ `SELECT -1 AS k, count(*) AS n FROM doomed`,
1369
+ `SELECT -2 AS k, count(*) AS n FROM doomed WHERE t = ${table.id}`,
1370
+ ...edges.map(({ other, column, onDelete, parent }, i) => `SELECT ${i} AS k, count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(other.name)} c WHERE ${pointing(parent, column)}${
1371
+ // A cleared row that is deleted anyway isn't cleared. A restrict
1372
+ // counts every row, even one the same delete removes: SQLite
1373
+ // checks it as each row goes, before the rest are gone.
1374
+ onDelete === 'set_null' ? ` AND c.id NOT IN ${doomedIn(other)}` : ''}`)
1375
+ ];
1376
+ const sql = `WITH RECURSIVE doomed(t, id) AS MATERIALIZED (
1377
+ ${base.length ? base.join('\n UNION ') : 'SELECT 0, 0 WHERE 0'}
1378
+ ${recursive.map(s => `UNION ${s}`).join('\n ')}
1379
+ LIMIT ${dataTypes_1.DATA_IMPACT_MAX_ROWS}
1380
+ )
1381
+ ${counts.join('\nUNION ALL ')}`;
1382
+ const n = new Map(runner.all(sql, ids ? [JSON.stringify(ids)] : []).map(row => [Number(row.k), Number(row.n)]));
1383
+ const effects = edges
1384
+ .map(({ other, column, onDelete, parent }, i) => ({
1385
+ table: other.name,
1386
+ column: column.name,
1387
+ onDelete,
1388
+ rows: n.get(i) ?? 0,
1389
+ depth: depth.get(parent.id)
1390
+ }))
1391
+ .filter(effect => effect.rows > 0)
1392
+ .sort((a, b) => a.depth - b.depth);
1393
+ return {
1394
+ table: table.name,
1395
+ rows: ids ? (n.get(-2) ?? 0) : estimateRows(runner, table),
1396
+ effects,
1397
+ blocked: effects.some(e => e.onDelete === 'restrict'),
1398
+ truncated: (n.get(-1) ?? 0) >= dataTypes_1.DATA_IMPACT_MAX_ROWS
1399
+ };
1400
+ };
1401
+ exports.deleteImpact = deleteImpact;
1402
+ // Row history
1403
+ const rowHistory = (runner, schema, caller, args) => {
1404
+ const table = (0, dataCompiler_1.tableFor)(schema, caller, args.table);
1405
+ if (!Number.isInteger(args.id))
1406
+ throw new dataTypes_1.DataError('"id" is a whole number.');
1407
+ // A bot reads the history of its own customer's rows only, and of rows
1408
+ // that still exist: a deleted row's owner can't be told apart.
1409
+ if ((0, dataCompiler_1.isScoped)(caller, table) &&
1410
+ !first(runner, `SELECT 1 AS found FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE id = ? AND participant_id = ?`, [args.id, (0, dataCompiler_1.participantOf)(caller)])) {
1411
+ throw new dataTypes_1.DataError(`There is no row ${args.id} in ${table.name}.`, 'not_found');
1412
+ }
1413
+ const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
1414
+ const entries = runner.all(`SELECT id, op, actor, at, changes FROM _ganju_row_log WHERE table_id = ? AND row_id = ?${args.cursor ? ' AND id < ?' : ''} ORDER BY id DESC LIMIT ?`, [table.id, args.id, ...(args.cursor ? [args.cursor] : []), limit + 1]);
1415
+ const page = entries.slice(0, limit);
1416
+ return {
1417
+ table: table.name,
1418
+ id: args.id,
1419
+ entries: page.map(e => ({
1420
+ op: e.op,
1421
+ actor: e.actor,
1422
+ at: (0, dataCompiler_1.zoned)(String(e.at), caller.timeZone),
1423
+ changes: parseJson(e.changes) ?? {}
1424
+ })),
1425
+ ...(entries.length > limit ? { nextCursor: Number(page[page.length - 1].id) } : {})
1426
+ };
1427
+ };
1428
+ exports.rowHistory = rowHistory;
1429
+ // Deletes history older than the retention window, a bounded batch at a
1430
+ // time. Returns how many entries went, so the caller knows whether to go on.
1431
+ const pruneHistory = (runner, now = new Date(), batch = 5_000) => {
1432
+ const cutoff = new Date(now.getTime() - dataTypes_1.DATA_HISTORY_DAYS * 86_400_000)
1433
+ .toISOString()
1434
+ .replace(/\.\d{3}Z$/, 'Z');
1435
+ return runner.all('DELETE FROM _ganju_row_log WHERE id IN (SELECT id FROM _ganju_row_log WHERE at < ? ORDER BY id LIMIT ?) RETURNING id', [cutoff, batch]).length;
1436
+ };
1437
+ exports.pruneHistory = pruneHistory;
1438
+ // Schema changes
1439
+ const insertColumnMeta = (runner, schema, tableId, tableName, column, position, selfId) => {
1440
+ const relationTableId = column.type === 'relation'
1441
+ ? column.table === tableName
1442
+ ? selfId
1443
+ : (schema.tables.find(t => t.name === column.table)?.id ?? null)
1444
+ : null;
1445
+ return Number(runner.all(`INSERT INTO _ganju_columns (table_id, name, label, description, type, required, is_unique, filterable, searchable, personal, enum_values, relation_table_id, on_delete, min, default_value, position)
1446
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`, [
1447
+ tableId,
1448
+ column.name,
1449
+ column.label,
1450
+ column.description,
1451
+ column.type,
1452
+ column.required ? 1 : 0,
1453
+ column.unique ? 1 : 0,
1454
+ column.filterable ? 1 : 0,
1455
+ column.searchable ? 1 : 0,
1456
+ column.personal ? 1 : 0,
1457
+ column.values ? JSON.stringify(column.values) : null,
1458
+ relationTableId,
1459
+ column.onDelete,
1460
+ column.min,
1461
+ column.default === undefined ? null : JSON.stringify({ v: column.default }),
1462
+ position
1463
+ ])[0].id);
1464
+ };
1465
+ const updateColumnMeta = (runner, schema, table, id, column) => {
1466
+ const relationTableId = column.type === 'relation'
1467
+ ? column.table === table.name
1468
+ ? table.id
1469
+ : (schema.tables.find(t => t.name === column.table)?.id ?? null)
1470
+ : null;
1471
+ runner.all(`UPDATE _ganju_columns SET name = ?, label = ?, description = ?, type = ?, required = ?, is_unique = ?, filterable = ?, searchable = ?, personal = ?, enum_values = ?, relation_table_id = ?, on_delete = ?, min = ?, default_value = ?, updated_at = ${dataDdl_1.DATA_NOW_SQL} WHERE id = ?`, [
1472
+ column.name,
1473
+ column.label,
1474
+ column.description,
1475
+ column.type,
1476
+ column.required ? 1 : 0,
1477
+ column.unique ? 1 : 0,
1478
+ column.filterable ? 1 : 0,
1479
+ column.searchable ? 1 : 0,
1480
+ column.personal ? 1 : 0,
1481
+ column.values ? JSON.stringify(column.values) : null,
1482
+ relationTableId,
1483
+ column.onDelete,
1484
+ column.min,
1485
+ column.default === undefined ? null : JSON.stringify({ v: column.default }),
1486
+ id
1487
+ ]);
1488
+ };
1489
+ const touchTable = (runner, tableId) => runner.all(`UPDATE _ganju_tables SET updated_at = ${dataDdl_1.DATA_NOW_SQL} WHERE id = ?`, [tableId]);
1490
+ const setDisplayColumn = (runner, tableId, name) => {
1491
+ runner.all('UPDATE _ganju_tables SET display_column_id = (SELECT id FROM _ganju_columns WHERE table_id = ? AND name = ?) WHERE id = ?', [tableId, name, tableId]);
1492
+ };
1493
+ const reload = (runner, name) => tableNamed((0, exports.loadSchema)(runner), name);
1494
+ const assertDdlAllowed = (caller) => {
1495
+ (0, exports.assertActor)(caller);
1496
+ if (caller.limits?.readOnly) {
1497
+ throw new dataTypes_1.DataError("This organization's plan no longer includes Data, so the database is read-only. Upgrade to Pro to change it.", 'read_only');
1498
+ }
1499
+ };
1500
+ const createTable = (runner, schema, caller, input) => {
1501
+ assertDdlAllowed(caller);
1502
+ if (!isPlainObject(input))
1503
+ throw new dataTypes_1.DataError('A table is an object: { "name", "columns" }.');
1504
+ const nameIssue = (0, dataIdentifiers_1.identifierIssue)(input.name, 'table');
1505
+ if (nameIssue)
1506
+ throw new dataTypes_1.DataError(nameIssue);
1507
+ const name = input.name;
1508
+ if (schema.tables.some(t => t.name === name)) {
1509
+ throw new dataTypes_1.DataError(`There is already a table "${name}".`, 'conflict');
1510
+ }
1511
+ const maxTables = Math.min(caller.limits?.maxTables ?? dataTypes_1.DATA_MAX_TABLES, dataTypes_1.DATA_MAX_TABLES);
1512
+ if (schema.tables.length >= maxTables) {
1513
+ throw new dataTypes_1.DataError(`A database holds up to ${maxTables} tables.`, 'too_large', {
1514
+ limit: maxTables
1515
+ });
1516
+ }
1517
+ if (!Array.isArray(input.columns) || input.columns.length === 0) {
1518
+ throw new dataTypes_1.DataError(`${name}: "columns" lists at least one column.`);
1519
+ }
1520
+ if (input.columns.length > dataTypes_1.DATA_MAX_COLUMNS) {
1521
+ throw new dataTypes_1.DataError(`A table has up to ${dataTypes_1.DATA_MAX_COLUMNS} columns.`, 'too_large');
1522
+ }
1523
+ const columns = input.columns.map(c => (0, exports.normaliseColumn)(c, schema, name));
1524
+ const names = new Set();
1525
+ for (const c of columns) {
1526
+ if (names.has(c.name))
1527
+ throw new dataTypes_1.DataError(`${name}.${c.name} is listed twice.`);
1528
+ names.add(c.name);
1529
+ }
1530
+ // "id" is what a table without a display column shows anyway. Left out,
1531
+ // it is the first text column, so relations to the table show "Margherita"
1532
+ // rather than an id; null keeps the id.
1533
+ const displayColumn = input.displayColumn === 'id'
1534
+ ? null
1535
+ : input.displayColumn === undefined
1536
+ ? (columns.find(c => c.type === 'text')?.name ?? null)
1537
+ : input.displayColumn;
1538
+ if (displayColumn !== null && !names.has(String(displayColumn))) {
1539
+ throw new dataTypes_1.DataError(`"displayColumn" is one of ${name}'s columns.`);
1540
+ }
1541
+ const access = normaliseAccess(input.access, 'read');
1542
+ const customerScoped = flag(input.customerScoped, 'customerScoped');
1543
+ const history = input.history === undefined ? true : flag(input.history, 'history');
1544
+ return runner.transaction(() => {
1545
+ const position = Number(first(runner, 'SELECT coalesce(max(position), -1) + 1 AS p FROM _ganju_tables')?.p ?? 0);
1546
+ // Never an id a dropped table had: its row history stays in the log
1547
+ // (a restore to the drop's bookmark brings both back), and a new table
1548
+ // reusing the id would show that history as its own.
1549
+ const nextId = Number(first(runner, `SELECT max(
1550
+ coalesce((SELECT max(id) FROM _ganju_tables), 0),
1551
+ coalesce((SELECT max(table_id) FROM _ganju_row_log), 0)
1552
+ ) + 1 AS id`)?.id ?? 1);
1553
+ const tableId = Number(runner.all('INSERT INTO _ganju_tables (id, name, label, description, access, customer_scoped, history, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id', [
1554
+ nextId,
1555
+ name,
1556
+ cleanText(input.label, dataTypes_1.DATA_MAX_LABEL, `${name} label`),
1557
+ cleanText(input.description, dataTypes_1.DATA_MAX_DESCRIPTION, `${name} description`),
1558
+ access,
1559
+ customerScoped ? 1 : 0,
1560
+ history ? 1 : 0,
1561
+ position
1562
+ ])[0].id);
1563
+ const stored = columns.map((c, i) => ({
1564
+ ...c,
1565
+ id: insertColumnMeta(runner, schema, tableId, name, c, i, tableId),
1566
+ position: i
1567
+ }));
1568
+ if (displayColumn !== null)
1569
+ setDisplayColumn(runner, tableId, String(displayColumn));
1570
+ runner.all((0, dataDdl_1.createTableSql)(name, stored));
1571
+ const table = reload(runner, name);
1572
+ syncTableObjects(runner, table);
1573
+ return table;
1574
+ });
1575
+ };
1576
+ exports.createTable = createTable;
1577
+ const updateTable = (runner, schema, caller, name, patch) => {
1578
+ assertDdlAllowed(caller);
1579
+ const table = tableNamed(schema, name);
1580
+ if (!isPlainObject(patch))
1581
+ throw new dataTypes_1.DataError('The change is an object.');
1582
+ const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history'];
1583
+ for (const key of Object.keys(patch)) {
1584
+ if (!allowed.includes(key)) {
1585
+ throw new dataTypes_1.DataError(`"${key}" can't be changed here; tables take ${allowed.join(', ')}.`);
1586
+ }
1587
+ }
1588
+ return runner.transaction(() => {
1589
+ let current = table.name;
1590
+ if (patch.name !== undefined && patch.name !== table.name) {
1591
+ const issue = (0, dataIdentifiers_1.identifierIssue)(patch.name, 'table');
1592
+ if (issue)
1593
+ throw new dataTypes_1.DataError(issue);
1594
+ if (schema.tables.some(t => t.name === patch.name)) {
1595
+ throw new dataTypes_1.DataError(`There is already a table "${String(patch.name)}".`, 'conflict');
1596
+ }
1597
+ dropTriggers(runner, table);
1598
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)((0, dataDdl_1.ftsTableName)(table))}`);
1599
+ runner.all(`ALTER TABLE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} RENAME TO ${(0, dataIdentifiers_1.quoteIdentifier)(patch.name)}`);
1600
+ runner.all('UPDATE _ganju_tables SET name = ? WHERE id = ?', [patch.name, table.id]);
1601
+ current = patch.name;
1602
+ }
1603
+ const sets = [];
1604
+ const params = [];
1605
+ if ('label' in patch) {
1606
+ sets.push('label = ?');
1607
+ params.push(cleanText(patch.label, dataTypes_1.DATA_MAX_LABEL, 'label'));
1608
+ }
1609
+ if ('description' in patch) {
1610
+ sets.push('description = ?');
1611
+ params.push(cleanText(patch.description, dataTypes_1.DATA_MAX_DESCRIPTION, 'description'));
1612
+ }
1613
+ if ('access' in patch) {
1614
+ sets.push('access = ?');
1615
+ params.push(normaliseAccess(patch.access, table.access));
1616
+ }
1617
+ if ('customerScoped' in patch) {
1618
+ sets.push('customer_scoped = ?');
1619
+ params.push(flag(patch.customerScoped, 'customerScoped') ? 1 : 0);
1620
+ }
1621
+ if ('history' in patch) {
1622
+ sets.push('history = ?');
1623
+ params.push(flag(patch.history, 'history') ? 1 : 0);
1624
+ }
1625
+ if (sets.length) {
1626
+ runner.all(`UPDATE _ganju_tables SET ${sets.join(', ')} WHERE id = ?`, [...params, table.id]);
1627
+ }
1628
+ if ('displayColumn' in patch) {
1629
+ const display = patch.displayColumn === 'id' ? null : patch.displayColumn;
1630
+ if (display !== null && !table.columns.some(c => c.name === display)) {
1631
+ throw new dataTypes_1.DataError(`"displayColumn" is one of ${table.name}'s columns.`);
1632
+ }
1633
+ setDisplayColumn(runner, table.id, display === null ? null : String(display));
1634
+ }
1635
+ touchTable(runner, table.id);
1636
+ const updated = reload(runner, current);
1637
+ syncTableObjects(runner, updated, table.name);
1638
+ return updated;
1639
+ });
1640
+ };
1641
+ exports.updateTable = updateTable;
1642
+ const dropTable = (runner, schema, caller, name) => {
1643
+ assertDdlAllowed(caller);
1644
+ const table = tableNamed(schema, name);
1645
+ const referencedBy = schema.tables.flatMap(other => other.name === table.name
1646
+ ? []
1647
+ : other.columns
1648
+ .filter(c => c.type === 'relation' && c.table === table.name)
1649
+ .map(c => `${other.name}.${c.name}`));
1650
+ if (referencedBy.length) {
1651
+ throw new dataTypes_1.DataError(`${table.name} can't be dropped while ${referencedBy.join(', ')} point${referencedBy.length === 1 ? 's' : ''} at it; drop or change those columns first.`, 'constraint', { referencedBy });
1652
+ }
1653
+ runner.transaction(() => {
1654
+ dropTriggers(runner, table);
1655
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)((0, dataDdl_1.ftsTableName)(table))}`);
1656
+ runner.all(`DROP TABLE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)}`);
1657
+ runner.all('UPDATE _ganju_tables SET display_column_id = NULL WHERE id = ?', [table.id]);
1658
+ runner.all('DELETE FROM _ganju_columns WHERE table_id = ?', [table.id]);
1659
+ runner.all('DELETE FROM _ganju_tables WHERE id = ?', [table.id]);
1660
+ });
1661
+ return { dropped: table.name };
1662
+ };
1663
+ exports.dropTable = dropTable;
1664
+ const addColumn = (runner, schema, caller, tableName, input) => {
1665
+ assertDdlAllowed(caller);
1666
+ const table = tableNamed(schema, tableName);
1667
+ if (table.columns.length >= dataTypes_1.DATA_MAX_COLUMNS) {
1668
+ throw new dataTypes_1.DataError(`A table has up to ${dataTypes_1.DATA_MAX_COLUMNS} columns.`, 'too_large');
1669
+ }
1670
+ const column = (0, exports.normaliseColumn)(input, schema, table.name);
1671
+ if (table.columns.some(c => c.name === column.name)) {
1672
+ throw new dataTypes_1.DataError(`${table.name} already has a column "${column.name}".`, 'conflict');
1673
+ }
1674
+ if (column.required && (column.default === undefined || column.default === null)) {
1675
+ throw new dataTypes_1.DataError(`${table.name}.${column.name} is required, so existing rows need a value: give it a default, or add it optional, fill it in and then make it required.`);
1676
+ }
1677
+ return runner.transaction(() => {
1678
+ const position = table.columns.length
1679
+ ? Math.max(...table.columns.map(c => c.position)) + 1
1680
+ : 0;
1681
+ const id = insertColumnMeta(runner, schema, table.id, table.name, column, position, table.id);
1682
+ runner.all(`ALTER TABLE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} ADD COLUMN ${(0, dataDdl_1.columnDefinitionSql)({ ...column, id, position })}`);
1683
+ touchTable(runner, table.id);
1684
+ const updated = reload(runner, table.name);
1685
+ syncTableObjects(runner, updated);
1686
+ return updated;
1687
+ });
1688
+ };
1689
+ exports.addColumn = addColumn;
1690
+ const REBUILD_KEYS = ['type', 'required', 'values', 'min', 'default', 'table', 'onDelete'];
1691
+ // Whether a column change copies the table. The database object asks before
1692
+ // running it: a copy has to switch foreign keys off first, at the start of
1693
+ // its own request.
1694
+ const columnChangeNeedsRebuild = (schema, tableName, columnName, patch) => {
1695
+ const table = schema.tables.find(t => t.name === tableName);
1696
+ const column = table?.columns.find(c => c.name === columnName);
1697
+ if (!column || !isPlainObject(patch))
1698
+ return false;
1699
+ return REBUILD_KEYS.some(key => {
1700
+ if (!(key in patch))
1701
+ return false;
1702
+ const next = patch[key];
1703
+ const now = column[key];
1704
+ return JSON.stringify(next ?? null) !== JSON.stringify(now ?? null);
1705
+ });
1706
+ };
1707
+ exports.columnChangeNeedsRebuild = columnChangeNeedsRebuild;
1708
+ const dropColumnNeedsRebuild = (schema, tableName, columnName) => {
1709
+ const column = schema.tables
1710
+ .find(t => t.name === tableName)
1711
+ ?.columns.find(c => c.name === columnName);
1712
+ return column?.type === 'relation';
1713
+ };
1714
+ exports.dropColumnNeedsRebuild = dropColumnNeedsRebuild;
1715
+ const COPY_KEY = 'table_copy';
1716
+ const COPY_MAX_ATTEMPTS = 3;
1717
+ const copyTemp = (table) => `${table.name}__new`;
1718
+ const COPIED_SYSTEM_COLUMNS = ['created_at', 'updated_at', 'version', 'created_by', 'updated_by', 'participant_id'];
1719
+ const copyColumns = (table, columns) => columns.map((c, position) => ({ ...c.draft, id: c.id, position }));
1720
+ // The INSERT … SELECT that copies the rows matching `where` into the copy.
1721
+ const copyRowsSql = (table, columns, where = '') => {
1722
+ const stored = copyColumns(table, columns);
1723
+ const targets = ['id', ...stored.map(c => c.name), ...COPIED_SYSTEM_COLUMNS];
1724
+ const sources = [
1725
+ 't.id',
1726
+ ...columns.map((c, i) => {
1727
+ const from = table.columns.find(col => col.id === c.id);
1728
+ return (0, dataDdl_1.conversionSql)(from.type, stored[i], `t.${(0, dataIdentifiers_1.quoteIdentifier)(from.name)}`);
1729
+ }),
1730
+ ...COPIED_SYSTEM_COLUMNS.map(name => `t.${name}`)
1731
+ ];
1732
+ return `INSERT INTO ${(0, dataIdentifiers_1.quoteIdentifier)(copyTemp(table))} (${targets.map(dataIdentifiers_1.quoteIdentifier).join(', ')}) SELECT ${sources.join(', ')} FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t${where}`;
1733
+ };
1734
+ // Any row that wouldn't survive the conversion stops the change before
1735
+ // anything is written.
1736
+ const assertRowsConvert = (runner, table, columns) => {
1737
+ const rules = (c) => JSON.stringify([c.type, c.required, c.values, c.min, c.table]);
1738
+ for (const c of columns) {
1739
+ const from = table.columns.find(col => col.id === c.id);
1740
+ if (rules(from) === rules(c.draft))
1741
+ continue;
1742
+ const to = { ...c.draft, id: c.id, position: 0 };
1743
+ const src = `t.${(0, dataIdentifiers_1.quoteIdentifier)(from.name)}`;
1744
+ const converted = (0, dataDdl_1.conversionSql)(from.type, to, src);
1745
+ const bad = runner.all(`SELECT t.id, ${src} AS value FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t WHERE ${(0, dataDdl_1.invalidAfterConversionSql)(to, src, converted)} LIMIT 10`);
1746
+ if (bad.length) {
1747
+ throw new dataTypes_1.DataError(`${table.name}.${from.name} can't become ${to.type}${to.required ? ', required' : ''}: ${bad.length === 10 ? 'these rows (and maybe more)' : 'these rows'} don't fit: ${bad
1748
+ .map(r => `id ${r.id} = ${JSON.stringify(r.value)}`)
1749
+ .join(', ')}. Nothing was changed.`, 'constraint', { rows: bad.map(r => ({ id: r.id, value: r.value })) });
1750
+ }
1751
+ }
1752
+ };
1753
+ // Replaces the table with its full copy and moves the metadata to match. Runs
1754
+ // inside the caller's transaction, with foreign keys off for the request
1755
+ // (dropping the table would otherwise cascade into the rows pointing at it);
1756
+ // a broken relation found after the swap throws, rolling it back.
1757
+ const swapInCopy = (runner, schema, table, columns, droppedId) => {
1758
+ dropTriggers(runner, table);
1759
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)((0, dataDdl_1.ftsTableName)(table))}`);
1760
+ if (droppedId !== null) {
1761
+ const dropped = table.columns.find(c => c.id === droppedId);
1762
+ if (dropped && table.displayColumn === dropped.name)
1763
+ setDisplayColumn(runner, table.id, null);
1764
+ runner.all('DELETE FROM _ganju_columns WHERE id = ?', [droppedId]);
1765
+ }
1766
+ columns.forEach((c, position) => {
1767
+ updateColumnMeta(runner, schema, table, c.id, c.draft);
1768
+ runner.all('UPDATE _ganju_columns SET position = ? WHERE id = ?', [position, c.id]);
1769
+ });
1770
+ runner.all(`DROP TABLE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)}`);
1771
+ runner.all(`ALTER TABLE ${(0, dataIdentifiers_1.quoteIdentifier)(copyTemp(table))} RENAME TO ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)}`);
1772
+ touchTable(runner, table.id);
1773
+ const updated = reload(runner, table.name);
1774
+ syncTableObjects(runner, updated);
1775
+ const checked = [
1776
+ updated.name,
1777
+ ...schema.tables
1778
+ .filter(t => t.name !== updated.name && t.columns.some(c => c.type === 'relation' && c.table === updated.name))
1779
+ .map(t => t.name)
1780
+ ];
1781
+ for (const name of checked) {
1782
+ const violations = runner.all(`PRAGMA foreign_key_check(${(0, dataIdentifiers_1.quoteIdentifier)(name)})`);
1783
+ if (violations.length) {
1784
+ throw new dataTypes_1.DataError(`The change would leave ${violations.length} row${violations.length === 1 ? '' : 's'} in ${name} pointing at nothing; nothing was changed.`, 'constraint');
1785
+ }
1786
+ }
1787
+ return updated;
1788
+ };
1789
+ // Copies the table with its columns changed to `columns`; the caller has
1790
+ // switched foreign keys off for this request. Up to DATA_REBUILD_MAX_ROWS rows
1791
+ // it is one transaction. A larger table only gets its empty copy here, and
1792
+ // the job is left for copyTableStep: the table stays as it is until the
1793
+ // swap, and readTableCopy says how far it got.
1794
+ const rebuildTable = (runner, schema, caller, table, columns, change, droppedId = null) => {
1795
+ const rows = Number(first(runner, `SELECT count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)}`)?.n ?? 0);
1796
+ assertRowsConvert(runner, table, columns);
1797
+ const temp = copyTemp(table);
1798
+ if (rows <= dataTypes_1.DATA_REBUILD_MAX_ROWS) {
1799
+ return runner.transaction(() => {
1800
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)(temp)}`);
1801
+ runner.all((0, dataDdl_1.createTableSql)(temp, copyColumns(table, columns)));
1802
+ runner.all(copyRowsSql(table, columns));
1803
+ return swapInCopy(runner, schema, table, columns, droppedId);
1804
+ });
1805
+ }
1806
+ return runner.transaction(() => {
1807
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)(temp)}`);
1808
+ runner.all((0, dataDdl_1.createTableSql)(temp, copyColumns(table, columns)));
1809
+ const job = {
1810
+ table: table.name,
1811
+ column: change.column,
1812
+ kind: change.kind,
1813
+ rows,
1814
+ copied: 0,
1815
+ startedAt: new Date().toISOString(),
1816
+ actor: caller.actor,
1817
+ status: 'running',
1818
+ error: null,
1819
+ tableId: table.id,
1820
+ columns,
1821
+ droppedId,
1822
+ afterId: 0,
1823
+ attempts: 0
1824
+ };
1825
+ (0, exports.setMeta)(runner, COPY_KEY, JSON.stringify(job));
1826
+ return table;
1827
+ });
1828
+ };
1829
+ const readCopyJob = (runner) => {
1830
+ const raw = (0, exports.getMeta)(runner, COPY_KEY);
1831
+ if (!raw)
1832
+ return null;
1833
+ try {
1834
+ return JSON.parse(raw);
1835
+ }
1836
+ catch {
1837
+ return null;
1838
+ }
1839
+ };
1840
+ // The background table copy, running or failed, if there is one.
1841
+ const readTableCopy = (runner) => {
1842
+ const job = readCopyJob(runner);
1843
+ if (!job)
1844
+ return null;
1845
+ return {
1846
+ table: job.table,
1847
+ column: job.column,
1848
+ kind: job.kind,
1849
+ rows: job.rows,
1850
+ copied: job.copied,
1851
+ startedAt: job.startedAt,
1852
+ actor: job.actor,
1853
+ status: job.status,
1854
+ error: job.error
1855
+ };
1856
+ };
1857
+ exports.readTableCopy = readTableCopy;
1858
+ // Whether a write would change rows of the table being copied, which the
1859
+ // copy would then miss: an insert, update or delete on it, or a delete in a
1860
+ // table it points at with cascade or set null, directly or through a chain of
1861
+ // cascades. Only those wait for the copy. Every other write goes on: row ids
1862
+ // survive the copy, so a relation pointing into the table stays valid.
1863
+ const writeReachesCopy = (schema, copy, rawArgs) => {
1864
+ const reaching = new Set([copy.table]);
1865
+ const queue = [copy.table];
1866
+ while (queue.length) {
1867
+ const name = queue.shift();
1868
+ const table = schema.tables.find(t => t.name === name);
1869
+ for (const column of table?.columns ?? []) {
1870
+ if (column.type !== 'relation' || !column.table)
1871
+ continue;
1872
+ const onDelete = column.onDelete ?? 'restrict';
1873
+ // A cleared relation changes the copied table's own rows only.
1874
+ const reaches = onDelete === 'cascade' || (onDelete === 'set_null' && name === copy.table);
1875
+ if (reaches && !reaching.has(column.table)) {
1876
+ reaching.add(column.table);
1877
+ queue.push(column.table);
1878
+ }
1879
+ }
1880
+ }
1881
+ let operations;
1882
+ try {
1883
+ operations = (0, dataCompiler_1.tidyArgs)(rawArgs).operations;
1884
+ }
1885
+ catch {
1886
+ return false;
1887
+ }
1888
+ if (!Array.isArray(operations))
1889
+ return false;
1890
+ return operations.some(op => isPlainObject(op) &&
1891
+ (op.table === copy.table || (op.op === 'delete' && reaching.has(String(op.table)))));
1892
+ };
1893
+ exports.writeReachesCopy = writeReachesCopy;
1894
+ // Forgets a failed copy once the owner has seen it, or a new change starts.
1895
+ const clearFailedTableCopy = (runner) => {
1896
+ if (readCopyJob(runner)?.status === 'failed')
1897
+ (0, exports.setMeta)(runner, COPY_KEY, null);
1898
+ };
1899
+ exports.clearFailedTableCopy = clearFailedTableCopy;
1900
+ // One step of the background copy: DATA_COPY_STEP_ROWS rows, or the swap
1901
+ // once every row is across. Foreign keys are off for the request, as for
1902
+ // the inline copy. A step that fails ends the job: the copy is dropped, the
1903
+ // table is left as it was, and readTableCopy carries the reason.
1904
+ const copyTableStep = (runner, schema) => {
1905
+ const job = readCopyJob(runner);
1906
+ if (!job || job.status !== 'running')
1907
+ return 'idle';
1908
+ const fail = (message) => {
1909
+ runner.transaction(() => {
1910
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)(copyTemp({ name: job.table }))}`);
1911
+ (0, exports.setMeta)(runner, COPY_KEY, JSON.stringify({ ...job, status: 'failed', error: message }));
1912
+ });
1913
+ return 'failed';
1914
+ };
1915
+ const table = schema.tables.find(t => t.id === job.tableId);
1916
+ if (!table)
1917
+ return fail(`${job.table} is gone.`);
1918
+ if (job.attempts >= COPY_MAX_ATTEMPTS) {
1919
+ return fail(`Copying ${table.name} stopped ${job.attempts} times at the same point. Nothing was changed.`);
1920
+ }
1921
+ (0, exports.setMeta)(runner, COPY_KEY, JSON.stringify({ ...job, attempts: job.attempts + 1 }));
1922
+ try {
1923
+ const next = first(runner, `SELECT max(id) AS upper, count(*) AS n FROM (SELECT id FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE id > ? ORDER BY id LIMIT ?)`, [job.afterId, dataTypes_1.DATA_COPY_STEP_ROWS]);
1924
+ const n = Number(next?.n ?? 0);
1925
+ if (n === 0) {
1926
+ runner.transaction(() => {
1927
+ swapInCopy(runner, schema, table, job.columns, job.droppedId);
1928
+ (0, exports.setMeta)(runner, COPY_KEY, null);
1929
+ });
1930
+ return 'done';
1931
+ }
1932
+ const upper = Number(next.upper);
1933
+ runner.transaction(() => {
1934
+ runner.all(copyRowsSql(table, job.columns, ' WHERE t.id > ? AND t.id <= ?'), [
1935
+ job.afterId,
1936
+ upper
1937
+ ]);
1938
+ (0, exports.setMeta)(runner, COPY_KEY, JSON.stringify({ ...job, afterId: upper, copied: job.copied + n, attempts: 0 }));
1939
+ });
1940
+ return 'more';
1941
+ }
1942
+ catch (error) {
1943
+ return fail((0, exports.translateSqliteError)(schema, table, error).message);
1944
+ }
1945
+ };
1946
+ exports.copyTableStep = copyTableStep;
1947
+ const updateColumn = (runner, schema, caller, tableName, columnName, patch) => {
1948
+ assertDdlAllowed(caller);
1949
+ const table = tableNamed(schema, tableName);
1950
+ const column = columnNamed(table, columnName);
1951
+ if (!isPlainObject(patch))
1952
+ throw new dataTypes_1.DataError('The change is an object.');
1953
+ const merged = { ...(0, dataTypes_1.toTableInput)(table).columns.find(c => c.name === column.name), ...patch };
1954
+ for (const key of Object.keys(merged)) {
1955
+ if (merged[key] === null)
1956
+ delete merged[key];
1957
+ }
1958
+ if (merged.type !== 'enum')
1959
+ delete merged.values;
1960
+ if (merged.type !== 'relation') {
1961
+ delete merged.table;
1962
+ delete merged.onDelete;
1963
+ }
1964
+ if (!dataDdl_1.NUMERIC_TYPES.has(merged.type))
1965
+ delete merged.min;
1966
+ const draft = (0, exports.normaliseColumn)(merged, schema, table.name);
1967
+ if (draft.name !== column.name && table.columns.some(c => c.name === draft.name)) {
1968
+ throw new dataTypes_1.DataError(`${table.name} already has a column "${draft.name}".`, 'conflict');
1969
+ }
1970
+ if ((0, exports.columnChangeNeedsRebuild)(schema, table.name, column.name, patch)) {
1971
+ return rebuildTable(runner, schema, caller, table, table.columns.map(c => ({ draft: c.id === column.id ? draft : c, id: c.id })), { column: column.name, kind: 'change' });
1972
+ }
1973
+ return runner.transaction(() => {
1974
+ if (draft.name !== column.name) {
1975
+ dropTriggers(runner, table);
1976
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)((0, dataDdl_1.ftsTableName)(table))}`);
1977
+ runner.all(`ALTER TABLE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} RENAME COLUMN ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} TO ${(0, dataIdentifiers_1.quoteIdentifier)(draft.name)}`);
1978
+ }
1979
+ updateColumnMeta(runner, schema, table, column.id, draft);
1980
+ touchTable(runner, table.id);
1981
+ const updated = reload(runner, table.name);
1982
+ syncTableObjects(runner, updated);
1983
+ return updated;
1984
+ });
1985
+ };
1986
+ exports.updateColumn = updateColumn;
1987
+ const dropColumn = (runner, schema, caller, tableName, columnName) => {
1988
+ assertDdlAllowed(caller);
1989
+ const table = tableNamed(schema, tableName);
1990
+ const column = columnNamed(table, columnName);
1991
+ if (table.columns.length === 1) {
1992
+ throw new dataTypes_1.DataError(`${table.name} needs at least one column; drop the table instead.`);
1993
+ }
1994
+ if ((0, exports.dropColumnNeedsRebuild)(schema, table.name, column.name)) {
1995
+ return rebuildTable(runner, schema, caller, table, table.columns.filter(c => c.id !== column.id).map(c => ({ draft: c, id: c.id })), { column: column.name, kind: 'drop' }, column.id);
1996
+ }
1997
+ return runner.transaction(() => {
1998
+ dropTriggers(runner, table);
1999
+ runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)((0, dataDdl_1.ftsTableName)(table))}`);
2000
+ for (const index of tableObjects(runner, 'index', table.name)) {
2001
+ if (index.name.endsWith(`_${column.id}`) && (index.name.startsWith('ix_') || index.name.startsWith('ux_'))) {
2002
+ runner.all(`DROP INDEX IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)(index.name)}`);
2003
+ }
2004
+ }
2005
+ runner.all(`ALTER TABLE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} DROP COLUMN ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)}`);
2006
+ if (table.displayColumn === column.name)
2007
+ setDisplayColumn(runner, table.id, null);
2008
+ runner.all('DELETE FROM _ganju_columns WHERE id = ?', [column.id]);
2009
+ touchTable(runner, table.id);
2010
+ const updated = reload(runner, table.name);
2011
+ syncTableObjects(runner, updated);
2012
+ return updated;
2013
+ });
2014
+ };
2015
+ exports.dropColumn = dropColumn;
2016
+ // Export
2017
+ // A table's rows as stored, every column included, from after `afterId`,
2018
+ // one at a time: an export stops reading once it has a step's worth.
2019
+ const exportRows = (runner, table, afterId) => runner.iterate(`SELECT * FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE id > ? ORDER BY id`, [
2020
+ afterId
2021
+ ]);
2022
+ exports.exportRows = exportRows;