@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
package/src/dataEngine.ts CHANGED
@@ -3,7 +3,12 @@
3
3
  // the tests and the tool-design eval. The runner executes; everything about
4
4
  // which SQL is allowed and how results look is decided here.
5
5
  import { identifierIssue, quoteIdentifier as q } from './dataIdentifiers';
6
- import { collectTriggerEvents, renameColumnInTriggers, triggersUsingColumn } from './dataTriggers';
6
+ import {
7
+ collectTriggerEvents,
8
+ renameColumnInTriggers,
9
+ timeTriggersCountingFrom,
10
+ triggersUsingColumn
11
+ } from './dataTriggers';
7
12
  import {
8
13
  DATA_NOW_SQL,
9
14
  NUMERIC_TYPES,
@@ -30,6 +35,7 @@ import {
30
35
  compileWhere,
31
36
  decodeCursor,
32
37
  encodeCursor,
38
+ fileUriOf,
33
39
  fromStored,
34
40
  ftsMatch,
35
41
  isScoped,
@@ -74,7 +80,10 @@ import {
74
80
  DATA_COPY_STEP_ROWS,
75
81
  DATA_IMPACT_MAX_IDS,
76
82
  DATA_IMPACT_MAX_ROWS,
83
+ DATA_FILE_LINKS_MAX_URIS,
77
84
  DATA_REBUILD_MAX_ROWS,
85
+ DATA_FILE_URI_MAX,
86
+ DATA_SHEET_MAX_ROWS,
78
87
  DataError
79
88
  } from './dataTypes';
80
89
  import type {
@@ -85,11 +94,19 @@ import type {
85
94
  DataColumnType,
86
95
  DataDefaultValue,
87
96
  DataDeleteImpact,
97
+ DataFileLinkChange,
98
+ DataFileLinks,
99
+ DataFileLinksReplaced,
88
100
  DataOnDelete,
101
+ DataQueryShape,
89
102
  DataSchema,
90
103
  DataTable,
91
104
  DataTableCopy,
92
- DataTableInput
105
+ DataTableInput,
106
+ DataTableSource,
107
+ DataTableSync,
108
+ DataUsage,
109
+ DataWriteShape
93
110
  } from './dataTypes';
94
111
 
95
112
  export type SqlRow = Record<string, SqlValue>;
@@ -101,6 +118,11 @@ export interface SqlRunner {
101
118
  iterate(sql: string, params?: readonly SqlValue[]): Iterable<SqlRow>;
102
119
  transaction<T>(fn: () => T): T;
103
120
  databaseSize(): number;
121
+ // Rows read and written so far in this call, and where a query or write
122
+ // notes what it did. Optional: tests and internal runners skip them.
123
+ usageSoFar?(): DataUsage;
124
+ noteQuery?(shape: DataQueryShape): void;
125
+ noteWrite?(shape: DataWriteShape): void;
104
126
  }
105
127
 
106
128
  const first = (runner: SqlRunner, sql: string, params?: readonly SqlValue[]) =>
@@ -227,6 +249,26 @@ export const INTERNAL_MIGRATIONS: readonly (readonly string[])[] = [
227
249
  message TEXT NOT NULL,
228
250
  created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL})
229
251
  )`
252
+ ],
253
+ // Time triggers: when each fires (`schedule`), how far its scans have got
254
+ // (`scan_state`), and the rows already fired for a due time, so none fires
255
+ // twice for the same one.
256
+ [
257
+ 'ALTER TABLE _ganju_triggers ADD COLUMN schedule TEXT',
258
+ 'ALTER TABLE _ganju_triggers ADD COLUMN scan_state TEXT',
259
+ `CREATE TABLE IF NOT EXISTS _ganju_trigger_fired (
260
+ trigger_id TEXT NOT NULL,
261
+ row_id INTEGER NOT NULL,
262
+ due_at TEXT NOT NULL,
263
+ PRIMARY KEY (trigger_id, row_id, due_at)
264
+ ) WITHOUT ROWID`,
265
+ 'CREATE INDEX IF NOT EXISTS _ganju_trigger_fired_due ON _ganju_trigger_fired(due_at)'
266
+ ],
267
+ // Tables synced from a spreadsheet: where from (`source`), and how the
268
+ // last sync went (`sync_state`).
269
+ [
270
+ 'ALTER TABLE _ganju_tables ADD COLUMN source TEXT',
271
+ 'ALTER TABLE _ganju_tables ADD COLUMN sync_state TEXT'
230
272
  ]
231
273
  ];
232
274
 
@@ -259,6 +301,18 @@ export const setMeta = (
259
301
  );
260
302
  };
261
303
 
304
+ // SQLite has no ADD COLUMN IF NOT EXISTS: a column already there is
305
+ // skipped, so every version can run again like the CREATE ... IF NOT EXISTS
306
+ // ones.
307
+ const ADD_COLUMN = /^ALTER TABLE (\w+) ADD COLUMN (\w+) /;
308
+ const runMigration = (runner: SqlRunner, statement: string) => {
309
+ const add = ADD_COLUMN.exec(statement);
310
+ if (add && runner.all(`PRAGMA table_info(${q(add[1])})`).some(c => c.name === add[2])) {
311
+ return;
312
+ }
313
+ runner.all(statement);
314
+ };
315
+
262
316
  // Brings our own tables up to date. Returns the version now in force.
263
317
  export const migrateInternal = (runner: SqlRunner): number =>
264
318
  runner.transaction(() => {
@@ -266,7 +320,7 @@ export const migrateInternal = (runner: SqlRunner): number =>
266
320
  ? Number(getMeta(runner, 'schema_version') ?? 0)
267
321
  : 0;
268
322
  for (let v = current; v < INTERNAL_MIGRATIONS.length; v++) {
269
- for (const statement of INTERNAL_MIGRATIONS[v]) runner.all(statement);
323
+ for (const statement of INTERNAL_MIGRATIONS[v]) runMigration(runner, statement);
270
324
  }
271
325
  if (current < INTERNAL_MIGRATIONS.length) {
272
326
  setMeta(runner, 'schema_version', String(INTERNAL_MIGRATIONS.length));
@@ -303,6 +357,8 @@ export const loadSchema = (runner: SqlRunner): DataSchema => {
303
357
  customerScoped: row.customer_scoped === 1,
304
358
  history: row.history === 1,
305
359
  position: Number(row.position),
360
+ source: parseJson<DataTableSource>(row.source ?? null),
361
+ sync: parseJson<DataTableSync>(row.sync_state ?? null),
306
362
  columns: columns
307
363
  .filter(c => c.table_id === row.id)
308
364
  .map(c => {
@@ -483,9 +539,14 @@ export const normaliseColumn = (
483
539
  if (column.unique && !UNIQUE_TYPES.has(column.type)) {
484
540
  throw new DataError(`${label}: a ${column.type} column can't be unique.`);
485
541
  }
486
- if (column.searchable && !SEARCHABLE_TYPES.has(column.type)) {
542
+ // On a file column, searchable means its uploads are indexed in knowledge.
543
+ if (
544
+ column.searchable &&
545
+ !SEARCHABLE_TYPES.has(column.type) &&
546
+ column.type !== 'file'
547
+ ) {
487
548
  throw new DataError(
488
- `${label}: only text columns can be searchable (text, long_text, email, url, phone, enum).`
549
+ `${label}: only text and file columns can be searchable (text, long_text, email, url, phone, enum, file).`
489
550
  );
490
551
  }
491
552
  // The search table has a column of its own called rank.
@@ -541,6 +602,59 @@ const normaliseAccess = (value: unknown, fallback: DataAccess): DataAccess => {
541
602
  return value as DataAccess;
542
603
  };
543
604
 
605
+ // A table's sheet source, checked against the columns it will have. Null
606
+ // stops syncing.
607
+ const normaliseSource = (
608
+ value: unknown,
609
+ tableName: string,
610
+ columns: readonly Pick<DataColumn, 'name' | 'unique'>[],
611
+ customerScoped: boolean
612
+ ): DataTableSource | null => {
613
+ if (value === undefined || value === null) return null;
614
+ // A sheet's rows have no customer, so a bot would never see them.
615
+ if (customerScoped) throw syncedScopedError(tableName);
616
+ if (!isPlainObject(value)) {
617
+ throw new DataError('"source" is { "uri", "sheet"?, "key" }, or null to stop syncing.');
618
+ }
619
+ for (const key of Object.keys(value)) {
620
+ if (!['uri', 'sheet', 'key', 'title'].includes(key)) {
621
+ throw new DataError(`"source" takes uri, sheet and key, not "${key}".`);
622
+ }
623
+ }
624
+ const { uri, sheet, key, title } = value;
625
+ if (typeof uri !== 'string' || !/^[a-z][\w+.-]*:\/\/\S+$/i.test(uri) || uri.length > DATA_FILE_URI_MAX) {
626
+ throw new DataError('"source.uri" is the uri of a spreadsheet or CSV in the project\'s knowledge.');
627
+ }
628
+ if (sheet !== undefined && sheet !== null && (typeof sheet !== 'string' || !sheet.trim() || sheet.length > 100)) {
629
+ throw new DataError('"source.sheet" is the name of a sheet in the file, up to 100 characters.');
630
+ }
631
+ const column = columns.find(c => c.name === key);
632
+ if (typeof key !== 'string' || !column) {
633
+ throw new DataError(
634
+ `"source.key" is the column that names a row in both the sheet and ${tableName}. Columns: ${columns.map(c => c.name).join(', ')}.`
635
+ );
636
+ }
637
+ if (!column.unique) {
638
+ throw new DataError(`${tableName}.${key} must be unique to be the sync key.`);
639
+ }
640
+ return {
641
+ uri,
642
+ ...(typeof sheet === 'string' ? { sheet: sheet.trim() } : {}),
643
+ key,
644
+ ...(typeof title === 'string' && title.trim() ? { title: title.trim().slice(0, 200) } : {})
645
+ };
646
+ };
647
+
648
+ const syncedScopedError = (tableName: string) =>
649
+ new DataError(
650
+ `${tableName} can't be both customer-scoped and synced from a sheet: synced rows have no customer, so the bots wouldn't see them.`
651
+ );
652
+
653
+ const sameSource = (a: DataTableSource | null | undefined, b: DataTableSource | null) =>
654
+ (a?.uri ?? null) === (b?.uri ?? null) &&
655
+ (a?.sheet ?? null) === (b?.sheet ?? null) &&
656
+ (a?.key ?? null) === (b?.key ?? null);
657
+
544
658
  // Keeping indexes, triggers and the search table in line with the metadata
545
659
 
546
660
  const tableObjects = (runner: SqlRunner, type: string, table: string) =>
@@ -786,6 +900,8 @@ const shapeRow = (
786
900
  ? fromStored(display, label, timeZone)
787
901
  : label
788
902
  };
903
+ } else if (column.type === 'file') {
904
+ row[column.name] = value === null ? null : { uri: String(value) };
789
905
  } else {
790
906
  row[column.name] = value;
791
907
  }
@@ -890,8 +1006,20 @@ export const query = (
890
1006
  caller,
891
1007
  table,
892
1008
  joins: new Map(),
893
- params: []
1009
+ params: [],
1010
+ filtered: new Set()
894
1011
  };
1012
+ // What this query read, noted for index advice once it has its rows.
1013
+ const readBefore = runner.usageSoFar?.().rowsRead ?? 0;
1014
+ const sorted: string[] = [];
1015
+ const noteShape = (returned: number) =>
1016
+ runner.noteQuery?.({
1017
+ table: table.name,
1018
+ filtered: [...(ctx.filtered ?? [])],
1019
+ sorted,
1020
+ rowsRead: (runner.usageSoFar?.().rowsRead ?? 0) - readBefore,
1021
+ returned
1022
+ });
895
1023
  const indexed = new Set<string>();
896
1024
  const where = compileWhere(ctx, args.where, indexed);
897
1025
  if (isScoped(caller, table)) {
@@ -907,7 +1035,10 @@ export const query = (
907
1035
  }
908
1036
 
909
1037
  if (args.aggregate !== undefined && args.aggregate !== null) {
910
- return aggregate(runner, ctx, args, where, searching, indexed.size > 0);
1038
+ const result = aggregate(runner, ctx, args, where, searching, indexed.size > 0);
1039
+ const groups = (result as { groups?: unknown }).groups;
1040
+ noteShape(Array.isArray(groups) ? groups.length : 1);
1041
+ return result;
911
1042
  }
912
1043
 
913
1044
  const limit = args.limit === undefined || args.limit === null ? DATA_DEFAULT_PAGE : args.limit;
@@ -946,6 +1077,7 @@ export const query = (
946
1077
  descending,
947
1078
  ...(servedByIndex ? {} : { unindexed: resolved.column.name })
948
1079
  });
1080
+ if (resolved.own && resolved.column.name !== 'id') sorted.push(resolved.column.name);
949
1081
  if (resolved.own && resolved.column.name === 'id') {
950
1082
  endsUnique = true;
951
1083
  break;
@@ -1043,6 +1175,7 @@ export const query = (
1043
1175
  }
1044
1176
 
1045
1177
  const { rows: out, last, more } = result;
1178
+ noteShape(out.length);
1046
1179
  const includeTruncated =
1047
1180
  args.include !== undefined && args.include !== null
1048
1181
  ? includeRelations(
@@ -1055,11 +1188,13 @@ export const query = (
1055
1188
  DATA_MAX_RESULT_BYTES - result.bytes
1056
1189
  )
1057
1190
  : false;
1191
+ const fileFields = fileFieldPaths(schema, table, args.include);
1058
1192
  return {
1059
1193
  table: table.name,
1060
1194
  ...(isScoped(caller, table) ? { scope: SCOPE_NOTE } : {}),
1061
1195
  ...(result.total !== undefined ? { total: result.total } : {}),
1062
1196
  ...(matched ? { searchMatched: matched } : {}),
1197
+ ...(fileFields.length ? { fileFields } : {}),
1063
1198
  rows: out,
1064
1199
  ...(includeTruncated
1065
1200
  ? {
@@ -1079,6 +1214,29 @@ export const query = (
1079
1214
  };
1080
1215
  };
1081
1216
 
1217
+ // Where a query's rows hold file values, as key paths within each row: the
1218
+ // table's file columns, and those of each included relation. The workers
1219
+ // fill those in from knowledge and drop this before answering; a json
1220
+ // column holding { "uri": … } is never mistaken for one.
1221
+ const fileFieldPaths = (
1222
+ schema: DataSchema,
1223
+ table: DataTable,
1224
+ include: unknown
1225
+ ): string[][] => {
1226
+ const own = table.columns
1227
+ .filter(column => column.type === 'file')
1228
+ .map(column => [column.name]);
1229
+ const included = (Array.isArray(include) ? include : []).flatMap(name => {
1230
+ const column = typeof name === 'string' ? columnFor(table, name) : undefined;
1231
+ if (!column || isSystemColumn(column) || column.type !== 'relation') return [];
1232
+ const target = schema.tables.find(t => t.name === column.table);
1233
+ return (target?.columns ?? [])
1234
+ .filter(c => c.type === 'file')
1235
+ .map(c => [name as string, c.name]);
1236
+ });
1237
+ return [...own, ...included];
1238
+ };
1239
+
1082
1240
  // Replaces each relation in `rows` named in `include` with the related row,
1083
1241
  // read as the caller may see it, within `budget` bytes. Returns whether any
1084
1242
  // was left as { id, label } to stay inside it.
@@ -1341,6 +1499,18 @@ export const assertWritable = (
1341
1499
  }
1342
1500
  };
1343
1501
 
1502
+ // A table synced from a sheet takes its rows from the sheet alone, whoever
1503
+ // writes: the dashboard, the API, the CLI, functions and the assistant.
1504
+ export const assertNotSynced = (table: DataTable) => {
1505
+ if (!table.source) return;
1506
+ const from = table.source.title || table.source.uri;
1507
+ throw new DataError(
1508
+ `${table.name} is synced from "${from}"; change the sheet, or stop syncing it.`,
1509
+ 'read_only',
1510
+ { source: table.source }
1511
+ );
1512
+ };
1513
+
1344
1514
  // Every write names its actor here first, so row history (kept by triggers)
1345
1515
  // records who made each change, cascades included.
1346
1516
  const setContextActor = (runner: SqlRunner, actor: string) => {
@@ -1575,6 +1745,7 @@ const runOperation = (
1575
1745
  op = { ...rawOp, id };
1576
1746
  }
1577
1747
  const table = tableFor(schema, caller, op.table, kind === 'insert' ? 'add' : 'full');
1748
+ assertNotSynced(table);
1578
1749
  const actor = caller.actor;
1579
1750
  const scoped = isScoped(caller, table);
1580
1751
 
@@ -1602,19 +1773,21 @@ const runOperation = (
1602
1773
  // One column list for the whole batch, so rows that leave a column out
1603
1774
  // get NULL there rather than its default only when another row sets it.
1604
1775
  const names = [...new Set(prepared.flatMap(values => [...values.keys()]))];
1605
- const columns = [...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1776
+ const columns = ['id', ...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1606
1777
  const ids: number[] = [];
1778
+ let nextId = nextRowId(runner, table);
1607
1779
  try {
1608
1780
  for (const chunk of chunkRows(prepared, columns.length)) {
1609
1781
  const usesDefaults = chunk.some(values => names.some(n => !values.has(n)));
1610
1782
  if (usesDefaults && chunk.length > 1) {
1611
1783
  // Rows with different columns go one by one, so each missing
1612
1784
  // column takes its default.
1613
- for (const values of chunk) ids.push(insertOne(runner, table, values, actor, scoped, caller));
1785
+ for (const values of chunk) ids.push(insertOne(runner, table, values, actor, scoped, caller, nextId++));
1614
1786
  continue;
1615
1787
  }
1616
1788
  const params: SqlValue[] = [];
1617
1789
  for (const values of chunk) {
1790
+ params.push(nextId++);
1618
1791
  for (const name of names) params.push(values.get(name) ?? null);
1619
1792
  params.push(actor, actor);
1620
1793
  if (scoped) params.push(participantOf(caller));
@@ -1731,6 +1904,33 @@ const runOperation = (
1731
1904
  .all(`SELECT t.id FROM ${q(table.name)} t WHERE ${where}`, ctx.params)
1732
1905
  .map(r => Number(r.id))
1733
1906
  : assertBoundedTarget();
1907
+ // A delete here mustn't change a synced table's rows behind its sheet,
1908
+ // through a cascade or a set null.
1909
+ for (const other of schema.tables) {
1910
+ if (!other.source || other.name === table.name || ids.length === 0) continue;
1911
+ for (const c of other.columns) {
1912
+ if (c.type !== 'relation' || c.table !== table.name) continue;
1913
+ if ((c.onDelete ?? 'restrict') === 'restrict') continue;
1914
+ let n = 0;
1915
+ for (let i = 0; i < ids.length; i += DATA_MAX_PARAMS) {
1916
+ const slice = ids.slice(i, i + DATA_MAX_PARAMS);
1917
+ n += Number(
1918
+ first(
1919
+ runner,
1920
+ `SELECT count(*) AS n FROM ${q(other.name)} WHERE ${q(c.name)} IN (${slice.map(() => '?').join(', ')})`,
1921
+ slice
1922
+ )?.n ?? 0
1923
+ );
1924
+ }
1925
+ if (n > 0) {
1926
+ throw new DataError(
1927
+ `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.`,
1928
+ 'read_only',
1929
+ { table: other.name, column: c.name, count: n }
1930
+ );
1931
+ }
1932
+ }
1933
+ }
1734
1934
  // A readable "3 rows in order_lines still point at this order" before
1735
1935
  // SQLite's bare foreign key error. A row of the same table counts even
1736
1936
  // when this delete removes it too: SQLite checks a restrict as each row
@@ -1793,16 +1993,32 @@ const runOperation = (
1793
1993
  return { op: kind, table: table.name, changed };
1794
1994
  };
1795
1995
 
1996
+ // The id the table's next row gets. Tables have no AUTOINCREMENT, so SQLite
1997
+ // would hand out the highest id again once that row is deleted, and the
1998
+ // deleted row's history would read as the new one's. Above both, instead.
1999
+ const nextRowId = (runner: SqlRunner, table: DataTable): number =>
2000
+ Number(
2001
+ first(
2002
+ runner,
2003
+ `SELECT max(
2004
+ coalesce((SELECT max(id) FROM ${q(table.name)}), 0),
2005
+ coalesce((SELECT max(row_id) FROM _ganju_row_log WHERE table_id = ?), 0)
2006
+ ) + 1 AS id`,
2007
+ [table.id]
2008
+ )?.id ?? 1
2009
+ );
2010
+
1796
2011
  const insertOne = (
1797
2012
  runner: SqlRunner,
1798
2013
  table: DataTable,
1799
2014
  values: Map<string, SqlValue>,
1800
2015
  actor: string,
1801
2016
  scoped: boolean,
1802
- caller: DataCaller
2017
+ caller: DataCaller,
2018
+ id: number
1803
2019
  ): number => {
1804
- const names = [...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1805
- const params = [...values.values(), actor, actor, ...(scoped ? [participantOf(caller)] : [])];
2020
+ const names = ['id', ...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
2021
+ const params = [id, ...values.values(), actor, actor, ...(scoped ? [participantOf(caller)] : [])];
1806
2022
  return Number(
1807
2023
  runner.all(
1808
2024
  `INSERT INTO ${q(table.name)} (${names.map(q).join(', ')}) VALUES (${names.map(() => '?').join(', ')}) RETURNING id`,
@@ -1840,7 +2056,12 @@ export const write = (
1840
2056
  const results: WriteResult[] = [];
1841
2057
  operations.forEach((op, index) => {
1842
2058
  try {
2059
+ const before = runner.usageSoFar?.().rowsWritten ?? 0;
1843
2060
  results.push(runOperation(runner, schema, caller, op, index, results));
2061
+ runner.noteWrite?.({
2062
+ table: results[results.length - 1].table,
2063
+ rowsWritten: (runner.usageSoFar?.().rowsWritten ?? 0) - before
2064
+ });
1844
2065
  } catch (error) {
1845
2066
  if (error instanceof DataError) {
1846
2067
  const label = isPlainObject(op) ? `${String(op.op)} ${String(op.table)}` : '?';
@@ -1858,6 +2079,279 @@ export const write = (
1858
2079
  });
1859
2080
  };
1860
2081
 
2082
+ // Sheet sync
2083
+
2084
+ export interface SheetSyncArgs {
2085
+ table: string;
2086
+ // The table's columns the sheet has, matched from its headers.
2087
+ columns: string[];
2088
+ // Each line of the sheet as column: value, read the way an import reads
2089
+ // it; a column left out is empty in that line.
2090
+ rows: Record<string, unknown>[];
2091
+ // Each row's line in the sheet, for messages.
2092
+ lines?: number[];
2093
+ // The file's name now, for the read-only message.
2094
+ title?: string | null;
2095
+ // The file's version (when it was stored). One older than the rows were
2096
+ // last made from is skipped.
2097
+ version?: string | null;
2098
+ }
2099
+
2100
+ // Whether `version` is older than what the table's rows were last made from.
2101
+ const staleVersion = (table: DataTable, version: string | null | undefined) =>
2102
+ typeof version === 'string' && !!table.sync?.version && version < table.sync.version;
2103
+
2104
+ // The same JSON with its keys in order, so a sheet that writes it another
2105
+ // way round isn't a change.
2106
+ const canonicalJson = (value: unknown): unknown =>
2107
+ Array.isArray(value)
2108
+ ? value.map(canonicalJson)
2109
+ : isPlainObject(value)
2110
+ ? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonicalJson(value[k])]))
2111
+ : value;
2112
+ const sameStored = (column: DataColumn, a: SqlValue, b: SqlValue) => {
2113
+ if (a === b) return true;
2114
+ if (column.type !== 'json' || typeof a !== 'string' || typeof b !== 'string') return false;
2115
+ try {
2116
+ return JSON.stringify(canonicalJson(JSON.parse(a))) === JSON.stringify(canonicalJson(JSON.parse(b)));
2117
+ } catch {
2118
+ return false;
2119
+ }
2120
+ };
2121
+
2122
+ const nowStamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
2123
+
2124
+ // A refusal about one line of the sheet.
2125
+ const atLine = (line: number, error: DataError) =>
2126
+ new DataError(`Line ${line}: ${error.message}`, error.code, error.details);
2127
+
2128
+ const saveSyncState = (
2129
+ runner: SqlRunner,
2130
+ table: DataTable,
2131
+ state: DataTableSync,
2132
+ title?: string | null
2133
+ ) => {
2134
+ const source =
2135
+ table.source && typeof title === 'string' && title.trim()
2136
+ ? { ...table.source, title: title.trim().slice(0, 200) }
2137
+ : table.source;
2138
+ runner.all('UPDATE _ganju_tables SET sync_state = ?, source = ? WHERE id = ?', [
2139
+ JSON.stringify(state),
2140
+ source ? JSON.stringify(source) : null,
2141
+ table.id
2142
+ ]);
2143
+ };
2144
+
2145
+ // Makes a synced table's rows what the sheet says, in one transaction: new
2146
+ // keys are inserted, rows whose values differ updated, rows whose key is
2147
+ // gone deleted. Unchanged rows aren't written. Anything that doesn't fit
2148
+ // refuses the whole sync, and the table keeps its rows.
2149
+ export const syncRows = (
2150
+ runner: SqlRunner,
2151
+ schema: DataSchema,
2152
+ caller: DataCaller,
2153
+ args: SheetSyncArgs
2154
+ ): DataTableSync => {
2155
+ assertWritable(runner, caller, true);
2156
+ const table = tableNamed(schema, args?.table);
2157
+ const source = table.source;
2158
+ if (!source) {
2159
+ throw new DataError(`${table.name} isn't synced from a sheet.`, 'conflict');
2160
+ }
2161
+ const keyColumn = columnNamed(table, source.key);
2162
+ if (!Array.isArray(args.columns) || !Array.isArray(args.rows)) {
2163
+ throw new DataError('A sync takes "columns" and "rows".');
2164
+ }
2165
+ const columns = [...new Set(args.columns)].map(name => columnNamed(table, name));
2166
+ if (!columns.includes(keyColumn)) {
2167
+ throw new DataError(
2168
+ `The sheet has no column for ${table.name}'s key, ${keyColumn.name}.`
2169
+ );
2170
+ }
2171
+ const unfilled = table.columns.filter(
2172
+ c => c.required && (c.default === undefined || c.default === null) && !columns.includes(c)
2173
+ );
2174
+ if (unfilled.length) {
2175
+ throw new DataError(
2176
+ `The sheet has no column for ${unfilled.map(c => `${table.name}.${c.name}`).join(', ')}, which ${unfilled.length === 1 ? 'is' : 'are'} required.`
2177
+ );
2178
+ }
2179
+ if (args.rows.length > DATA_SHEET_MAX_ROWS) {
2180
+ throw new DataError(
2181
+ `The sheet has ${args.rows.length.toLocaleString('en-US')} rows; a synced table takes up to ${DATA_SHEET_MAX_ROWS.toLocaleString('en-US')}.`,
2182
+ 'too_large'
2183
+ );
2184
+ }
2185
+
2186
+ // Every line as it will be stored, checked before anything is written.
2187
+ const keyOf = (value: SqlValue) => JSON.stringify(value);
2188
+ const wanted = new Map<string, { line: number; values: Map<string, SqlValue> }>();
2189
+ args.rows.forEach((row, i) => {
2190
+ const line = args.lines?.[i] ?? i + 2;
2191
+ if (!isPlainObject(row)) throw atLine(line, new DataError('A row is an object of column: value.'));
2192
+ const values = new Map<string, SqlValue>();
2193
+ for (const column of columns) {
2194
+ const raw = row[column.name];
2195
+ let stored: SqlValue;
2196
+ try {
2197
+ stored = raw === undefined || raw === null ? null : toStored(table, column, raw, caller.timeZone);
2198
+ } catch (error) {
2199
+ if (error instanceof DataError) throw atLine(line, error);
2200
+ throw error;
2201
+ }
2202
+ if (stored === null && column.required) {
2203
+ throw atLine(line, new DataError(`${table.name}.${column.name} is required.`));
2204
+ }
2205
+ if (column.min !== null && typeof stored === 'number' && stored < column.min) {
2206
+ throw atLine(line, new DataError(`${table.name}.${column.name} can't go below ${column.min}.`));
2207
+ }
2208
+ values.set(column.name, stored);
2209
+ }
2210
+ const key = values.get(keyColumn.name) ?? null;
2211
+ if (key === null) {
2212
+ throw atLine(line, new DataError(`The key, ${keyColumn.name}, is empty.`));
2213
+ }
2214
+ const seen = wanted.get(keyOf(key));
2215
+ if (seen) {
2216
+ throw atLine(
2217
+ line,
2218
+ new DataError(
2219
+ `${keyColumn.name} ${JSON.stringify(key)} is already on line ${seen.line}; each line of the sheet needs its own key.`,
2220
+ 'constraint',
2221
+ { key, lines: [seen.line, line] }
2222
+ )
2223
+ );
2224
+ }
2225
+ wanted.set(keyOf(key), { line, values });
2226
+ });
2227
+
2228
+ // Two syncs that overlap: the one that read the older file changes nothing.
2229
+ if (staleVersion(table, args.version)) {
2230
+ return { ...table.sync!, inserted: 0, updated: 0, deleted: 0, unchanged: 0, stale: true };
2231
+ }
2232
+
2233
+ return runner.transaction(() => {
2234
+ setContextActor(runner, caller.actor);
2235
+ runner.all('DELETE FROM _ganju_changes');
2236
+ const before = runner.usageSoFar?.().rowsWritten ?? 0;
2237
+ const names = columns.map(c => c.name);
2238
+ const existing = new Map<string, SqlRow>();
2239
+ const stale: number[] = [];
2240
+ let nextId = nextRowId(runner, table);
2241
+ for (const row of runner.all(
2242
+ `SELECT id, ${names.map(n => q(n)).join(', ')} FROM ${q(table.name)}`
2243
+ )) {
2244
+ const key = row[keyColumn.name] ?? null;
2245
+ if (key !== null && wanted.has(keyOf(key))) existing.set(keyOf(key), row);
2246
+ else stale.push(Number(row.id));
2247
+ }
2248
+ const counts = { inserted: 0, updated: 0, deleted: 0, unchanged: 0 };
2249
+
2250
+ // Rows the sheet no longer has go first, so a key that moved to a new
2251
+ // line isn't taken twice. A row another table still points at with
2252
+ // restrict stops the sync.
2253
+ for (const other of schema.tables) {
2254
+ for (const c of other.columns) {
2255
+ if (c.type !== 'relation' || c.table !== table.name) continue;
2256
+ if ((c.onDelete ?? 'restrict') !== 'restrict' || stale.length === 0) continue;
2257
+ let n = 0;
2258
+ for (let i = 0; i < stale.length; i += DATA_MAX_PARAMS) {
2259
+ const slice = stale.slice(i, i + DATA_MAX_PARAMS);
2260
+ n += Number(
2261
+ first(
2262
+ runner,
2263
+ `SELECT count(*) AS n FROM ${q(other.name)} WHERE ${q(c.name)} IN (${slice.map(() => '?').join(', ')})`,
2264
+ slice
2265
+ )?.n ?? 0
2266
+ );
2267
+ }
2268
+ if (n > 0) {
2269
+ throw new DataError(
2270
+ `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.`,
2271
+ 'constraint',
2272
+ { table: other.name, column: c.name, count: n }
2273
+ );
2274
+ }
2275
+ }
2276
+ }
2277
+ try {
2278
+ for (let i = 0; i < stale.length; i += DATA_MAX_PARAMS) {
2279
+ const slice = stale.slice(i, i + DATA_MAX_PARAMS);
2280
+ counts.deleted += runner.all(
2281
+ `DELETE FROM ${q(table.name)} WHERE id IN (${slice.map(() => '?').join(', ')}) RETURNING id`,
2282
+ slice
2283
+ ).length;
2284
+ }
2285
+ for (const [key, { line, values }] of wanted) {
2286
+ const current = existing.get(key);
2287
+ try {
2288
+ if (!current) {
2289
+ insertOne(runner, table, values, caller.actor, false, caller, nextId++);
2290
+ counts.inserted++;
2291
+ continue;
2292
+ }
2293
+ const changed = columns
2294
+ .filter(c => !sameStored(c, current[c.name] ?? null, values.get(c.name) ?? null))
2295
+ .map(c => c.name);
2296
+ if (changed.length === 0) {
2297
+ counts.unchanged++;
2298
+ continue;
2299
+ }
2300
+ runner.all(
2301
+ `UPDATE ${q(table.name)} SET ${changed.map(n => `${q(n)} = ?`).join(', ')}, version = version + 1, updated_at = ${DATA_NOW_SQL}, updated_by = ? WHERE id = ?`,
2302
+ [...changed.map(n => values.get(n) ?? null), caller.actor, Number(current.id)]
2303
+ );
2304
+ counts.updated++;
2305
+ } catch (error) {
2306
+ throw atLine(line, translateSqliteError(schema, table, error));
2307
+ }
2308
+ }
2309
+ } catch (error) {
2310
+ throw translateSqliteError(schema, table, error);
2311
+ }
2312
+ runner.noteWrite?.({
2313
+ table: table.name,
2314
+ rowsWritten: (runner.usageSoFar?.().rowsWritten ?? 0) - before
2315
+ });
2316
+ collectTriggerEvents(runner, schema, caller);
2317
+ const state: DataTableSync = {
2318
+ at: nowStamp(),
2319
+ status: 'ok',
2320
+ ...counts,
2321
+ error: null,
2322
+ version: typeof args.version === 'string' ? args.version : (table.sync?.version ?? null)
2323
+ };
2324
+ saveSyncState(runner, table, state, args.title);
2325
+ return state;
2326
+ });
2327
+ };
2328
+
2329
+ // Keeps why a sync was refused on the table, where the owner sees it. The
2330
+ // rows are as they were.
2331
+ export const recordSyncFailure = (
2332
+ runner: SqlRunner,
2333
+ schema: DataSchema,
2334
+ args: { table: string; error: string; title?: string | null; version?: string | null }
2335
+ ): DataTableSync | null => {
2336
+ const table = schema.tables.find(t => t.name === args?.table);
2337
+ if (!table?.source) return null;
2338
+ // An older file's problem says nothing about the rows now.
2339
+ if (staleVersion(table, args.version)) return table.sync ?? null;
2340
+ const state: DataTableSync = {
2341
+ at: nowStamp(),
2342
+ status: 'failed',
2343
+ inserted: 0,
2344
+ updated: 0,
2345
+ deleted: 0,
2346
+ unchanged: 0,
2347
+ error: String(args.error ?? '').slice(0, 1000) || 'The sync failed.',
2348
+ // The rows are still from the last file applied.
2349
+ version: table.sync?.version ?? null
2350
+ };
2351
+ saveSyncState(runner, table, state, args.title);
2352
+ return state;
2353
+ };
2354
+
1861
2355
  // Delete preview
1862
2356
 
1863
2357
  // What deleting `ids` of a table, or all its rows, would do to the rows that
@@ -1979,6 +2473,129 @@ export const deleteImpact = (
1979
2473
  };
1980
2474
  };
1981
2475
 
2476
+ // File links
2477
+
2478
+ const fileColumns = (schema: DataSchema) =>
2479
+ schema.tables.flatMap(table =>
2480
+ table.columns
2481
+ .filter(column => column.type === 'file')
2482
+ .map(column => ({ table, column }))
2483
+ );
2484
+
2485
+ const fileUriList = (value: unknown): string[] => {
2486
+ if (
2487
+ !Array.isArray(value) ||
2488
+ value.length === 0 ||
2489
+ value.length > DATA_FILE_LINKS_MAX_URIS
2490
+ ) {
2491
+ throw new DataError(
2492
+ `"uris" lists 1 to ${DATA_FILE_LINKS_MAX_URIS} knowledge item uris.`
2493
+ );
2494
+ }
2495
+ return [...new Set(value.map(uri => fileUriOf(uri, 'uris')))];
2496
+ };
2497
+
2498
+ // The rows whose file columns point at `uris`, per column: knowledge asks
2499
+ // before it deletes an item. Each file column is indexed.
2500
+ export const fileLinks = (
2501
+ runner: SqlRunner,
2502
+ schema: DataSchema,
2503
+ caller: DataCaller,
2504
+ rawArgs: unknown
2505
+ ): DataFileLinks => {
2506
+ assertActor(caller);
2507
+ if (caller.restricted) {
2508
+ throw new DataError('File links are for the owner.', 'forbidden');
2509
+ }
2510
+ const uris = JSON.stringify(fileUriList(tidyArgs(rawArgs).uris));
2511
+ const links: DataFileLinks['links'] = [];
2512
+ for (const { table, column } of fileColumns(schema)) {
2513
+ const rows = Number(
2514
+ first(
2515
+ runner,
2516
+ `SELECT count(*) AS n FROM ${q(table.name)} WHERE ${q(column.name)} IN (SELECT value FROM json_each(?))`,
2517
+ [uris]
2518
+ )?.n ?? 0
2519
+ );
2520
+ if (rows === 0) continue;
2521
+ links.push({
2522
+ table: table.name,
2523
+ tableLabel: table.label,
2524
+ column: column.name,
2525
+ columnLabel: column.label,
2526
+ required: column.required,
2527
+ rows
2528
+ });
2529
+ }
2530
+ return { links };
2531
+ };
2532
+
2533
+ // Points the links at `from` to `to`, or clears them (`to: null`) once the
2534
+ // item is gone. A required column keeps its link, which then reads as
2535
+ // missing. Recorded in row history; triggers don't see it.
2536
+ export const replaceFileLinks = (
2537
+ runner: SqlRunner,
2538
+ schema: DataSchema,
2539
+ caller: DataCaller,
2540
+ rawArgs: unknown
2541
+ ): DataFileLinksReplaced => {
2542
+ assertActor(caller);
2543
+ if (caller.restricted) {
2544
+ throw new DataError('File links are for the owner.', 'forbidden');
2545
+ }
2546
+ const raw = tidyArgs(rawArgs).changes;
2547
+ if (
2548
+ !Array.isArray(raw) ||
2549
+ raw.length === 0 ||
2550
+ raw.length > DATA_FILE_LINKS_MAX_URIS
2551
+ ) {
2552
+ throw new DataError(
2553
+ `"changes" lists 1 to ${DATA_FILE_LINKS_MAX_URIS} { from, to } pairs.`
2554
+ );
2555
+ }
2556
+ const changes: DataFileLinkChange[] = raw.map(change => {
2557
+ if (!isPlainObject(change)) {
2558
+ throw new DataError('Each change is { from, to }; to is null to clear.');
2559
+ }
2560
+ return {
2561
+ from: fileUriOf(change.from, 'from'),
2562
+ to: change.to === null ? null : fileUriOf(change.to, 'to')
2563
+ };
2564
+ });
2565
+ const columns = fileColumns(schema);
2566
+ if (columns.length === 0) return { updated: 0, kept: [] };
2567
+ assertWritable(runner, caller, changes.some(change => change.to !== null));
2568
+ return runner.transaction(() => {
2569
+ setContextActor(runner, caller.actor);
2570
+ runner.all('DELETE FROM _ganju_changes');
2571
+ let updated = 0;
2572
+ const kept: DataFileLinksReplaced['kept'] = [];
2573
+ for (const { table, column } of columns) {
2574
+ for (const change of changes) {
2575
+ if (change.to === null && column.required) {
2576
+ const rows = Number(
2577
+ first(
2578
+ runner,
2579
+ `SELECT count(*) AS n FROM ${q(table.name)} WHERE ${q(column.name)} = ?`,
2580
+ [change.from]
2581
+ )?.n ?? 0
2582
+ );
2583
+ if (rows > 0) kept.push({ table: table.name, column: column.name, rows });
2584
+ continue;
2585
+ }
2586
+ updated += runner.all(
2587
+ `UPDATE ${q(table.name)} SET ${q(column.name)} = ?, version = version + 1, updated_at = ${DATA_NOW_SQL}, updated_by = ? WHERE ${q(column.name)} = ? RETURNING id`,
2588
+ [change.to, caller.actor, change.from]
2589
+ ).length;
2590
+ }
2591
+ }
2592
+ // Tidying links after a knowledge change isn't the owner's edit: it
2593
+ // stays in row history but fires no trigger.
2594
+ runner.all('DELETE FROM _ganju_changes');
2595
+ return { updated, kept };
2596
+ });
2597
+ };
2598
+
1982
2599
  // Row history
1983
2600
 
1984
2601
  export const rowHistory = (
@@ -2190,6 +2807,7 @@ export const createTable = (
2190
2807
  const access = normaliseAccess(input.access, 'read');
2191
2808
  const customerScoped = flag(input.customerScoped, 'customerScoped');
2192
2809
  const history = input.history === undefined ? true : flag(input.history, 'history');
2810
+ const source = normaliseSource(input.source, name, columns, customerScoped);
2193
2811
 
2194
2812
  return runner.transaction(() => {
2195
2813
  const position = Number(
@@ -2209,7 +2827,7 @@ export const createTable = (
2209
2827
  );
2210
2828
  const tableId = Number(
2211
2829
  runner.all(
2212
- 'INSERT INTO _ganju_tables (id, name, label, description, access, customer_scoped, history, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
2830
+ 'INSERT INTO _ganju_tables (id, name, label, description, access, customer_scoped, history, position, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
2213
2831
  [
2214
2832
  nextId,
2215
2833
  name,
@@ -2218,7 +2836,8 @@ export const createTable = (
2218
2836
  access,
2219
2837
  customerScoped ? 1 : 0,
2220
2838
  history ? 1 : 0,
2221
- position
2839
+ position,
2840
+ source ? JSON.stringify(source) : null
2222
2841
  ]
2223
2842
  )[0].id
2224
2843
  );
@@ -2245,7 +2864,7 @@ export const updateTable = (
2245
2864
  assertDdlAllowed(caller);
2246
2865
  const table = tableNamed(schema, name);
2247
2866
  if (!isPlainObject(patch)) throw new DataError('The change is an object.');
2248
- const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history'];
2867
+ const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history', 'source'];
2249
2868
  for (const key of Object.keys(patch)) {
2250
2869
  if (!allowed.includes(key)) {
2251
2870
  throw new DataError(`"${key}" can't be changed here; tables take ${allowed.join(', ')}.`);
@@ -2280,6 +2899,8 @@ export const updateTable = (
2280
2899
  params.push(normaliseAccess(patch.access, table.access));
2281
2900
  }
2282
2901
  if ('customerScoped' in patch) {
2902
+ const keepsSource = 'source' in patch ? patch.source !== null && patch.source !== undefined : !!table.source;
2903
+ if (patch.customerScoped === true && keepsSource) throw syncedScopedError(table.name);
2283
2904
  sets.push('customer_scoped = ?');
2284
2905
  params.push(flag(patch.customerScoped, 'customerScoped') ? 1 : 0);
2285
2906
  }
@@ -2287,6 +2908,14 @@ export const updateTable = (
2287
2908
  sets.push('history = ?');
2288
2909
  params.push(flag(patch.history, 'history') ? 1 : 0);
2289
2910
  }
2911
+ if ('source' in patch) {
2912
+ const scoped = 'customerScoped' in patch ? patch.customerScoped === true : table.customerScoped;
2913
+ const source = normaliseSource(patch.source, current, table.columns, scoped);
2914
+ sets.push('source = ?');
2915
+ params.push(source ? JSON.stringify(source) : null);
2916
+ // A new file, sheet or key starts over: the last sync was of another.
2917
+ if (!sameSource(table.source, source)) sets.push('sync_state = NULL');
2918
+ }
2290
2919
  if (sets.length) {
2291
2920
  runner.all(`UPDATE _ganju_tables SET ${sets.join(', ')} WHERE id = ?`, [...params, table.id]);
2292
2921
  }
@@ -2746,8 +3375,32 @@ export const updateColumn = (
2746
3375
  if (draft.name !== column.name && table.columns.some(c => c.name === draft.name)) {
2747
3376
  throw new DataError(`${table.name} already has a column "${draft.name}".`, 'conflict');
2748
3377
  }
3378
+ // A time trigger counts from a date or datetime; anything else has no time.
3379
+ if (draft.type !== 'date' && draft.type !== 'datetime') {
3380
+ const timed = timeTriggersCountingFrom(runner, table.id, column.name);
3381
+ if (timed.length) {
3382
+ throw new DataError(
3383
+ `${table.name}.${column.name} stays a date or datetime while time triggers count from it (${timed.join(', ')}); change or delete them first.`,
3384
+ 'constraint',
3385
+ { triggers: timed }
3386
+ );
3387
+ }
3388
+ }
3389
+ const isSyncKey = table.source?.key === column.name;
3390
+ if (isSyncKey && !draft.unique) {
3391
+ throw new DataError(
3392
+ `${table.name}.${column.name} is the key the sheet sync matches rows by, so it stays unique.`,
3393
+ 'constraint'
3394
+ );
3395
+ }
2749
3396
 
2750
3397
  if (columnChangeNeedsRebuild(schema, table.name, column.name, patch)) {
3398
+ if (isSyncKey && draft.name !== column.name) {
3399
+ throw new DataError(
3400
+ `${table.name}.${column.name} is the sheet sync's key: rename it on its own, then make the other changes.`,
3401
+ 'constraint'
3402
+ );
3403
+ }
2751
3404
  // A rename renames the column in its triggers too; a copy can't, so the
2752
3405
  // two go one after the other.
2753
3406
  const triggers = draft.name !== column.name ? triggersUsingColumn(runner, table.id, column.name) : [];
@@ -2776,6 +3429,12 @@ export const updateColumn = (
2776
3429
  `ALTER TABLE ${q(table.name)} RENAME COLUMN ${q(column.name)} TO ${q(draft.name)}`
2777
3430
  );
2778
3431
  renameColumnInTriggers(runner, table.id, column.name, draft.name);
3432
+ if (isSyncKey && table.source) {
3433
+ runner.all('UPDATE _ganju_tables SET source = ? WHERE id = ?', [
3434
+ JSON.stringify({ ...table.source, key: draft.name }),
3435
+ table.id
3436
+ ]);
3437
+ }
2779
3438
  }
2780
3439
  updateColumnMeta(runner, schema, table, column.id, draft);
2781
3440
  touchTable(runner, table.id);
@@ -2799,6 +3458,12 @@ export const dropColumn = (
2799
3458
  throw new DataError(`${table.name} needs at least one column; drop the table instead.`);
2800
3459
  }
2801
3460
  assertNoTriggerUses(runner, table, column.name);
3461
+ if (table.source?.key === column.name) {
3462
+ throw new DataError(
3463
+ `${table.name}.${column.name} is the key the sheet sync matches rows by; stop syncing the table, or pick another key, first.`,
3464
+ 'constraint'
3465
+ );
3466
+ }
2802
3467
  if (dropColumnNeedsRebuild(schema, table.name, column.name)) {
2803
3468
  return rebuildTable(
2804
3469
  runner,