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