@ganju/utils 0.0.20 → 0.0.22

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 (72) hide show
  1. package/dist/constants.d.ts +16 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +43 -0
  4. package/dist/data.d.ts +7 -5
  5. package/dist/data.d.ts.map +1 -1
  6. package/dist/data.js +27 -3
  7. package/dist/dataApi.d.ts +82 -6
  8. package/dist/dataApi.d.ts.map +1 -1
  9. package/dist/dataApi.js +47 -4
  10. package/dist/dataAttachment.d.ts +8 -0
  11. package/dist/dataAttachment.d.ts.map +1 -0
  12. package/dist/dataAttachment.js +19 -0
  13. package/dist/dataCompiler.d.ts +4 -0
  14. package/dist/dataCompiler.d.ts.map +1 -1
  15. package/dist/dataCompiler.js +27 -2
  16. package/dist/dataDdl.d.ts.map +1 -1
  17. package/dist/dataDdl.js +5 -0
  18. package/dist/dataEngine.d.ts +25 -3
  19. package/dist/dataEngine.d.ts.map +1 -1
  20. package/dist/dataEngine.js +508 -14
  21. package/dist/dataImport.d.ts +3 -0
  22. package/dist/dataImport.d.ts.map +1 -1
  23. package/dist/dataImport.js +12 -2
  24. package/dist/dataIndexAdvice.d.ts +36 -0
  25. package/dist/dataIndexAdvice.d.ts.map +1 -0
  26. package/dist/dataIndexAdvice.js +89 -0
  27. package/dist/dataRpc.d.ts +20 -1
  28. package/dist/dataRpc.d.ts.map +1 -1
  29. package/dist/dataSheet.d.ts +34 -0
  30. package/dist/dataSheet.d.ts.map +1 -0
  31. package/dist/dataSheet.js +100 -0
  32. package/dist/dataTools.js +3 -3
  33. package/dist/dataTriggers.d.ts +66 -4
  34. package/dist/dataTriggers.d.ts.map +1 -1
  35. package/dist/dataTriggers.js +446 -20
  36. package/dist/dataTypes.d.ts +69 -0
  37. package/dist/dataTypes.d.ts.map +1 -1
  38. package/dist/dataTypes.js +21 -1
  39. package/dist/index.d.ts +91 -7
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -0
  42. package/dist/localizeDataError.d.ts.map +1 -1
  43. package/dist/localizeDataError.js +109 -3
  44. package/dist/schema.d.ts +67 -6
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +57 -2
  47. package/dist/untrustedContent.d.ts +7 -0
  48. package/dist/untrustedContent.d.ts.map +1 -1
  49. package/dist/untrustedContent.js +13 -1
  50. package/dist/whatsappWebhook.d.ts +28 -0
  51. package/dist/whatsappWebhook.d.ts.map +1 -0
  52. package/dist/whatsappWebhook.js +30 -0
  53. package/package.json +1 -1
  54. package/src/constants.ts +46 -0
  55. package/src/data.ts +33 -3
  56. package/src/dataApi.ts +54 -4
  57. package/src/dataAttachment.ts +23 -0
  58. package/src/dataCompiler.ts +41 -1
  59. package/src/dataDdl.ts +7 -0
  60. package/src/dataEngine.ts +680 -15
  61. package/src/dataImport.ts +16 -1
  62. package/src/dataIndexAdvice.ts +127 -0
  63. package/src/dataRpc.ts +27 -2
  64. package/src/dataSheet.ts +147 -0
  65. package/src/dataTools.ts +3 -3
  66. package/src/dataTriggers.ts +599 -20
  67. package/src/dataTypes.ts +108 -0
  68. package/src/index.ts +15 -0
  69. package/src/localizeDataError.ts +183 -4
  70. package/src/schema.ts +60 -2
  71. package/src/untrustedContent.ts +15 -0
  72. package/src/whatsappWebhook.ts +55 -0
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
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;
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.replaceFileLinks = exports.fileLinks = exports.deleteImpact = exports.recordSyncFailure = exports.syncRows = exports.write = exports.assertNotSynced = 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
4
  // What a project's database does, written against a tiny SQL runner so the
5
5
  // same code runs inside the Durable Object (apps/data) and on node:sqlite in
6
6
  // the tests and the tool-design eval. The runner executes; everything about
@@ -130,6 +130,26 @@ exports.INTERNAL_MIGRATIONS = [
130
130
  message TEXT NOT NULL,
131
131
  created_at TEXT NOT NULL DEFAULT (${dataDdl_1.DATA_NOW_SQL})
132
132
  )`
133
+ ],
134
+ // Time triggers: when each fires (`schedule`), how far its scans have got
135
+ // (`scan_state`), and the rows already fired for a due time, so none fires
136
+ // twice for the same one.
137
+ [
138
+ 'ALTER TABLE _ganju_triggers ADD COLUMN schedule TEXT',
139
+ 'ALTER TABLE _ganju_triggers ADD COLUMN scan_state TEXT',
140
+ `CREATE TABLE IF NOT EXISTS _ganju_trigger_fired (
141
+ trigger_id TEXT NOT NULL,
142
+ row_id INTEGER NOT NULL,
143
+ due_at TEXT NOT NULL,
144
+ PRIMARY KEY (trigger_id, row_id, due_at)
145
+ ) WITHOUT ROWID`,
146
+ 'CREATE INDEX IF NOT EXISTS _ganju_trigger_fired_due ON _ganju_trigger_fired(due_at)'
147
+ ],
148
+ // Tables synced from a spreadsheet: where from (`source`), and how the
149
+ // last sync went (`sync_state`).
150
+ [
151
+ 'ALTER TABLE _ganju_tables ADD COLUMN source TEXT',
152
+ 'ALTER TABLE _ganju_tables ADD COLUMN sync_state TEXT'
133
153
  ]
134
154
  ];
135
155
  exports.INTERNAL_SCHEMA_VERSION = exports.INTERNAL_MIGRATIONS.length;
@@ -150,6 +170,17 @@ const setMeta = (runner, key, value) => {
150
170
  runner.all('INSERT INTO _ganju_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value]);
151
171
  };
152
172
  exports.setMeta = setMeta;
173
+ // SQLite has no ADD COLUMN IF NOT EXISTS: a column already there is
174
+ // skipped, so every version can run again like the CREATE ... IF NOT EXISTS
175
+ // ones.
176
+ const ADD_COLUMN = /^ALTER TABLE (\w+) ADD COLUMN (\w+) /;
177
+ const runMigration = (runner, statement) => {
178
+ const add = ADD_COLUMN.exec(statement);
179
+ if (add && runner.all(`PRAGMA table_info(${(0, dataIdentifiers_1.quoteIdentifier)(add[1])})`).some(c => c.name === add[2])) {
180
+ return;
181
+ }
182
+ runner.all(statement);
183
+ };
153
184
  // Brings our own tables up to date. Returns the version now in force.
154
185
  const migrateInternal = (runner) => runner.transaction(() => {
155
186
  const current = (0, exports.hasInternalTables)(runner)
@@ -157,7 +188,7 @@ const migrateInternal = (runner) => runner.transaction(() => {
157
188
  : 0;
158
189
  for (let v = current; v < exports.INTERNAL_MIGRATIONS.length; v++) {
159
190
  for (const statement of exports.INTERNAL_MIGRATIONS[v])
160
- runner.all(statement);
191
+ runMigration(runner, statement);
161
192
  }
162
193
  if (current < exports.INTERNAL_MIGRATIONS.length) {
163
194
  (0, exports.setMeta)(runner, 'schema_version', String(exports.INTERNAL_MIGRATIONS.length));
@@ -190,6 +221,8 @@ const loadSchema = (runner) => {
190
221
  customerScoped: row.customer_scoped === 1,
191
222
  history: row.history === 1,
192
223
  position: Number(row.position),
224
+ source: parseJson(row.source ?? null),
225
+ sync: parseJson(row.sync_state ?? null),
193
226
  columns: columns
194
227
  .filter(c => c.table_id === row.id)
195
228
  .map(c => {
@@ -335,8 +368,11 @@ const normaliseColumn = (input, schema, selfTable) => {
335
368
  if (column.unique && !dataDdl_1.UNIQUE_TYPES.has(column.type)) {
336
369
  throw new dataTypes_1.DataError(`${label}: a ${column.type} column can't be unique.`);
337
370
  }
338
- if (column.searchable && !dataDdl_1.SEARCHABLE_TYPES.has(column.type)) {
339
- throw new dataTypes_1.DataError(`${label}: only text columns can be searchable (text, long_text, email, url, phone, enum).`);
371
+ // On a file column, searchable means its uploads are indexed in knowledge.
372
+ if (column.searchable &&
373
+ !dataDdl_1.SEARCHABLE_TYPES.has(column.type) &&
374
+ column.type !== 'file') {
375
+ throw new dataTypes_1.DataError(`${label}: only text and file columns can be searchable (text, long_text, email, url, phone, enum, file).`);
340
376
  }
341
377
  // The search table has a column of its own called rank.
342
378
  if (column.searchable && column.name === 'rank') {
@@ -386,6 +422,47 @@ const normaliseAccess = (value, fallback) => {
386
422
  }
387
423
  return value;
388
424
  };
425
+ // A table's sheet source, checked against the columns it will have. Null
426
+ // stops syncing.
427
+ const normaliseSource = (value, tableName, columns, customerScoped) => {
428
+ if (value === undefined || value === null)
429
+ return null;
430
+ // A sheet's rows have no customer, so a bot would never see them.
431
+ if (customerScoped)
432
+ throw syncedScopedError(tableName);
433
+ if (!isPlainObject(value)) {
434
+ throw new dataTypes_1.DataError('"source" is { "uri", "sheet"?, "key" }, or null to stop syncing.');
435
+ }
436
+ for (const key of Object.keys(value)) {
437
+ if (!['uri', 'sheet', 'key', 'title'].includes(key)) {
438
+ throw new dataTypes_1.DataError(`"source" takes uri, sheet and key, not "${key}".`);
439
+ }
440
+ }
441
+ const { uri, sheet, key, title } = value;
442
+ if (typeof uri !== 'string' || !/^[a-z][\w+.-]*:\/\/\S+$/i.test(uri) || uri.length > dataTypes_1.DATA_FILE_URI_MAX) {
443
+ throw new dataTypes_1.DataError('"source.uri" is the uri of a spreadsheet or CSV in the project\'s knowledge.');
444
+ }
445
+ if (sheet !== undefined && sheet !== null && (typeof sheet !== 'string' || !sheet.trim() || sheet.length > 100)) {
446
+ throw new dataTypes_1.DataError('"source.sheet" is the name of a sheet in the file, up to 100 characters.');
447
+ }
448
+ const column = columns.find(c => c.name === key);
449
+ if (typeof key !== 'string' || !column) {
450
+ throw new dataTypes_1.DataError(`"source.key" is the column that names a row in both the sheet and ${tableName}. Columns: ${columns.map(c => c.name).join(', ')}.`);
451
+ }
452
+ if (!column.unique) {
453
+ throw new dataTypes_1.DataError(`${tableName}.${key} must be unique to be the sync key.`);
454
+ }
455
+ return {
456
+ uri,
457
+ ...(typeof sheet === 'string' ? { sheet: sheet.trim() } : {}),
458
+ key,
459
+ ...(typeof title === 'string' && title.trim() ? { title: title.trim().slice(0, 200) } : {})
460
+ };
461
+ };
462
+ const syncedScopedError = (tableName) => new dataTypes_1.DataError(`${tableName} can't be both customer-scoped and synced from a sheet: synced rows have no customer, so the bots wouldn't see them.`);
463
+ const sameSource = (a, b) => (a?.uri ?? null) === (b?.uri ?? null) &&
464
+ (a?.sheet ?? null) === (b?.sheet ?? null) &&
465
+ (a?.key ?? null) === (b?.key ?? null);
389
466
  // Keeping indexes, triggers and the search table in line with the metadata
390
467
  const tableObjects = (runner, type, table) => runner
391
468
  .all('SELECT name, sql FROM sqlite_master WHERE type = ? AND tbl_name = ?', [type, table])
@@ -548,6 +625,9 @@ const shapeRow = (schema, table, raw, caller) => {
548
625
  : label
549
626
  };
550
627
  }
628
+ else if (column.type === 'file') {
629
+ row[column.name] = value === null ? null : { uri: String(value) };
630
+ }
551
631
  else {
552
632
  row[column.name] = value;
553
633
  }
@@ -626,8 +706,19 @@ const query = (runner, schema, caller, rawArgs) => {
626
706
  caller,
627
707
  table,
628
708
  joins: new Map(),
629
- params: []
709
+ params: [],
710
+ filtered: new Set()
630
711
  };
712
+ // What this query read, noted for index advice once it has its rows.
713
+ const readBefore = runner.usageSoFar?.().rowsRead ?? 0;
714
+ const sorted = [];
715
+ const noteShape = (returned) => runner.noteQuery?.({
716
+ table: table.name,
717
+ filtered: [...(ctx.filtered ?? [])],
718
+ sorted,
719
+ rowsRead: (runner.usageSoFar?.().rowsRead ?? 0) - readBefore,
720
+ returned
721
+ });
631
722
  const indexed = new Set();
632
723
  const where = (0, dataCompiler_1.compileWhere)(ctx, args.where, indexed);
633
724
  if ((0, dataCompiler_1.isScoped)(caller, table)) {
@@ -639,7 +730,10 @@ const query = (runner, schema, caller, rawArgs) => {
639
730
  throw new dataTypes_1.DataError(`"${table.name}" has no searchable columns; filter with "where" instead (e.g. { "name": { "contains": "..." } }).`);
640
731
  }
641
732
  if (args.aggregate !== undefined && args.aggregate !== null) {
642
- return aggregate(runner, ctx, args, where, searching, indexed.size > 0);
733
+ const result = aggregate(runner, ctx, args, where, searching, indexed.size > 0);
734
+ const groups = result.groups;
735
+ noteShape(Array.isArray(groups) ? groups.length : 1);
736
+ return result;
643
737
  }
644
738
  const limit = args.limit === undefined || args.limit === null ? dataTypes_1.DATA_DEFAULT_PAGE : args.limit;
645
739
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > dataTypes_1.DATA_MAX_PAGE) {
@@ -672,6 +766,8 @@ const query = (runner, schema, caller, rawArgs) => {
672
766
  descending,
673
767
  ...(servedByIndex ? {} : { unindexed: resolved.column.name })
674
768
  });
769
+ if (resolved.own && resolved.column.name !== 'id')
770
+ sorted.push(resolved.column.name);
675
771
  if (resolved.own && resolved.column.name === 'id') {
676
772
  endsUnique = true;
677
773
  break;
@@ -763,14 +859,17 @@ const query = (runner, schema, caller, rawArgs) => {
763
859
  throw (0, exports.translateSqliteError)(schema, table, error);
764
860
  }
765
861
  const { rows: out, last, more } = result;
862
+ noteShape(out.length);
766
863
  const includeTruncated = args.include !== undefined && args.include !== null
767
864
  ? includeRelations(runner, schema, caller, table, out, args.include, dataTypes_1.DATA_MAX_RESULT_BYTES - result.bytes)
768
865
  : false;
866
+ const fileFields = fileFieldPaths(schema, table, args.include);
769
867
  return {
770
868
  table: table.name,
771
869
  ...((0, dataCompiler_1.isScoped)(caller, table) ? { scope: SCOPE_NOTE } : {}),
772
870
  ...(result.total !== undefined ? { total: result.total } : {}),
773
871
  ...(matched ? { searchMatched: matched } : {}),
872
+ ...(fileFields.length ? { fileFields } : {}),
774
873
  rows: out,
775
874
  ...(includeTruncated
776
875
  ? {
@@ -789,6 +888,25 @@ const query = (runner, schema, caller, rawArgs) => {
789
888
  };
790
889
  };
791
890
  exports.query = query;
891
+ // Where a query's rows hold file values, as key paths within each row: the
892
+ // table's file columns, and those of each included relation. The workers
893
+ // fill those in from knowledge and drop this before answering; a json
894
+ // column holding { "uri": … } is never mistaken for one.
895
+ const fileFieldPaths = (schema, table, include) => {
896
+ const own = table.columns
897
+ .filter(column => column.type === 'file')
898
+ .map(column => [column.name]);
899
+ const included = (Array.isArray(include) ? include : []).flatMap(name => {
900
+ const column = typeof name === 'string' ? (0, dataCompiler_1.columnFor)(table, name) : undefined;
901
+ if (!column || (0, dataCompiler_1.isSystemColumn)(column) || column.type !== 'relation')
902
+ return [];
903
+ const target = schema.tables.find(t => t.name === column.table);
904
+ return (target?.columns ?? [])
905
+ .filter(c => c.type === 'file')
906
+ .map(c => [name, c.name]);
907
+ });
908
+ return [...own, ...included];
909
+ };
792
910
  // Replaces each relation in `rows` named in `include` with the related row,
793
911
  // read as the caller may see it, within `budget` bytes. Returns whether any
794
912
  // was left as { id, label } to stay inside it.
@@ -990,6 +1108,15 @@ const assertWritable = (runner, caller, addsData) => {
990
1108
  }
991
1109
  };
992
1110
  exports.assertWritable = assertWritable;
1111
+ // A table synced from a sheet takes its rows from the sheet alone, whoever
1112
+ // writes: the dashboard, the API, the CLI, functions and the assistant.
1113
+ const assertNotSynced = (table) => {
1114
+ if (!table.source)
1115
+ return;
1116
+ const from = table.source.title || table.source.uri;
1117
+ throw new dataTypes_1.DataError(`${table.name} is synced from "${from}"; change the sheet, or stop syncing it.`, 'read_only', { source: table.source });
1118
+ };
1119
+ exports.assertNotSynced = assertNotSynced;
993
1120
  // Every write names its actor here first, so row history (kept by triggers)
994
1121
  // records who made each change, cascades included.
995
1122
  const setContextActor = (runner, actor) => {
@@ -1149,6 +1276,7 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1149
1276
  op = { ...rawOp, id };
1150
1277
  }
1151
1278
  const table = (0, dataCompiler_1.tableFor)(schema, caller, op.table, kind === 'insert' ? 'add' : 'full');
1279
+ (0, exports.assertNotSynced)(table);
1152
1280
  const actor = caller.actor;
1153
1281
  const scoped = (0, dataCompiler_1.isScoped)(caller, table);
1154
1282
  if (kind === 'insert') {
@@ -1176,8 +1304,9 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1176
1304
  // One column list for the whole batch, so rows that leave a column out
1177
1305
  // get NULL there rather than its default only when another row sets it.
1178
1306
  const names = [...new Set(prepared.flatMap(values => [...values.keys()]))];
1179
- const columns = [...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1307
+ const columns = ['id', ...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1180
1308
  const ids = [];
1309
+ let nextId = nextRowId(runner, table);
1181
1310
  try {
1182
1311
  for (const chunk of (0, dataCompiler_1.chunkRows)(prepared, columns.length)) {
1183
1312
  const usesDefaults = chunk.some(values => names.some(n => !values.has(n)));
@@ -1185,11 +1314,12 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1185
1314
  // Rows with different columns go one by one, so each missing
1186
1315
  // column takes its default.
1187
1316
  for (const values of chunk)
1188
- ids.push(insertOne(runner, table, values, actor, scoped, caller));
1317
+ ids.push(insertOne(runner, table, values, actor, scoped, caller, nextId++));
1189
1318
  continue;
1190
1319
  }
1191
1320
  const params = [];
1192
1321
  for (const values of chunk) {
1322
+ params.push(nextId++);
1193
1323
  for (const name of names)
1194
1324
  params.push(values.get(name) ?? null);
1195
1325
  params.push(actor, actor);
@@ -1292,6 +1422,26 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1292
1422
  .all(`SELECT t.id FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t WHERE ${where}`, ctx.params)
1293
1423
  .map(r => Number(r.id))
1294
1424
  : assertBoundedTarget();
1425
+ // A delete here mustn't change a synced table's rows behind its sheet,
1426
+ // through a cascade or a set null.
1427
+ for (const other of schema.tables) {
1428
+ if (!other.source || other.name === table.name || ids.length === 0)
1429
+ continue;
1430
+ for (const c of other.columns) {
1431
+ if (c.type !== 'relation' || c.table !== table.name)
1432
+ continue;
1433
+ if ((c.onDelete ?? 'restrict') === 'restrict')
1434
+ continue;
1435
+ let n = 0;
1436
+ for (let i = 0; i < ids.length; i += dataTypes_1.DATA_MAX_PARAMS) {
1437
+ const slice = ids.slice(i, i + dataTypes_1.DATA_MAX_PARAMS);
1438
+ 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);
1439
+ }
1440
+ if (n > 0) {
1441
+ throw new dataTypes_1.DataError(`Can't delete: it would change ${n === 1 ? 'a row' : `${n} rows`} of ${other.name} (${other.name}.${c.name}), which is synced from "${other.source.title || other.source.uri}"; change the sheet first, or stop syncing it.`, 'read_only', { table: other.name, column: c.name, count: n });
1442
+ }
1443
+ }
1444
+ }
1295
1445
  // A readable "3 rows in order_lines still point at this order" before
1296
1446
  // SQLite's bare foreign key error. A row of the same table counts even
1297
1447
  // when this delete removes it too: SQLite checks a restrict as each row
@@ -1330,9 +1480,16 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1330
1480
  }
1331
1481
  return { op: kind, table: table.name, changed };
1332
1482
  };
1333
- const insertOne = (runner, table, values, actor, scoped, caller) => {
1334
- const names = [...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1335
- const params = [...values.values(), actor, actor, ...(scoped ? [(0, dataCompiler_1.participantOf)(caller)] : [])];
1483
+ // The id the table's next row gets. Tables have no AUTOINCREMENT, so SQLite
1484
+ // would hand out the highest id again once that row is deleted, and the
1485
+ // deleted row's history would read as the new one's. Above both, instead.
1486
+ const nextRowId = (runner, table) => Number(first(runner, `SELECT max(
1487
+ coalesce((SELECT max(id) FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)}), 0),
1488
+ coalesce((SELECT max(row_id) FROM _ganju_row_log WHERE table_id = ?), 0)
1489
+ ) + 1 AS id`, [table.id])?.id ?? 1);
1490
+ const insertOne = (runner, table, values, actor, scoped, caller, id) => {
1491
+ const names = ['id', ...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1492
+ const params = [id, ...values.values(), actor, actor, ...(scoped ? [(0, dataCompiler_1.participantOf)(caller)] : [])];
1336
1493
  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);
1337
1494
  };
1338
1495
  // A list of inserts, updates and deletes across tables, applied together or
@@ -1353,7 +1510,12 @@ const write = (runner, schema, caller, rawArgs) => {
1353
1510
  const results = [];
1354
1511
  operations.forEach((op, index) => {
1355
1512
  try {
1513
+ const before = runner.usageSoFar?.().rowsWritten ?? 0;
1356
1514
  results.push(runOperation(runner, schema, caller, op, index, results));
1515
+ runner.noteWrite?.({
1516
+ table: results[results.length - 1].table,
1517
+ rowsWritten: (runner.usageSoFar?.().rowsWritten ?? 0) - before
1518
+ });
1357
1519
  }
1358
1520
  catch (error) {
1359
1521
  if (error instanceof dataTypes_1.DataError) {
@@ -1368,6 +1530,214 @@ const write = (runner, schema, caller, rawArgs) => {
1368
1530
  });
1369
1531
  };
1370
1532
  exports.write = write;
1533
+ // Whether `version` is older than what the table's rows were last made from.
1534
+ const staleVersion = (table, version) => typeof version === 'string' && !!table.sync?.version && version < table.sync.version;
1535
+ // The same JSON with its keys in order, so a sheet that writes it another
1536
+ // way round isn't a change.
1537
+ const canonicalJson = (value) => Array.isArray(value)
1538
+ ? value.map(canonicalJson)
1539
+ : isPlainObject(value)
1540
+ ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonicalJson(value[k])]))
1541
+ : value;
1542
+ const sameStored = (column, a, b) => {
1543
+ if (a === b)
1544
+ return true;
1545
+ if (column.type !== 'json' || typeof a !== 'string' || typeof b !== 'string')
1546
+ return false;
1547
+ try {
1548
+ return JSON.stringify(canonicalJson(JSON.parse(a))) === JSON.stringify(canonicalJson(JSON.parse(b)));
1549
+ }
1550
+ catch {
1551
+ return false;
1552
+ }
1553
+ };
1554
+ const nowStamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
1555
+ // A refusal about one line of the sheet.
1556
+ const atLine = (line, error) => new dataTypes_1.DataError(`Line ${line}: ${error.message}`, error.code, error.details);
1557
+ const saveSyncState = (runner, table, state, title) => {
1558
+ const source = table.source && typeof title === 'string' && title.trim()
1559
+ ? { ...table.source, title: title.trim().slice(0, 200) }
1560
+ : table.source;
1561
+ runner.all('UPDATE _ganju_tables SET sync_state = ?, source = ? WHERE id = ?', [
1562
+ JSON.stringify(state),
1563
+ source ? JSON.stringify(source) : null,
1564
+ table.id
1565
+ ]);
1566
+ };
1567
+ // Makes a synced table's rows what the sheet says, in one transaction: new
1568
+ // keys are inserted, rows whose values differ updated, rows whose key is
1569
+ // gone deleted. Unchanged rows aren't written. Anything that doesn't fit
1570
+ // refuses the whole sync, and the table keeps its rows.
1571
+ const syncRows = (runner, schema, caller, args) => {
1572
+ (0, exports.assertWritable)(runner, caller, true);
1573
+ const table = tableNamed(schema, args?.table);
1574
+ const source = table.source;
1575
+ if (!source) {
1576
+ throw new dataTypes_1.DataError(`${table.name} isn't synced from a sheet.`, 'conflict');
1577
+ }
1578
+ const keyColumn = columnNamed(table, source.key);
1579
+ if (!Array.isArray(args.columns) || !Array.isArray(args.rows)) {
1580
+ throw new dataTypes_1.DataError('A sync takes "columns" and "rows".');
1581
+ }
1582
+ const columns = [...new Set(args.columns)].map(name => columnNamed(table, name));
1583
+ if (!columns.includes(keyColumn)) {
1584
+ throw new dataTypes_1.DataError(`The sheet has no column for ${table.name}'s key, ${keyColumn.name}.`);
1585
+ }
1586
+ const unfilled = table.columns.filter(c => c.required && (c.default === undefined || c.default === null) && !columns.includes(c));
1587
+ if (unfilled.length) {
1588
+ throw new dataTypes_1.DataError(`The sheet has no column for ${unfilled.map(c => `${table.name}.${c.name}`).join(', ')}, which ${unfilled.length === 1 ? 'is' : 'are'} required.`);
1589
+ }
1590
+ if (args.rows.length > dataTypes_1.DATA_SHEET_MAX_ROWS) {
1591
+ throw new dataTypes_1.DataError(`The sheet has ${args.rows.length.toLocaleString('en-US')} rows; a synced table takes up to ${dataTypes_1.DATA_SHEET_MAX_ROWS.toLocaleString('en-US')}.`, 'too_large');
1592
+ }
1593
+ // Every line as it will be stored, checked before anything is written.
1594
+ const keyOf = (value) => JSON.stringify(value);
1595
+ const wanted = new Map();
1596
+ args.rows.forEach((row, i) => {
1597
+ const line = args.lines?.[i] ?? i + 2;
1598
+ if (!isPlainObject(row))
1599
+ throw atLine(line, new dataTypes_1.DataError('A row is an object of column: value.'));
1600
+ const values = new Map();
1601
+ for (const column of columns) {
1602
+ const raw = row[column.name];
1603
+ let stored;
1604
+ try {
1605
+ stored = raw === undefined || raw === null ? null : (0, dataCompiler_1.toStored)(table, column, raw, caller.timeZone);
1606
+ }
1607
+ catch (error) {
1608
+ if (error instanceof dataTypes_1.DataError)
1609
+ throw atLine(line, error);
1610
+ throw error;
1611
+ }
1612
+ if (stored === null && column.required) {
1613
+ throw atLine(line, new dataTypes_1.DataError(`${table.name}.${column.name} is required.`));
1614
+ }
1615
+ if (column.min !== null && typeof stored === 'number' && stored < column.min) {
1616
+ throw atLine(line, new dataTypes_1.DataError(`${table.name}.${column.name} can't go below ${column.min}.`));
1617
+ }
1618
+ values.set(column.name, stored);
1619
+ }
1620
+ const key = values.get(keyColumn.name) ?? null;
1621
+ if (key === null) {
1622
+ throw atLine(line, new dataTypes_1.DataError(`The key, ${keyColumn.name}, is empty.`));
1623
+ }
1624
+ const seen = wanted.get(keyOf(key));
1625
+ if (seen) {
1626
+ throw atLine(line, new dataTypes_1.DataError(`${keyColumn.name} ${JSON.stringify(key)} is already on line ${seen.line}; each line of the sheet needs its own key.`, 'constraint', { key, lines: [seen.line, line] }));
1627
+ }
1628
+ wanted.set(keyOf(key), { line, values });
1629
+ });
1630
+ // Two syncs that overlap: the one that read the older file changes nothing.
1631
+ if (staleVersion(table, args.version)) {
1632
+ return { ...table.sync, inserted: 0, updated: 0, deleted: 0, unchanged: 0, stale: true };
1633
+ }
1634
+ return runner.transaction(() => {
1635
+ setContextActor(runner, caller.actor);
1636
+ runner.all('DELETE FROM _ganju_changes');
1637
+ const before = runner.usageSoFar?.().rowsWritten ?? 0;
1638
+ const names = columns.map(c => c.name);
1639
+ const existing = new Map();
1640
+ const stale = [];
1641
+ let nextId = nextRowId(runner, table);
1642
+ for (const row of runner.all(`SELECT id, ${names.map(n => (0, dataIdentifiers_1.quoteIdentifier)(n)).join(', ')} FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)}`)) {
1643
+ const key = row[keyColumn.name] ?? null;
1644
+ if (key !== null && wanted.has(keyOf(key)))
1645
+ existing.set(keyOf(key), row);
1646
+ else
1647
+ stale.push(Number(row.id));
1648
+ }
1649
+ const counts = { inserted: 0, updated: 0, deleted: 0, unchanged: 0 };
1650
+ // Rows the sheet no longer has go first, so a key that moved to a new
1651
+ // line isn't taken twice. A row another table still points at with
1652
+ // restrict stops the sync.
1653
+ for (const other of schema.tables) {
1654
+ for (const c of other.columns) {
1655
+ if (c.type !== 'relation' || c.table !== table.name)
1656
+ continue;
1657
+ if ((c.onDelete ?? 'restrict') !== 'restrict' || stale.length === 0)
1658
+ continue;
1659
+ let n = 0;
1660
+ for (let i = 0; i < stale.length; i += dataTypes_1.DATA_MAX_PARAMS) {
1661
+ const slice = stale.slice(i, i + dataTypes_1.DATA_MAX_PARAMS);
1662
+ 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);
1663
+ }
1664
+ if (n > 0) {
1665
+ throw new dataTypes_1.DataError(`Rows in ${other.name} still point at ${n === 1 ? 'a row' : `${n} rows`} the sheet no longer has (${other.name}.${c.name}); put them back in the sheet, or change those rows first.`, 'constraint', { table: other.name, column: c.name, count: n });
1666
+ }
1667
+ }
1668
+ }
1669
+ try {
1670
+ for (let i = 0; i < stale.length; i += dataTypes_1.DATA_MAX_PARAMS) {
1671
+ const slice = stale.slice(i, i + dataTypes_1.DATA_MAX_PARAMS);
1672
+ counts.deleted += runner.all(`DELETE FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE id IN (${slice.map(() => '?').join(', ')}) RETURNING id`, slice).length;
1673
+ }
1674
+ for (const [key, { line, values }] of wanted) {
1675
+ const current = existing.get(key);
1676
+ try {
1677
+ if (!current) {
1678
+ insertOne(runner, table, values, caller.actor, false, caller, nextId++);
1679
+ counts.inserted++;
1680
+ continue;
1681
+ }
1682
+ const changed = columns
1683
+ .filter(c => !sameStored(c, current[c.name] ?? null, values.get(c.name) ?? null))
1684
+ .map(c => c.name);
1685
+ if (changed.length === 0) {
1686
+ counts.unchanged++;
1687
+ continue;
1688
+ }
1689
+ runner.all(`UPDATE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} SET ${changed.map(n => `${(0, dataIdentifiers_1.quoteIdentifier)(n)} = ?`).join(', ')}, version = version + 1, updated_at = ${dataDdl_1.DATA_NOW_SQL}, updated_by = ? WHERE id = ?`, [...changed.map(n => values.get(n) ?? null), caller.actor, Number(current.id)]);
1690
+ counts.updated++;
1691
+ }
1692
+ catch (error) {
1693
+ throw atLine(line, (0, exports.translateSqliteError)(schema, table, error));
1694
+ }
1695
+ }
1696
+ }
1697
+ catch (error) {
1698
+ throw (0, exports.translateSqliteError)(schema, table, error);
1699
+ }
1700
+ runner.noteWrite?.({
1701
+ table: table.name,
1702
+ rowsWritten: (runner.usageSoFar?.().rowsWritten ?? 0) - before
1703
+ });
1704
+ (0, dataTriggers_1.collectTriggerEvents)(runner, schema, caller);
1705
+ const state = {
1706
+ at: nowStamp(),
1707
+ status: 'ok',
1708
+ ...counts,
1709
+ error: null,
1710
+ version: typeof args.version === 'string' ? args.version : (table.sync?.version ?? null)
1711
+ };
1712
+ saveSyncState(runner, table, state, args.title);
1713
+ return state;
1714
+ });
1715
+ };
1716
+ exports.syncRows = syncRows;
1717
+ // Keeps why a sync was refused on the table, where the owner sees it. The
1718
+ // rows are as they were.
1719
+ const recordSyncFailure = (runner, schema, args) => {
1720
+ const table = schema.tables.find(t => t.name === args?.table);
1721
+ if (!table?.source)
1722
+ return null;
1723
+ // An older file's problem says nothing about the rows now.
1724
+ if (staleVersion(table, args.version))
1725
+ return table.sync ?? null;
1726
+ const state = {
1727
+ at: nowStamp(),
1728
+ status: 'failed',
1729
+ inserted: 0,
1730
+ updated: 0,
1731
+ deleted: 0,
1732
+ unchanged: 0,
1733
+ error: String(args.error ?? '').slice(0, 1000) || 'The sync failed.',
1734
+ // The rows are still from the last file applied.
1735
+ version: table.sync?.version ?? null
1736
+ };
1737
+ saveSyncState(runner, table, state, args.title);
1738
+ return state;
1739
+ };
1740
+ exports.recordSyncFailure = recordSyncFailure;
1371
1741
  // Delete preview
1372
1742
  // What deleting `ids` of a table, or all its rows, would do to the rows that
1373
1743
  // point at them, counted the way SQLite applies it: cascades are followed to
@@ -1461,6 +1831,93 @@ const deleteImpact = (runner, schema, caller, rawArgs) => {
1461
1831
  };
1462
1832
  };
1463
1833
  exports.deleteImpact = deleteImpact;
1834
+ // File links
1835
+ const fileColumns = (schema) => schema.tables.flatMap(table => table.columns
1836
+ .filter(column => column.type === 'file')
1837
+ .map(column => ({ table, column })));
1838
+ const fileUriList = (value) => {
1839
+ if (!Array.isArray(value) ||
1840
+ value.length === 0 ||
1841
+ value.length > dataTypes_1.DATA_FILE_LINKS_MAX_URIS) {
1842
+ throw new dataTypes_1.DataError(`"uris" lists 1 to ${dataTypes_1.DATA_FILE_LINKS_MAX_URIS} knowledge item uris.`);
1843
+ }
1844
+ return [...new Set(value.map(uri => (0, dataCompiler_1.fileUriOf)(uri, 'uris')))];
1845
+ };
1846
+ // The rows whose file columns point at `uris`, per column: knowledge asks
1847
+ // before it deletes an item. Each file column is indexed.
1848
+ const fileLinks = (runner, schema, caller, rawArgs) => {
1849
+ (0, exports.assertActor)(caller);
1850
+ if (caller.restricted) {
1851
+ throw new dataTypes_1.DataError('File links are for the owner.', 'forbidden');
1852
+ }
1853
+ const uris = JSON.stringify(fileUriList((0, dataCompiler_1.tidyArgs)(rawArgs).uris));
1854
+ const links = [];
1855
+ for (const { table, column } of fileColumns(schema)) {
1856
+ const rows = Number(first(runner, `SELECT count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} IN (SELECT value FROM json_each(?))`, [uris])?.n ?? 0);
1857
+ if (rows === 0)
1858
+ continue;
1859
+ links.push({
1860
+ table: table.name,
1861
+ tableLabel: table.label,
1862
+ column: column.name,
1863
+ columnLabel: column.label,
1864
+ required: column.required,
1865
+ rows
1866
+ });
1867
+ }
1868
+ return { links };
1869
+ };
1870
+ exports.fileLinks = fileLinks;
1871
+ // Points the links at `from` to `to`, or clears them (`to: null`) once the
1872
+ // item is gone. A required column keeps its link, which then reads as
1873
+ // missing. Recorded in row history; triggers don't see it.
1874
+ const replaceFileLinks = (runner, schema, caller, rawArgs) => {
1875
+ (0, exports.assertActor)(caller);
1876
+ if (caller.restricted) {
1877
+ throw new dataTypes_1.DataError('File links are for the owner.', 'forbidden');
1878
+ }
1879
+ const raw = (0, dataCompiler_1.tidyArgs)(rawArgs).changes;
1880
+ if (!Array.isArray(raw) ||
1881
+ raw.length === 0 ||
1882
+ raw.length > dataTypes_1.DATA_FILE_LINKS_MAX_URIS) {
1883
+ throw new dataTypes_1.DataError(`"changes" lists 1 to ${dataTypes_1.DATA_FILE_LINKS_MAX_URIS} { from, to } pairs.`);
1884
+ }
1885
+ const changes = raw.map(change => {
1886
+ if (!isPlainObject(change)) {
1887
+ throw new dataTypes_1.DataError('Each change is { from, to }; to is null to clear.');
1888
+ }
1889
+ return {
1890
+ from: (0, dataCompiler_1.fileUriOf)(change.from, 'from'),
1891
+ to: change.to === null ? null : (0, dataCompiler_1.fileUriOf)(change.to, 'to')
1892
+ };
1893
+ });
1894
+ const columns = fileColumns(schema);
1895
+ if (columns.length === 0)
1896
+ return { updated: 0, kept: [] };
1897
+ (0, exports.assertWritable)(runner, caller, changes.some(change => change.to !== null));
1898
+ return runner.transaction(() => {
1899
+ setContextActor(runner, caller.actor);
1900
+ runner.all('DELETE FROM _ganju_changes');
1901
+ let updated = 0;
1902
+ const kept = [];
1903
+ for (const { table, column } of columns) {
1904
+ for (const change of changes) {
1905
+ if (change.to === null && column.required) {
1906
+ const rows = Number(first(runner, `SELECT count(*) AS n FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} WHERE ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} = ?`, [change.from])?.n ?? 0);
1907
+ if (rows > 0)
1908
+ kept.push({ table: table.name, column: column.name, rows });
1909
+ continue;
1910
+ }
1911
+ updated += runner.all(`UPDATE ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} SET ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} = ?, version = version + 1, updated_at = ${dataDdl_1.DATA_NOW_SQL}, updated_by = ? WHERE ${(0, dataIdentifiers_1.quoteIdentifier)(column.name)} = ? RETURNING id`, [change.to, caller.actor, change.from]).length;
1912
+ }
1913
+ }
1914
+ // Tidying links after a knowledge change isn't the owner's edit: it
1915
+ // stays in row history but fires no trigger.
1916
+ runner.all('DELETE FROM _ganju_changes');
1917
+ return { updated, kept };
1918
+ });
1919
+ };
1920
+ exports.replaceFileLinks = replaceFileLinks;
1464
1921
  // Row history
1465
1922
  const rowHistory = (runner, schema, caller, args) => {
1466
1923
  const table = (0, dataCompiler_1.tableFor)(schema, caller, args.table);
@@ -1603,6 +2060,7 @@ const createTable = (runner, schema, caller, input) => {
1603
2060
  const access = normaliseAccess(input.access, 'read');
1604
2061
  const customerScoped = flag(input.customerScoped, 'customerScoped');
1605
2062
  const history = input.history === undefined ? true : flag(input.history, 'history');
2063
+ const source = normaliseSource(input.source, name, columns, customerScoped);
1606
2064
  return runner.transaction(() => {
1607
2065
  const position = Number(first(runner, 'SELECT coalesce(max(position), -1) + 1 AS p FROM _ganju_tables')?.p ?? 0);
1608
2066
  // Never an id a dropped table had: its row history stays in the log
@@ -1612,7 +2070,7 @@ const createTable = (runner, schema, caller, input) => {
1612
2070
  coalesce((SELECT max(id) FROM _ganju_tables), 0),
1613
2071
  coalesce((SELECT max(table_id) FROM _ganju_row_log), 0)
1614
2072
  ) + 1 AS id`)?.id ?? 1);
1615
- const tableId = Number(runner.all('INSERT INTO _ganju_tables (id, name, label, description, access, customer_scoped, history, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id', [
2073
+ const tableId = Number(runner.all('INSERT INTO _ganju_tables (id, name, label, description, access, customer_scoped, history, position, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id', [
1616
2074
  nextId,
1617
2075
  name,
1618
2076
  cleanText(input.label, dataTypes_1.DATA_MAX_LABEL, `${name} label`),
@@ -1620,7 +2078,8 @@ const createTable = (runner, schema, caller, input) => {
1620
2078
  access,
1621
2079
  customerScoped ? 1 : 0,
1622
2080
  history ? 1 : 0,
1623
- position
2081
+ position,
2082
+ source ? JSON.stringify(source) : null
1624
2083
  ])[0].id);
1625
2084
  const stored = columns.map((c, i) => ({
1626
2085
  ...c,
@@ -1641,7 +2100,7 @@ const updateTable = (runner, schema, caller, name, patch) => {
1641
2100
  const table = tableNamed(schema, name);
1642
2101
  if (!isPlainObject(patch))
1643
2102
  throw new dataTypes_1.DataError('The change is an object.');
1644
- const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history'];
2103
+ const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history', 'source'];
1645
2104
  for (const key of Object.keys(patch)) {
1646
2105
  if (!allowed.includes(key)) {
1647
2106
  throw new dataTypes_1.DataError(`"${key}" can't be changed here; tables take ${allowed.join(', ')}.`);
@@ -1677,6 +2136,9 @@ const updateTable = (runner, schema, caller, name, patch) => {
1677
2136
  params.push(normaliseAccess(patch.access, table.access));
1678
2137
  }
1679
2138
  if ('customerScoped' in patch) {
2139
+ const keepsSource = 'source' in patch ? patch.source !== null && patch.source !== undefined : !!table.source;
2140
+ if (patch.customerScoped === true && keepsSource)
2141
+ throw syncedScopedError(table.name);
1680
2142
  sets.push('customer_scoped = ?');
1681
2143
  params.push(flag(patch.customerScoped, 'customerScoped') ? 1 : 0);
1682
2144
  }
@@ -1684,6 +2146,15 @@ const updateTable = (runner, schema, caller, name, patch) => {
1684
2146
  sets.push('history = ?');
1685
2147
  params.push(flag(patch.history, 'history') ? 1 : 0);
1686
2148
  }
2149
+ if ('source' in patch) {
2150
+ const scoped = 'customerScoped' in patch ? patch.customerScoped === true : table.customerScoped;
2151
+ const source = normaliseSource(patch.source, current, table.columns, scoped);
2152
+ sets.push('source = ?');
2153
+ params.push(source ? JSON.stringify(source) : null);
2154
+ // A new file, sheet or key starts over: the last sync was of another.
2155
+ if (!sameSource(table.source, source))
2156
+ sets.push('sync_state = NULL');
2157
+ }
1687
2158
  if (sets.length) {
1688
2159
  runner.all(`UPDATE _ganju_tables SET ${sets.join(', ')} WHERE id = ?`, [...params, table.id]);
1689
2160
  }
@@ -2041,7 +2512,21 @@ const updateColumn = (runner, schema, caller, tableName, columnName, patch) => {
2041
2512
  if (draft.name !== column.name && table.columns.some(c => c.name === draft.name)) {
2042
2513
  throw new dataTypes_1.DataError(`${table.name} already has a column "${draft.name}".`, 'conflict');
2043
2514
  }
2515
+ // A time trigger counts from a date or datetime; anything else has no time.
2516
+ if (draft.type !== 'date' && draft.type !== 'datetime') {
2517
+ const timed = (0, dataTriggers_1.timeTriggersCountingFrom)(runner, table.id, column.name);
2518
+ if (timed.length) {
2519
+ throw new dataTypes_1.DataError(`${table.name}.${column.name} stays a date or datetime while time triggers count from it (${timed.join(', ')}); change or delete them first.`, 'constraint', { triggers: timed });
2520
+ }
2521
+ }
2522
+ const isSyncKey = table.source?.key === column.name;
2523
+ if (isSyncKey && !draft.unique) {
2524
+ throw new dataTypes_1.DataError(`${table.name}.${column.name} is the key the sheet sync matches rows by, so it stays unique.`, 'constraint');
2525
+ }
2044
2526
  if ((0, exports.columnChangeNeedsRebuild)(schema, table.name, column.name, patch)) {
2527
+ if (isSyncKey && draft.name !== column.name) {
2528
+ throw new dataTypes_1.DataError(`${table.name}.${column.name} is the sheet sync's key: rename it on its own, then make the other changes.`, 'constraint');
2529
+ }
2045
2530
  // A rename renames the column in its triggers too; a copy can't, so the
2046
2531
  // two go one after the other.
2047
2532
  const triggers = draft.name !== column.name ? (0, dataTriggers_1.triggersUsingColumn)(runner, table.id, column.name) : [];
@@ -2056,6 +2541,12 @@ const updateColumn = (runner, schema, caller, tableName, columnName, patch) => {
2056
2541
  runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)((0, dataDdl_1.ftsTableName)(table))}`);
2057
2542
  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)}`);
2058
2543
  (0, dataTriggers_1.renameColumnInTriggers)(runner, table.id, column.name, draft.name);
2544
+ if (isSyncKey && table.source) {
2545
+ runner.all('UPDATE _ganju_tables SET source = ? WHERE id = ?', [
2546
+ JSON.stringify({ ...table.source, key: draft.name }),
2547
+ table.id
2548
+ ]);
2549
+ }
2059
2550
  }
2060
2551
  updateColumnMeta(runner, schema, table, column.id, draft);
2061
2552
  touchTable(runner, table.id);
@@ -2073,6 +2564,9 @@ const dropColumn = (runner, schema, caller, tableName, columnName) => {
2073
2564
  throw new dataTypes_1.DataError(`${table.name} needs at least one column; drop the table instead.`);
2074
2565
  }
2075
2566
  assertNoTriggerUses(runner, table, column.name);
2567
+ if (table.source?.key === column.name) {
2568
+ throw new dataTypes_1.DataError(`${table.name}.${column.name} is the key the sheet sync matches rows by; stop syncing the table, or pick another key, first.`, 'constraint');
2569
+ }
2076
2570
  if ((0, exports.dropColumnNeedsRebuild)(schema, table.name, column.name)) {
2077
2571
  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);
2078
2572
  }