@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
@@ -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.replaceFileLinks = exports.fileLinks = 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 => {
@@ -389,6 +422,47 @@ const normaliseAccess = (value, fallback) => {
389
422
  }
390
423
  return value;
391
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);
392
466
  // Keeping indexes, triggers and the search table in line with the metadata
393
467
  const tableObjects = (runner, type, table) => runner
394
468
  .all('SELECT name, sql FROM sqlite_master WHERE type = ? AND tbl_name = ?', [type, table])
@@ -1034,6 +1108,15 @@ const assertWritable = (runner, caller, addsData) => {
1034
1108
  }
1035
1109
  };
1036
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;
1037
1120
  // Every write names its actor here first, so row history (kept by triggers)
1038
1121
  // records who made each change, cascades included.
1039
1122
  const setContextActor = (runner, actor) => {
@@ -1193,6 +1276,7 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1193
1276
  op = { ...rawOp, id };
1194
1277
  }
1195
1278
  const table = (0, dataCompiler_1.tableFor)(schema, caller, op.table, kind === 'insert' ? 'add' : 'full');
1279
+ (0, exports.assertNotSynced)(table);
1196
1280
  const actor = caller.actor;
1197
1281
  const scoped = (0, dataCompiler_1.isScoped)(caller, table);
1198
1282
  if (kind === 'insert') {
@@ -1220,8 +1304,9 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1220
1304
  // One column list for the whole batch, so rows that leave a column out
1221
1305
  // get NULL there rather than its default only when another row sets it.
1222
1306
  const names = [...new Set(prepared.flatMap(values => [...values.keys()]))];
1223
- const columns = [...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1307
+ const columns = ['id', ...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1224
1308
  const ids = [];
1309
+ let nextId = nextRowId(runner, table);
1225
1310
  try {
1226
1311
  for (const chunk of (0, dataCompiler_1.chunkRows)(prepared, columns.length)) {
1227
1312
  const usesDefaults = chunk.some(values => names.some(n => !values.has(n)));
@@ -1229,11 +1314,12 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1229
1314
  // Rows with different columns go one by one, so each missing
1230
1315
  // column takes its default.
1231
1316
  for (const values of chunk)
1232
- ids.push(insertOne(runner, table, values, actor, scoped, caller));
1317
+ ids.push(insertOne(runner, table, values, actor, scoped, caller, nextId++));
1233
1318
  continue;
1234
1319
  }
1235
1320
  const params = [];
1236
1321
  for (const values of chunk) {
1322
+ params.push(nextId++);
1237
1323
  for (const name of names)
1238
1324
  params.push(values.get(name) ?? null);
1239
1325
  params.push(actor, actor);
@@ -1336,6 +1422,26 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1336
1422
  .all(`SELECT t.id FROM ${(0, dataIdentifiers_1.quoteIdentifier)(table.name)} t WHERE ${where}`, ctx.params)
1337
1423
  .map(r => Number(r.id))
1338
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
+ }
1339
1445
  // A readable "3 rows in order_lines still point at this order" before
1340
1446
  // SQLite's bare foreign key error. A row of the same table counts even
1341
1447
  // when this delete removes it too: SQLite checks a restrict as each row
@@ -1374,9 +1480,16 @@ const runOperation = (runner, schema, caller, rawOp, index, results) => {
1374
1480
  }
1375
1481
  return { op: kind, table: table.name, changed };
1376
1482
  };
1377
- const insertOne = (runner, table, values, actor, scoped, caller) => {
1378
- const names = [...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
1379
- 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)] : [])];
1380
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);
1381
1494
  };
1382
1495
  // A list of inserts, updates and deletes across tables, applied together or
@@ -1417,6 +1530,214 @@ const write = (runner, schema, caller, rawArgs) => {
1417
1530
  });
1418
1531
  };
1419
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;
1420
1741
  // Delete preview
1421
1742
  // What deleting `ids` of a table, or all its rows, would do to the rows that
1422
1743
  // point at them, counted the way SQLite applies it: cascades are followed to
@@ -1739,6 +2060,7 @@ const createTable = (runner, schema, caller, input) => {
1739
2060
  const access = normaliseAccess(input.access, 'read');
1740
2061
  const customerScoped = flag(input.customerScoped, 'customerScoped');
1741
2062
  const history = input.history === undefined ? true : flag(input.history, 'history');
2063
+ const source = normaliseSource(input.source, name, columns, customerScoped);
1742
2064
  return runner.transaction(() => {
1743
2065
  const position = Number(first(runner, 'SELECT coalesce(max(position), -1) + 1 AS p FROM _ganju_tables')?.p ?? 0);
1744
2066
  // Never an id a dropped table had: its row history stays in the log
@@ -1748,7 +2070,7 @@ const createTable = (runner, schema, caller, input) => {
1748
2070
  coalesce((SELECT max(id) FROM _ganju_tables), 0),
1749
2071
  coalesce((SELECT max(table_id) FROM _ganju_row_log), 0)
1750
2072
  ) + 1 AS id`)?.id ?? 1);
1751
- 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', [
1752
2074
  nextId,
1753
2075
  name,
1754
2076
  cleanText(input.label, dataTypes_1.DATA_MAX_LABEL, `${name} label`),
@@ -1756,7 +2078,8 @@ const createTable = (runner, schema, caller, input) => {
1756
2078
  access,
1757
2079
  customerScoped ? 1 : 0,
1758
2080
  history ? 1 : 0,
1759
- position
2081
+ position,
2082
+ source ? JSON.stringify(source) : null
1760
2083
  ])[0].id);
1761
2084
  const stored = columns.map((c, i) => ({
1762
2085
  ...c,
@@ -1777,7 +2100,7 @@ const updateTable = (runner, schema, caller, name, patch) => {
1777
2100
  const table = tableNamed(schema, name);
1778
2101
  if (!isPlainObject(patch))
1779
2102
  throw new dataTypes_1.DataError('The change is an object.');
1780
- const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history'];
2103
+ const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history', 'source'];
1781
2104
  for (const key of Object.keys(patch)) {
1782
2105
  if (!allowed.includes(key)) {
1783
2106
  throw new dataTypes_1.DataError(`"${key}" can't be changed here; tables take ${allowed.join(', ')}.`);
@@ -1813,6 +2136,9 @@ const updateTable = (runner, schema, caller, name, patch) => {
1813
2136
  params.push(normaliseAccess(patch.access, table.access));
1814
2137
  }
1815
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);
1816
2142
  sets.push('customer_scoped = ?');
1817
2143
  params.push(flag(patch.customerScoped, 'customerScoped') ? 1 : 0);
1818
2144
  }
@@ -1820,6 +2146,15 @@ const updateTable = (runner, schema, caller, name, patch) => {
1820
2146
  sets.push('history = ?');
1821
2147
  params.push(flag(patch.history, 'history') ? 1 : 0);
1822
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
+ }
1823
2158
  if (sets.length) {
1824
2159
  runner.all(`UPDATE _ganju_tables SET ${sets.join(', ')} WHERE id = ?`, [...params, table.id]);
1825
2160
  }
@@ -2177,7 +2512,21 @@ const updateColumn = (runner, schema, caller, tableName, columnName, patch) => {
2177
2512
  if (draft.name !== column.name && table.columns.some(c => c.name === draft.name)) {
2178
2513
  throw new dataTypes_1.DataError(`${table.name} already has a column "${draft.name}".`, 'conflict');
2179
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
+ }
2180
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
+ }
2181
2530
  // A rename renames the column in its triggers too; a copy can't, so the
2182
2531
  // two go one after the other.
2183
2532
  const triggers = draft.name !== column.name ? (0, dataTriggers_1.triggersUsingColumn)(runner, table.id, column.name) : [];
@@ -2192,6 +2541,12 @@ const updateColumn = (runner, schema, caller, tableName, columnName, patch) => {
2192
2541
  runner.all(`DROP TABLE IF EXISTS ${(0, dataIdentifiers_1.quoteIdentifier)((0, dataDdl_1.ftsTableName)(table))}`);
2193
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)}`);
2194
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
+ }
2195
2550
  }
2196
2551
  updateColumnMeta(runner, schema, table, column.id, draft);
2197
2552
  touchTable(runner, table.id);
@@ -2209,6 +2564,9 @@ const dropColumn = (runner, schema, caller, tableName, columnName) => {
2209
2564
  throw new dataTypes_1.DataError(`${table.name} needs at least one column; drop the table instead.`);
2210
2565
  }
2211
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
+ }
2212
2570
  if ((0, exports.dropColumnNeedsRebuild)(schema, table.name, column.name)) {
2213
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);
2214
2572
  }
@@ -66,6 +66,7 @@ export interface ImportedRow {
66
66
  values: Record<string, unknown>;
67
67
  errors: string[];
68
68
  warnings: NumberGuess[];
69
+ repeated: string[];
69
70
  }
70
71
  export declare const importRows: (table: Pick<DataTable, "name" | "columns">, mapping: (string | null)[], rows: ImportRow[], options?: {
71
72
  relations?: Record<string, RelationLookup>;
@@ -76,4 +77,6 @@ export declare const importRows: (table: Pick<DataTable, "name" | "columns">, ma
76
77
  export declare const csvLine: (cells: ImportCell[]) => string;
77
78
  export { detectSeparator, parseCsv } from './dataCsv';
78
79
  export type { ParsedCsv } from './dataCsv';
80
+ export { DATA_CSV_MIME, DATA_SPREADSHEET_MIME, cellFromSheet, isTabularMimeType, readCsvText, readSheet, readTabularFile } from './dataSheet';
81
+ export type { SheetJs, TabularFile } from './dataSheet';
79
82
  //# sourceMappingURL=dataImport.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dataImport.d.ts","sourceRoot":"","sources":["../src/dataImport.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"dataImport.d.ts","sourceRoot":"","sources":["../src/dataImport.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.parseCsv = exports.detectSeparator = exports.csvLine = exports.importRows = exports.importCell = exports.relationKey = exports.matchHeaders = exports.guessColumns = exports.guessColumnType = exports.columnNameFromHeader = exports.normaliseHeader = exports.parseNumber = exports.detectDecimalMark = exports.DATA_IMPORT_BATCH = exports.DATA_IMPORT_SAMPLE_ROWS = exports.toTableInput = exports.isValidIdentifier = exports.identifierIssue = void 0;
3
+ exports.readTabularFile = exports.readSheet = exports.readCsvText = exports.isTabularMimeType = exports.cellFromSheet = exports.DATA_SPREADSHEET_MIME = exports.DATA_CSV_MIME = exports.parseCsv = exports.detectSeparator = exports.csvLine = exports.importRows = exports.importCell = exports.relationKey = exports.matchHeaders = exports.guessColumns = exports.guessColumnType = exports.columnNameFromHeader = exports.normaliseHeader = exports.parseNumber = exports.detectDecimalMark = exports.DATA_IMPORT_BATCH = exports.DATA_IMPORT_SAMPLE_ROWS = exports.toTableInput = exports.isValidIdentifier = exports.identifierIssue = void 0;
4
4
  // Reading a spreadsheet into a table: the column types guessed for a new
5
5
  // table, the headers matched to an existing one's columns, and each cell
6
6
  // checked the way the database will check it. The dashboard's import and
@@ -405,6 +405,7 @@ const importRows = (table, mapping, rows, options = {}) => {
405
405
  const values = {};
406
406
  const errors = missing.map(c => `${c.name} is required.`);
407
407
  const warnings = [];
408
+ const repeated = [];
408
409
  mapping.forEach((name, i) => {
409
410
  if (name === null)
410
411
  return;
@@ -432,11 +433,12 @@ const importRows = (table, mapping, rows, options = {}) => {
432
433
  if (first === undefined)
433
434
  lines.set(key, line);
434
435
  else {
436
+ repeated.push(column.name);
435
437
  errors.push(`${column.name}: "${text(row[i])}" is already on line ${first}, and this column takes each value once.`);
436
438
  }
437
439
  }
438
440
  });
439
- return { index, line, values, errors, warnings };
441
+ return { index, line, values, errors, warnings, repeated };
440
442
  });
441
443
  };
442
444
  exports.importRows = importRows;
@@ -451,3 +453,11 @@ exports.csvLine = csvLine;
451
453
  var dataCsv_1 = require("./dataCsv");
452
454
  Object.defineProperty(exports, "detectSeparator", { enumerable: true, get: function () { return dataCsv_1.detectSeparator; } });
453
455
  Object.defineProperty(exports, "parseCsv", { enumerable: true, get: function () { return dataCsv_1.parseCsv; } });
456
+ var dataSheet_1 = require("./dataSheet");
457
+ Object.defineProperty(exports, "DATA_CSV_MIME", { enumerable: true, get: function () { return dataSheet_1.DATA_CSV_MIME; } });
458
+ Object.defineProperty(exports, "DATA_SPREADSHEET_MIME", { enumerable: true, get: function () { return dataSheet_1.DATA_SPREADSHEET_MIME; } });
459
+ Object.defineProperty(exports, "cellFromSheet", { enumerable: true, get: function () { return dataSheet_1.cellFromSheet; } });
460
+ Object.defineProperty(exports, "isTabularMimeType", { enumerable: true, get: function () { return dataSheet_1.isTabularMimeType; } });
461
+ Object.defineProperty(exports, "readCsvText", { enumerable: true, get: function () { return dataSheet_1.readCsvText; } });
462
+ Object.defineProperty(exports, "readSheet", { enumerable: true, get: function () { return dataSheet_1.readSheet; } });
463
+ Object.defineProperty(exports, "readTabularFile", { enumerable: true, get: function () { return dataSheet_1.readTabularFile; } });
package/dist/dataRpc.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { DataCaller, DataDeleteImpact, DataFileLinkChange, DataFileLinks, DataFileLinksReplaced, DataResult, DataSchema, DataTable, DataTableCopy } from './dataTypes';
1
+ import type { DataCaller, DataDeleteImpact, DataFileLinkChange, DataFileLinks, DataFileLinksReplaced, DataResult, DataSchema, DataTable, DataTableCopy, DataTableSync } from './dataTypes';
2
+ import type { SheetSyncArgs } from './dataEngine';
2
3
  import type { DataQuery } from './dataQueries';
3
4
  import type { DataTrigger, DataTriggerWithSecret, SavedTrigger } from './dataTriggers';
4
5
  export type ExportFormat = 'jsonl' | 'csv';
@@ -128,6 +129,13 @@ export interface ProjectDatabaseRpc {
128
129
  replaceFileLinks(caller: DataCaller, args: {
129
130
  changes: DataFileLinkChange[];
130
131
  }): R<DataFileLinksReplaced>;
132
+ syncRows(caller: DataCaller, args: SheetSyncArgs): R<DataTableSync>;
133
+ recordSyncFailure(caller: DataCaller, args: {
134
+ table: string;
135
+ error: string;
136
+ title?: string | null;
137
+ version?: string | null;
138
+ }): R<DataTableSync | null>;
131
139
  history(caller: DataCaller, args: {
132
140
  table: string;
133
141
  id: number;