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