@ganju/utils 0.0.21 → 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 (51) hide show
  1. package/dist/data.d.ts +4 -4
  2. package/dist/data.d.ts.map +1 -1
  3. package/dist/data.js +16 -2
  4. package/dist/dataApi.d.ts +32 -2
  5. package/dist/dataApi.d.ts.map +1 -1
  6. package/dist/dataApi.js +11 -1
  7. package/dist/dataCompiler.d.ts +1 -0
  8. package/dist/dataCompiler.d.ts.map +1 -1
  9. package/dist/dataCompiler.js +3 -2
  10. package/dist/dataEngine.d.ts +17 -1
  11. package/dist/dataEngine.d.ts.map +1 -1
  12. package/dist/dataEngine.js +368 -10
  13. package/dist/dataImport.d.ts +3 -0
  14. package/dist/dataImport.d.ts.map +1 -1
  15. package/dist/dataImport.js +12 -2
  16. package/dist/dataRpc.d.ts +9 -1
  17. package/dist/dataRpc.d.ts.map +1 -1
  18. package/dist/dataSheet.d.ts +34 -0
  19. package/dist/dataSheet.d.ts.map +1 -0
  20. package/dist/dataSheet.js +100 -0
  21. package/dist/dataTriggers.d.ts +43 -4
  22. package/dist/dataTriggers.d.ts.map +1 -1
  23. package/dist/dataTriggers.js +401 -19
  24. package/dist/dataTypes.d.ts +23 -0
  25. package/dist/dataTypes.d.ts.map +1 -1
  26. package/dist/dataTypes.js +15 -1
  27. package/dist/index.d.ts +35 -4
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -0
  30. package/dist/localizeDataError.d.ts.map +1 -1
  31. package/dist/localizeDataError.js +45 -1
  32. package/dist/schema.d.ts +33 -4
  33. package/dist/schema.d.ts.map +1 -1
  34. package/dist/schema.js +26 -3
  35. package/dist/untrustedContent.d.ts +7 -0
  36. package/dist/untrustedContent.d.ts.map +1 -1
  37. package/dist/untrustedContent.js +13 -1
  38. package/package.json +1 -1
  39. package/src/data.ts +19 -3
  40. package/src/dataApi.ts +14 -2
  41. package/src/dataCompiler.ts +4 -1
  42. package/src/dataEngine.ts +485 -10
  43. package/src/dataImport.ts +16 -1
  44. package/src/dataRpc.ts +10 -1
  45. package/src/dataSheet.ts +147 -0
  46. package/src/dataTriggers.ts +528 -19
  47. package/src/dataTypes.ts +46 -0
  48. package/src/index.ts +4 -0
  49. package/src/localizeDataError.ts +82 -1
  50. package/src/schema.ts +27 -3
  51. package/src/untrustedContent.ts +15 -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,
@@ -77,6 +82,8 @@ import {
77
82
  DATA_IMPACT_MAX_ROWS,
78
83
  DATA_FILE_LINKS_MAX_URIS,
79
84
  DATA_REBUILD_MAX_ROWS,
85
+ DATA_FILE_URI_MAX,
86
+ DATA_SHEET_MAX_ROWS,
80
87
  DataError
81
88
  } from './dataTypes';
82
89
  import type {
@@ -96,6 +103,8 @@ import type {
96
103
  DataTable,
97
104
  DataTableCopy,
98
105
  DataTableInput,
106
+ DataTableSource,
107
+ DataTableSync,
99
108
  DataUsage,
100
109
  DataWriteShape
101
110
  } from './dataTypes';
@@ -240,6 +249,26 @@ export const INTERNAL_MIGRATIONS: readonly (readonly string[])[] = [
240
249
  message TEXT NOT NULL,
241
250
  created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL})
242
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'
243
272
  ]
244
273
  ];
245
274
 
@@ -272,6 +301,18 @@ export const setMeta = (
272
301
  );
273
302
  };
274
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
+
275
316
  // Brings our own tables up to date. Returns the version now in force.
276
317
  export const migrateInternal = (runner: SqlRunner): number =>
277
318
  runner.transaction(() => {
@@ -279,7 +320,7 @@ export const migrateInternal = (runner: SqlRunner): number =>
279
320
  ? Number(getMeta(runner, 'schema_version') ?? 0)
280
321
  : 0;
281
322
  for (let v = current; v < INTERNAL_MIGRATIONS.length; v++) {
282
- for (const statement of INTERNAL_MIGRATIONS[v]) runner.all(statement);
323
+ for (const statement of INTERNAL_MIGRATIONS[v]) runMigration(runner, statement);
283
324
  }
284
325
  if (current < INTERNAL_MIGRATIONS.length) {
285
326
  setMeta(runner, 'schema_version', String(INTERNAL_MIGRATIONS.length));
@@ -316,6 +357,8 @@ export const loadSchema = (runner: SqlRunner): DataSchema => {
316
357
  customerScoped: row.customer_scoped === 1,
317
358
  history: row.history === 1,
318
359
  position: Number(row.position),
360
+ source: parseJson<DataTableSource>(row.source ?? null),
361
+ sync: parseJson<DataTableSync>(row.sync_state ?? null),
319
362
  columns: columns
320
363
  .filter(c => c.table_id === row.id)
321
364
  .map(c => {
@@ -559,6 +602,59 @@ const normaliseAccess = (value: unknown, fallback: DataAccess): DataAccess => {
559
602
  return value as DataAccess;
560
603
  };
561
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
+
562
658
  // Keeping indexes, triggers and the search table in line with the metadata
563
659
 
564
660
  const tableObjects = (runner: SqlRunner, type: string, table: string) =>
@@ -1403,6 +1499,18 @@ export const assertWritable = (
1403
1499
  }
1404
1500
  };
1405
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
+
1406
1514
  // Every write names its actor here first, so row history (kept by triggers)
1407
1515
  // records who made each change, cascades included.
1408
1516
  const setContextActor = (runner: SqlRunner, actor: string) => {
@@ -1637,6 +1745,7 @@ const runOperation = (
1637
1745
  op = { ...rawOp, id };
1638
1746
  }
1639
1747
  const table = tableFor(schema, caller, op.table, kind === 'insert' ? 'add' : 'full');
1748
+ assertNotSynced(table);
1640
1749
  const actor = caller.actor;
1641
1750
  const scoped = isScoped(caller, table);
1642
1751
 
@@ -1664,19 +1773,21 @@ const runOperation = (
1664
1773
  // One column list for the whole batch, so rows that leave a column out
1665
1774
  // get NULL there rather than its default only when another row sets it.
1666
1775
  const names = [...new Set(prepared.flatMap(values => [...values.keys()]))];
1667
- const columns = [...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1776
+ const columns = ['id', ...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1668
1777
  const ids: number[] = [];
1778
+ let nextId = nextRowId(runner, table);
1669
1779
  try {
1670
1780
  for (const chunk of chunkRows(prepared, columns.length)) {
1671
1781
  const usesDefaults = chunk.some(values => names.some(n => !values.has(n)));
1672
1782
  if (usesDefaults && chunk.length > 1) {
1673
1783
  // Rows with different columns go one by one, so each missing
1674
1784
  // column takes its default.
1675
- 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++));
1676
1786
  continue;
1677
1787
  }
1678
1788
  const params: SqlValue[] = [];
1679
1789
  for (const values of chunk) {
1790
+ params.push(nextId++);
1680
1791
  for (const name of names) params.push(values.get(name) ?? null);
1681
1792
  params.push(actor, actor);
1682
1793
  if (scoped) params.push(participantOf(caller));
@@ -1793,6 +1904,33 @@ const runOperation = (
1793
1904
  .all(`SELECT t.id FROM ${q(table.name)} t WHERE ${where}`, ctx.params)
1794
1905
  .map(r => Number(r.id))
1795
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
+ }
1796
1934
  // A readable "3 rows in order_lines still point at this order" before
1797
1935
  // SQLite's bare foreign key error. A row of the same table counts even
1798
1936
  // when this delete removes it too: SQLite checks a restrict as each row
@@ -1855,16 +1993,32 @@ const runOperation = (
1855
1993
  return { op: kind, table: table.name, changed };
1856
1994
  };
1857
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
+
1858
2011
  const insertOne = (
1859
2012
  runner: SqlRunner,
1860
2013
  table: DataTable,
1861
2014
  values: Map<string, SqlValue>,
1862
2015
  actor: string,
1863
2016
  scoped: boolean,
1864
- caller: DataCaller
2017
+ caller: DataCaller,
2018
+ id: number
1865
2019
  ): number => {
1866
- const names = [...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1867
- 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)] : [])];
1868
2022
  return Number(
1869
2023
  runner.all(
1870
2024
  `INSERT INTO ${q(table.name)} (${names.map(q).join(', ')}) VALUES (${names.map(() => '?').join(', ')}) RETURNING id`,
@@ -1925,6 +2079,279 @@ export const write = (
1925
2079
  });
1926
2080
  };
1927
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
+
1928
2355
  // Delete preview
1929
2356
 
1930
2357
  // What deleting `ids` of a table, or all its rows, would do to the rows that
@@ -2380,6 +2807,7 @@ export const createTable = (
2380
2807
  const access = normaliseAccess(input.access, 'read');
2381
2808
  const customerScoped = flag(input.customerScoped, 'customerScoped');
2382
2809
  const history = input.history === undefined ? true : flag(input.history, 'history');
2810
+ const source = normaliseSource(input.source, name, columns, customerScoped);
2383
2811
 
2384
2812
  return runner.transaction(() => {
2385
2813
  const position = Number(
@@ -2399,7 +2827,7 @@ export const createTable = (
2399
2827
  );
2400
2828
  const tableId = Number(
2401
2829
  runner.all(
2402
- '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',
2403
2831
  [
2404
2832
  nextId,
2405
2833
  name,
@@ -2408,7 +2836,8 @@ export const createTable = (
2408
2836
  access,
2409
2837
  customerScoped ? 1 : 0,
2410
2838
  history ? 1 : 0,
2411
- position
2839
+ position,
2840
+ source ? JSON.stringify(source) : null
2412
2841
  ]
2413
2842
  )[0].id
2414
2843
  );
@@ -2435,7 +2864,7 @@ export const updateTable = (
2435
2864
  assertDdlAllowed(caller);
2436
2865
  const table = tableNamed(schema, name);
2437
2866
  if (!isPlainObject(patch)) throw new DataError('The change is an object.');
2438
- const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history'];
2867
+ const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history', 'source'];
2439
2868
  for (const key of Object.keys(patch)) {
2440
2869
  if (!allowed.includes(key)) {
2441
2870
  throw new DataError(`"${key}" can't be changed here; tables take ${allowed.join(', ')}.`);
@@ -2470,6 +2899,8 @@ export const updateTable = (
2470
2899
  params.push(normaliseAccess(patch.access, table.access));
2471
2900
  }
2472
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);
2473
2904
  sets.push('customer_scoped = ?');
2474
2905
  params.push(flag(patch.customerScoped, 'customerScoped') ? 1 : 0);
2475
2906
  }
@@ -2477,6 +2908,14 @@ export const updateTable = (
2477
2908
  sets.push('history = ?');
2478
2909
  params.push(flag(patch.history, 'history') ? 1 : 0);
2479
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
+ }
2480
2919
  if (sets.length) {
2481
2920
  runner.all(`UPDATE _ganju_tables SET ${sets.join(', ')} WHERE id = ?`, [...params, table.id]);
2482
2921
  }
@@ -2936,8 +3375,32 @@ export const updateColumn = (
2936
3375
  if (draft.name !== column.name && table.columns.some(c => c.name === draft.name)) {
2937
3376
  throw new DataError(`${table.name} already has a column "${draft.name}".`, 'conflict');
2938
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
+ }
2939
3396
 
2940
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
+ }
2941
3404
  // A rename renames the column in its triggers too; a copy can't, so the
2942
3405
  // two go one after the other.
2943
3406
  const triggers = draft.name !== column.name ? triggersUsingColumn(runner, table.id, column.name) : [];
@@ -2966,6 +3429,12 @@ export const updateColumn = (
2966
3429
  `ALTER TABLE ${q(table.name)} RENAME COLUMN ${q(column.name)} TO ${q(draft.name)}`
2967
3430
  );
2968
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
+ }
2969
3438
  }
2970
3439
  updateColumnMeta(runner, schema, table, column.id, draft);
2971
3440
  touchTable(runner, table.id);
@@ -2989,6 +3458,12 @@ export const dropColumn = (
2989
3458
  throw new DataError(`${table.name} needs at least one column; drop the table instead.`);
2990
3459
  }
2991
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
+ }
2992
3467
  if (dropColumnNeedsRebuild(schema, table.name, column.name)) {
2993
3468
  return rebuildTable(
2994
3469
  runner,