@ganju/utils 0.0.21 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/data.d.ts +4 -4
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +16 -2
- package/dist/dataApi.d.ts +32 -2
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +11 -1
- package/dist/dataCompiler.d.ts +1 -0
- package/dist/dataCompiler.d.ts.map +1 -1
- package/dist/dataCompiler.js +3 -2
- package/dist/dataEngine.d.ts +17 -1
- package/dist/dataEngine.d.ts.map +1 -1
- package/dist/dataEngine.js +368 -10
- package/dist/dataImport.d.ts +3 -0
- package/dist/dataImport.d.ts.map +1 -1
- package/dist/dataImport.js +12 -2
- package/dist/dataRpc.d.ts +9 -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/dataTriggers.d.ts +43 -4
- package/dist/dataTriggers.d.ts.map +1 -1
- package/dist/dataTriggers.js +401 -19
- package/dist/dataTypes.d.ts +23 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/dataTypes.js +15 -1
- package/dist/index.d.ts +35 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/localizeDataError.d.ts.map +1 -1
- package/dist/localizeDataError.js +45 -1
- package/dist/schema.d.ts +33 -4
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +26 -3
- package/dist/untrustedContent.d.ts +7 -0
- package/dist/untrustedContent.d.ts.map +1 -1
- package/dist/untrustedContent.js +13 -1
- package/package.json +1 -1
- package/src/data.ts +19 -3
- package/src/dataApi.ts +14 -2
- package/src/dataCompiler.ts +4 -1
- package/src/dataEngine.ts +485 -10
- package/src/dataImport.ts +16 -1
- package/src/dataRpc.ts +10 -1
- package/src/dataSheet.ts +147 -0
- package/src/dataTriggers.ts +528 -19
- package/src/dataTypes.ts +46 -0
- package/src/index.ts +4 -0
- package/src/localizeDataError.ts +82 -1
- package/src/schema.ts +27 -3
- package/src/untrustedContent.ts +15 -0
package/src/dataEngine.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// the tests and the tool-design eval. The runner executes; everything about
|
|
4
4
|
// which SQL is allowed and how results look is decided here.
|
|
5
5
|
import { identifierIssue, quoteIdentifier as q } from './dataIdentifiers';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
collectTriggerEvents,
|
|
8
|
+
renameColumnInTriggers,
|
|
9
|
+
timeTriggersCountingFrom,
|
|
10
|
+
triggersUsingColumn
|
|
11
|
+
} from './dataTriggers';
|
|
7
12
|
import {
|
|
8
13
|
DATA_NOW_SQL,
|
|
9
14
|
NUMERIC_TYPES,
|
|
@@ -77,6 +82,8 @@ import {
|
|
|
77
82
|
DATA_IMPACT_MAX_ROWS,
|
|
78
83
|
DATA_FILE_LINKS_MAX_URIS,
|
|
79
84
|
DATA_REBUILD_MAX_ROWS,
|
|
85
|
+
DATA_FILE_URI_MAX,
|
|
86
|
+
DATA_SHEET_MAX_ROWS,
|
|
80
87
|
DataError
|
|
81
88
|
} from './dataTypes';
|
|
82
89
|
import type {
|
|
@@ -96,6 +103,8 @@ import type {
|
|
|
96
103
|
DataTable,
|
|
97
104
|
DataTableCopy,
|
|
98
105
|
DataTableInput,
|
|
106
|
+
DataTableSource,
|
|
107
|
+
DataTableSync,
|
|
99
108
|
DataUsage,
|
|
100
109
|
DataWriteShape
|
|
101
110
|
} from './dataTypes';
|
|
@@ -240,6 +249,26 @@ export const INTERNAL_MIGRATIONS: readonly (readonly string[])[] = [
|
|
|
240
249
|
message TEXT NOT NULL,
|
|
241
250
|
created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL})
|
|
242
251
|
)`
|
|
252
|
+
],
|
|
253
|
+
// Time triggers: when each fires (`schedule`), how far its scans have got
|
|
254
|
+
// (`scan_state`), and the rows already fired for a due time, so none fires
|
|
255
|
+
// twice for the same one.
|
|
256
|
+
[
|
|
257
|
+
'ALTER TABLE _ganju_triggers ADD COLUMN schedule TEXT',
|
|
258
|
+
'ALTER TABLE _ganju_triggers ADD COLUMN scan_state TEXT',
|
|
259
|
+
`CREATE TABLE IF NOT EXISTS _ganju_trigger_fired (
|
|
260
|
+
trigger_id TEXT NOT NULL,
|
|
261
|
+
row_id INTEGER NOT NULL,
|
|
262
|
+
due_at TEXT NOT NULL,
|
|
263
|
+
PRIMARY KEY (trigger_id, row_id, due_at)
|
|
264
|
+
) WITHOUT ROWID`,
|
|
265
|
+
'CREATE INDEX IF NOT EXISTS _ganju_trigger_fired_due ON _ganju_trigger_fired(due_at)'
|
|
266
|
+
],
|
|
267
|
+
// Tables synced from a spreadsheet: where from (`source`), and how the
|
|
268
|
+
// last sync went (`sync_state`).
|
|
269
|
+
[
|
|
270
|
+
'ALTER TABLE _ganju_tables ADD COLUMN source TEXT',
|
|
271
|
+
'ALTER TABLE _ganju_tables ADD COLUMN sync_state TEXT'
|
|
243
272
|
]
|
|
244
273
|
];
|
|
245
274
|
|
|
@@ -272,6 +301,18 @@ export const setMeta = (
|
|
|
272
301
|
);
|
|
273
302
|
};
|
|
274
303
|
|
|
304
|
+
// SQLite has no ADD COLUMN IF NOT EXISTS: a column already there is
|
|
305
|
+
// skipped, so every version can run again like the CREATE ... IF NOT EXISTS
|
|
306
|
+
// ones.
|
|
307
|
+
const ADD_COLUMN = /^ALTER TABLE (\w+) ADD COLUMN (\w+) /;
|
|
308
|
+
const runMigration = (runner: SqlRunner, statement: string) => {
|
|
309
|
+
const add = ADD_COLUMN.exec(statement);
|
|
310
|
+
if (add && runner.all(`PRAGMA table_info(${q(add[1])})`).some(c => c.name === add[2])) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
runner.all(statement);
|
|
314
|
+
};
|
|
315
|
+
|
|
275
316
|
// Brings our own tables up to date. Returns the version now in force.
|
|
276
317
|
export const migrateInternal = (runner: SqlRunner): number =>
|
|
277
318
|
runner.transaction(() => {
|
|
@@ -279,7 +320,7 @@ export const migrateInternal = (runner: SqlRunner): number =>
|
|
|
279
320
|
? Number(getMeta(runner, 'schema_version') ?? 0)
|
|
280
321
|
: 0;
|
|
281
322
|
for (let v = current; v < INTERNAL_MIGRATIONS.length; v++) {
|
|
282
|
-
for (const statement of INTERNAL_MIGRATIONS[v]) runner
|
|
323
|
+
for (const statement of INTERNAL_MIGRATIONS[v]) runMigration(runner, statement);
|
|
283
324
|
}
|
|
284
325
|
if (current < INTERNAL_MIGRATIONS.length) {
|
|
285
326
|
setMeta(runner, 'schema_version', String(INTERNAL_MIGRATIONS.length));
|
|
@@ -316,6 +357,8 @@ export const loadSchema = (runner: SqlRunner): DataSchema => {
|
|
|
316
357
|
customerScoped: row.customer_scoped === 1,
|
|
317
358
|
history: row.history === 1,
|
|
318
359
|
position: Number(row.position),
|
|
360
|
+
source: parseJson<DataTableSource>(row.source ?? null),
|
|
361
|
+
sync: parseJson<DataTableSync>(row.sync_state ?? null),
|
|
319
362
|
columns: columns
|
|
320
363
|
.filter(c => c.table_id === row.id)
|
|
321
364
|
.map(c => {
|
|
@@ -559,6 +602,59 @@ const normaliseAccess = (value: unknown, fallback: DataAccess): DataAccess => {
|
|
|
559
602
|
return value as DataAccess;
|
|
560
603
|
};
|
|
561
604
|
|
|
605
|
+
// A table's sheet source, checked against the columns it will have. Null
|
|
606
|
+
// stops syncing.
|
|
607
|
+
const normaliseSource = (
|
|
608
|
+
value: unknown,
|
|
609
|
+
tableName: string,
|
|
610
|
+
columns: readonly Pick<DataColumn, 'name' | 'unique'>[],
|
|
611
|
+
customerScoped: boolean
|
|
612
|
+
): DataTableSource | null => {
|
|
613
|
+
if (value === undefined || value === null) return null;
|
|
614
|
+
// A sheet's rows have no customer, so a bot would never see them.
|
|
615
|
+
if (customerScoped) throw syncedScopedError(tableName);
|
|
616
|
+
if (!isPlainObject(value)) {
|
|
617
|
+
throw new DataError('"source" is { "uri", "sheet"?, "key" }, or null to stop syncing.');
|
|
618
|
+
}
|
|
619
|
+
for (const key of Object.keys(value)) {
|
|
620
|
+
if (!['uri', 'sheet', 'key', 'title'].includes(key)) {
|
|
621
|
+
throw new DataError(`"source" takes uri, sheet and key, not "${key}".`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const { uri, sheet, key, title } = value;
|
|
625
|
+
if (typeof uri !== 'string' || !/^[a-z][\w+.-]*:\/\/\S+$/i.test(uri) || uri.length > DATA_FILE_URI_MAX) {
|
|
626
|
+
throw new DataError('"source.uri" is the uri of a spreadsheet or CSV in the project\'s knowledge.');
|
|
627
|
+
}
|
|
628
|
+
if (sheet !== undefined && sheet !== null && (typeof sheet !== 'string' || !sheet.trim() || sheet.length > 100)) {
|
|
629
|
+
throw new DataError('"source.sheet" is the name of a sheet in the file, up to 100 characters.');
|
|
630
|
+
}
|
|
631
|
+
const column = columns.find(c => c.name === key);
|
|
632
|
+
if (typeof key !== 'string' || !column) {
|
|
633
|
+
throw new DataError(
|
|
634
|
+
`"source.key" is the column that names a row in both the sheet and ${tableName}. Columns: ${columns.map(c => c.name).join(', ')}.`
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if (!column.unique) {
|
|
638
|
+
throw new DataError(`${tableName}.${key} must be unique to be the sync key.`);
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
uri,
|
|
642
|
+
...(typeof sheet === 'string' ? { sheet: sheet.trim() } : {}),
|
|
643
|
+
key,
|
|
644
|
+
...(typeof title === 'string' && title.trim() ? { title: title.trim().slice(0, 200) } : {})
|
|
645
|
+
};
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const syncedScopedError = (tableName: string) =>
|
|
649
|
+
new DataError(
|
|
650
|
+
`${tableName} can't be both customer-scoped and synced from a sheet: synced rows have no customer, so the bots wouldn't see them.`
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const sameSource = (a: DataTableSource | null | undefined, b: DataTableSource | null) =>
|
|
654
|
+
(a?.uri ?? null) === (b?.uri ?? null) &&
|
|
655
|
+
(a?.sheet ?? null) === (b?.sheet ?? null) &&
|
|
656
|
+
(a?.key ?? null) === (b?.key ?? null);
|
|
657
|
+
|
|
562
658
|
// Keeping indexes, triggers and the search table in line with the metadata
|
|
563
659
|
|
|
564
660
|
const tableObjects = (runner: SqlRunner, type: string, table: string) =>
|
|
@@ -1403,6 +1499,18 @@ export const assertWritable = (
|
|
|
1403
1499
|
}
|
|
1404
1500
|
};
|
|
1405
1501
|
|
|
1502
|
+
// A table synced from a sheet takes its rows from the sheet alone, whoever
|
|
1503
|
+
// writes: the dashboard, the API, the CLI, functions and the assistant.
|
|
1504
|
+
export const assertNotSynced = (table: DataTable) => {
|
|
1505
|
+
if (!table.source) return;
|
|
1506
|
+
const from = table.source.title || table.source.uri;
|
|
1507
|
+
throw new DataError(
|
|
1508
|
+
`${table.name} is synced from "${from}"; change the sheet, or stop syncing it.`,
|
|
1509
|
+
'read_only',
|
|
1510
|
+
{ source: table.source }
|
|
1511
|
+
);
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1406
1514
|
// Every write names its actor here first, so row history (kept by triggers)
|
|
1407
1515
|
// records who made each change, cascades included.
|
|
1408
1516
|
const setContextActor = (runner: SqlRunner, actor: string) => {
|
|
@@ -1637,6 +1745,7 @@ const runOperation = (
|
|
|
1637
1745
|
op = { ...rawOp, id };
|
|
1638
1746
|
}
|
|
1639
1747
|
const table = tableFor(schema, caller, op.table, kind === 'insert' ? 'add' : 'full');
|
|
1748
|
+
assertNotSynced(table);
|
|
1640
1749
|
const actor = caller.actor;
|
|
1641
1750
|
const scoped = isScoped(caller, table);
|
|
1642
1751
|
|
|
@@ -1664,19 +1773,21 @@ const runOperation = (
|
|
|
1664
1773
|
// One column list for the whole batch, so rows that leave a column out
|
|
1665
1774
|
// get NULL there rather than its default only when another row sets it.
|
|
1666
1775
|
const names = [...new Set(prepared.flatMap(values => [...values.keys()]))];
|
|
1667
|
-
const columns = [...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
|
|
1776
|
+
const columns = ['id', ...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
|
|
1668
1777
|
const ids: number[] = [];
|
|
1778
|
+
let nextId = nextRowId(runner, table);
|
|
1669
1779
|
try {
|
|
1670
1780
|
for (const chunk of chunkRows(prepared, columns.length)) {
|
|
1671
1781
|
const usesDefaults = chunk.some(values => names.some(n => !values.has(n)));
|
|
1672
1782
|
if (usesDefaults && chunk.length > 1) {
|
|
1673
1783
|
// Rows with different columns go one by one, so each missing
|
|
1674
1784
|
// column takes its default.
|
|
1675
|
-
for (const values of chunk) ids.push(insertOne(runner, table, values, actor, scoped, caller));
|
|
1785
|
+
for (const values of chunk) ids.push(insertOne(runner, table, values, actor, scoped, caller, nextId++));
|
|
1676
1786
|
continue;
|
|
1677
1787
|
}
|
|
1678
1788
|
const params: SqlValue[] = [];
|
|
1679
1789
|
for (const values of chunk) {
|
|
1790
|
+
params.push(nextId++);
|
|
1680
1791
|
for (const name of names) params.push(values.get(name) ?? null);
|
|
1681
1792
|
params.push(actor, actor);
|
|
1682
1793
|
if (scoped) params.push(participantOf(caller));
|
|
@@ -1793,6 +1904,33 @@ const runOperation = (
|
|
|
1793
1904
|
.all(`SELECT t.id FROM ${q(table.name)} t WHERE ${where}`, ctx.params)
|
|
1794
1905
|
.map(r => Number(r.id))
|
|
1795
1906
|
: assertBoundedTarget();
|
|
1907
|
+
// A delete here mustn't change a synced table's rows behind its sheet,
|
|
1908
|
+
// through a cascade or a set null.
|
|
1909
|
+
for (const other of schema.tables) {
|
|
1910
|
+
if (!other.source || other.name === table.name || ids.length === 0) continue;
|
|
1911
|
+
for (const c of other.columns) {
|
|
1912
|
+
if (c.type !== 'relation' || c.table !== table.name) continue;
|
|
1913
|
+
if ((c.onDelete ?? 'restrict') === 'restrict') continue;
|
|
1914
|
+
let n = 0;
|
|
1915
|
+
for (let i = 0; i < ids.length; i += DATA_MAX_PARAMS) {
|
|
1916
|
+
const slice = ids.slice(i, i + DATA_MAX_PARAMS);
|
|
1917
|
+
n += Number(
|
|
1918
|
+
first(
|
|
1919
|
+
runner,
|
|
1920
|
+
`SELECT count(*) AS n FROM ${q(other.name)} WHERE ${q(c.name)} IN (${slice.map(() => '?').join(', ')})`,
|
|
1921
|
+
slice
|
|
1922
|
+
)?.n ?? 0
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
if (n > 0) {
|
|
1926
|
+
throw new DataError(
|
|
1927
|
+
`Can't delete: it would change ${n === 1 ? 'a row' : `${n} rows`} of ${other.name} (${other.name}.${c.name}), which is synced from "${other.source.title || other.source.uri}"; change the sheet first, or stop syncing it.`,
|
|
1928
|
+
'read_only',
|
|
1929
|
+
{ table: other.name, column: c.name, count: n }
|
|
1930
|
+
);
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1796
1934
|
// A readable "3 rows in order_lines still point at this order" before
|
|
1797
1935
|
// SQLite's bare foreign key error. A row of the same table counts even
|
|
1798
1936
|
// when this delete removes it too: SQLite checks a restrict as each row
|
|
@@ -1855,16 +1993,32 @@ const runOperation = (
|
|
|
1855
1993
|
return { op: kind, table: table.name, changed };
|
|
1856
1994
|
};
|
|
1857
1995
|
|
|
1996
|
+
// The id the table's next row gets. Tables have no AUTOINCREMENT, so SQLite
|
|
1997
|
+
// would hand out the highest id again once that row is deleted, and the
|
|
1998
|
+
// deleted row's history would read as the new one's. Above both, instead.
|
|
1999
|
+
const nextRowId = (runner: SqlRunner, table: DataTable): number =>
|
|
2000
|
+
Number(
|
|
2001
|
+
first(
|
|
2002
|
+
runner,
|
|
2003
|
+
`SELECT max(
|
|
2004
|
+
coalesce((SELECT max(id) FROM ${q(table.name)}), 0),
|
|
2005
|
+
coalesce((SELECT max(row_id) FROM _ganju_row_log WHERE table_id = ?), 0)
|
|
2006
|
+
) + 1 AS id`,
|
|
2007
|
+
[table.id]
|
|
2008
|
+
)?.id ?? 1
|
|
2009
|
+
);
|
|
2010
|
+
|
|
1858
2011
|
const insertOne = (
|
|
1859
2012
|
runner: SqlRunner,
|
|
1860
2013
|
table: DataTable,
|
|
1861
2014
|
values: Map<string, SqlValue>,
|
|
1862
2015
|
actor: string,
|
|
1863
2016
|
scoped: boolean,
|
|
1864
|
-
caller: DataCaller
|
|
2017
|
+
caller: DataCaller,
|
|
2018
|
+
id: number
|
|
1865
2019
|
): number => {
|
|
1866
|
-
const names = [...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
|
|
1867
|
-
const params = [...values.values(), actor, actor, ...(scoped ? [participantOf(caller)] : [])];
|
|
2020
|
+
const names = ['id', ...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
|
|
2021
|
+
const params = [id, ...values.values(), actor, actor, ...(scoped ? [participantOf(caller)] : [])];
|
|
1868
2022
|
return Number(
|
|
1869
2023
|
runner.all(
|
|
1870
2024
|
`INSERT INTO ${q(table.name)} (${names.map(q).join(', ')}) VALUES (${names.map(() => '?').join(', ')}) RETURNING id`,
|
|
@@ -1925,6 +2079,279 @@ export const write = (
|
|
|
1925
2079
|
});
|
|
1926
2080
|
};
|
|
1927
2081
|
|
|
2082
|
+
// Sheet sync
|
|
2083
|
+
|
|
2084
|
+
export interface SheetSyncArgs {
|
|
2085
|
+
table: string;
|
|
2086
|
+
// The table's columns the sheet has, matched from its headers.
|
|
2087
|
+
columns: string[];
|
|
2088
|
+
// Each line of the sheet as column: value, read the way an import reads
|
|
2089
|
+
// it; a column left out is empty in that line.
|
|
2090
|
+
rows: Record<string, unknown>[];
|
|
2091
|
+
// Each row's line in the sheet, for messages.
|
|
2092
|
+
lines?: number[];
|
|
2093
|
+
// The file's name now, for the read-only message.
|
|
2094
|
+
title?: string | null;
|
|
2095
|
+
// The file's version (when it was stored). One older than the rows were
|
|
2096
|
+
// last made from is skipped.
|
|
2097
|
+
version?: string | null;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// Whether `version` is older than what the table's rows were last made from.
|
|
2101
|
+
const staleVersion = (table: DataTable, version: string | null | undefined) =>
|
|
2102
|
+
typeof version === 'string' && !!table.sync?.version && version < table.sync.version;
|
|
2103
|
+
|
|
2104
|
+
// The same JSON with its keys in order, so a sheet that writes it another
|
|
2105
|
+
// way round isn't a change.
|
|
2106
|
+
const canonicalJson = (value: unknown): unknown =>
|
|
2107
|
+
Array.isArray(value)
|
|
2108
|
+
? value.map(canonicalJson)
|
|
2109
|
+
: isPlainObject(value)
|
|
2110
|
+
? Object.fromEntries(Object.keys(value).sort().map(k => [k, canonicalJson(value[k])]))
|
|
2111
|
+
: value;
|
|
2112
|
+
const sameStored = (column: DataColumn, a: SqlValue, b: SqlValue) => {
|
|
2113
|
+
if (a === b) return true;
|
|
2114
|
+
if (column.type !== 'json' || typeof a !== 'string' || typeof b !== 'string') return false;
|
|
2115
|
+
try {
|
|
2116
|
+
return JSON.stringify(canonicalJson(JSON.parse(a))) === JSON.stringify(canonicalJson(JSON.parse(b)));
|
|
2117
|
+
} catch {
|
|
2118
|
+
return false;
|
|
2119
|
+
}
|
|
2120
|
+
};
|
|
2121
|
+
|
|
2122
|
+
const nowStamp = () => new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
2123
|
+
|
|
2124
|
+
// A refusal about one line of the sheet.
|
|
2125
|
+
const atLine = (line: number, error: DataError) =>
|
|
2126
|
+
new DataError(`Line ${line}: ${error.message}`, error.code, error.details);
|
|
2127
|
+
|
|
2128
|
+
const saveSyncState = (
|
|
2129
|
+
runner: SqlRunner,
|
|
2130
|
+
table: DataTable,
|
|
2131
|
+
state: DataTableSync,
|
|
2132
|
+
title?: string | null
|
|
2133
|
+
) => {
|
|
2134
|
+
const source =
|
|
2135
|
+
table.source && typeof title === 'string' && title.trim()
|
|
2136
|
+
? { ...table.source, title: title.trim().slice(0, 200) }
|
|
2137
|
+
: table.source;
|
|
2138
|
+
runner.all('UPDATE _ganju_tables SET sync_state = ?, source = ? WHERE id = ?', [
|
|
2139
|
+
JSON.stringify(state),
|
|
2140
|
+
source ? JSON.stringify(source) : null,
|
|
2141
|
+
table.id
|
|
2142
|
+
]);
|
|
2143
|
+
};
|
|
2144
|
+
|
|
2145
|
+
// Makes a synced table's rows what the sheet says, in one transaction: new
|
|
2146
|
+
// keys are inserted, rows whose values differ updated, rows whose key is
|
|
2147
|
+
// gone deleted. Unchanged rows aren't written. Anything that doesn't fit
|
|
2148
|
+
// refuses the whole sync, and the table keeps its rows.
|
|
2149
|
+
export const syncRows = (
|
|
2150
|
+
runner: SqlRunner,
|
|
2151
|
+
schema: DataSchema,
|
|
2152
|
+
caller: DataCaller,
|
|
2153
|
+
args: SheetSyncArgs
|
|
2154
|
+
): DataTableSync => {
|
|
2155
|
+
assertWritable(runner, caller, true);
|
|
2156
|
+
const table = tableNamed(schema, args?.table);
|
|
2157
|
+
const source = table.source;
|
|
2158
|
+
if (!source) {
|
|
2159
|
+
throw new DataError(`${table.name} isn't synced from a sheet.`, 'conflict');
|
|
2160
|
+
}
|
|
2161
|
+
const keyColumn = columnNamed(table, source.key);
|
|
2162
|
+
if (!Array.isArray(args.columns) || !Array.isArray(args.rows)) {
|
|
2163
|
+
throw new DataError('A sync takes "columns" and "rows".');
|
|
2164
|
+
}
|
|
2165
|
+
const columns = [...new Set(args.columns)].map(name => columnNamed(table, name));
|
|
2166
|
+
if (!columns.includes(keyColumn)) {
|
|
2167
|
+
throw new DataError(
|
|
2168
|
+
`The sheet has no column for ${table.name}'s key, ${keyColumn.name}.`
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
const unfilled = table.columns.filter(
|
|
2172
|
+
c => c.required && (c.default === undefined || c.default === null) && !columns.includes(c)
|
|
2173
|
+
);
|
|
2174
|
+
if (unfilled.length) {
|
|
2175
|
+
throw new DataError(
|
|
2176
|
+
`The sheet has no column for ${unfilled.map(c => `${table.name}.${c.name}`).join(', ')}, which ${unfilled.length === 1 ? 'is' : 'are'} required.`
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
if (args.rows.length > DATA_SHEET_MAX_ROWS) {
|
|
2180
|
+
throw new DataError(
|
|
2181
|
+
`The sheet has ${args.rows.length.toLocaleString('en-US')} rows; a synced table takes up to ${DATA_SHEET_MAX_ROWS.toLocaleString('en-US')}.`,
|
|
2182
|
+
'too_large'
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// Every line as it will be stored, checked before anything is written.
|
|
2187
|
+
const keyOf = (value: SqlValue) => JSON.stringify(value);
|
|
2188
|
+
const wanted = new Map<string, { line: number; values: Map<string, SqlValue> }>();
|
|
2189
|
+
args.rows.forEach((row, i) => {
|
|
2190
|
+
const line = args.lines?.[i] ?? i + 2;
|
|
2191
|
+
if (!isPlainObject(row)) throw atLine(line, new DataError('A row is an object of column: value.'));
|
|
2192
|
+
const values = new Map<string, SqlValue>();
|
|
2193
|
+
for (const column of columns) {
|
|
2194
|
+
const raw = row[column.name];
|
|
2195
|
+
let stored: SqlValue;
|
|
2196
|
+
try {
|
|
2197
|
+
stored = raw === undefined || raw === null ? null : toStored(table, column, raw, caller.timeZone);
|
|
2198
|
+
} catch (error) {
|
|
2199
|
+
if (error instanceof DataError) throw atLine(line, error);
|
|
2200
|
+
throw error;
|
|
2201
|
+
}
|
|
2202
|
+
if (stored === null && column.required) {
|
|
2203
|
+
throw atLine(line, new DataError(`${table.name}.${column.name} is required.`));
|
|
2204
|
+
}
|
|
2205
|
+
if (column.min !== null && typeof stored === 'number' && stored < column.min) {
|
|
2206
|
+
throw atLine(line, new DataError(`${table.name}.${column.name} can't go below ${column.min}.`));
|
|
2207
|
+
}
|
|
2208
|
+
values.set(column.name, stored);
|
|
2209
|
+
}
|
|
2210
|
+
const key = values.get(keyColumn.name) ?? null;
|
|
2211
|
+
if (key === null) {
|
|
2212
|
+
throw atLine(line, new DataError(`The key, ${keyColumn.name}, is empty.`));
|
|
2213
|
+
}
|
|
2214
|
+
const seen = wanted.get(keyOf(key));
|
|
2215
|
+
if (seen) {
|
|
2216
|
+
throw atLine(
|
|
2217
|
+
line,
|
|
2218
|
+
new DataError(
|
|
2219
|
+
`${keyColumn.name} ${JSON.stringify(key)} is already on line ${seen.line}; each line of the sheet needs its own key.`,
|
|
2220
|
+
'constraint',
|
|
2221
|
+
{ key, lines: [seen.line, line] }
|
|
2222
|
+
)
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
wanted.set(keyOf(key), { line, values });
|
|
2226
|
+
});
|
|
2227
|
+
|
|
2228
|
+
// Two syncs that overlap: the one that read the older file changes nothing.
|
|
2229
|
+
if (staleVersion(table, args.version)) {
|
|
2230
|
+
return { ...table.sync!, inserted: 0, updated: 0, deleted: 0, unchanged: 0, stale: true };
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
return runner.transaction(() => {
|
|
2234
|
+
setContextActor(runner, caller.actor);
|
|
2235
|
+
runner.all('DELETE FROM _ganju_changes');
|
|
2236
|
+
const before = runner.usageSoFar?.().rowsWritten ?? 0;
|
|
2237
|
+
const names = columns.map(c => c.name);
|
|
2238
|
+
const existing = new Map<string, SqlRow>();
|
|
2239
|
+
const stale: number[] = [];
|
|
2240
|
+
let nextId = nextRowId(runner, table);
|
|
2241
|
+
for (const row of runner.all(
|
|
2242
|
+
`SELECT id, ${names.map(n => q(n)).join(', ')} FROM ${q(table.name)}`
|
|
2243
|
+
)) {
|
|
2244
|
+
const key = row[keyColumn.name] ?? null;
|
|
2245
|
+
if (key !== null && wanted.has(keyOf(key))) existing.set(keyOf(key), row);
|
|
2246
|
+
else stale.push(Number(row.id));
|
|
2247
|
+
}
|
|
2248
|
+
const counts = { inserted: 0, updated: 0, deleted: 0, unchanged: 0 };
|
|
2249
|
+
|
|
2250
|
+
// Rows the sheet no longer has go first, so a key that moved to a new
|
|
2251
|
+
// line isn't taken twice. A row another table still points at with
|
|
2252
|
+
// restrict stops the sync.
|
|
2253
|
+
for (const other of schema.tables) {
|
|
2254
|
+
for (const c of other.columns) {
|
|
2255
|
+
if (c.type !== 'relation' || c.table !== table.name) continue;
|
|
2256
|
+
if ((c.onDelete ?? 'restrict') !== 'restrict' || stale.length === 0) continue;
|
|
2257
|
+
let n = 0;
|
|
2258
|
+
for (let i = 0; i < stale.length; i += DATA_MAX_PARAMS) {
|
|
2259
|
+
const slice = stale.slice(i, i + DATA_MAX_PARAMS);
|
|
2260
|
+
n += Number(
|
|
2261
|
+
first(
|
|
2262
|
+
runner,
|
|
2263
|
+
`SELECT count(*) AS n FROM ${q(other.name)} WHERE ${q(c.name)} IN (${slice.map(() => '?').join(', ')})`,
|
|
2264
|
+
slice
|
|
2265
|
+
)?.n ?? 0
|
|
2266
|
+
);
|
|
2267
|
+
}
|
|
2268
|
+
if (n > 0) {
|
|
2269
|
+
throw new DataError(
|
|
2270
|
+
`Rows in ${other.name} still point at ${n === 1 ? 'a row' : `${n} rows`} the sheet no longer has (${other.name}.${c.name}); put them back in the sheet, or change those rows first.`,
|
|
2271
|
+
'constraint',
|
|
2272
|
+
{ table: other.name, column: c.name, count: n }
|
|
2273
|
+
);
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
try {
|
|
2278
|
+
for (let i = 0; i < stale.length; i += DATA_MAX_PARAMS) {
|
|
2279
|
+
const slice = stale.slice(i, i + DATA_MAX_PARAMS);
|
|
2280
|
+
counts.deleted += runner.all(
|
|
2281
|
+
`DELETE FROM ${q(table.name)} WHERE id IN (${slice.map(() => '?').join(', ')}) RETURNING id`,
|
|
2282
|
+
slice
|
|
2283
|
+
).length;
|
|
2284
|
+
}
|
|
2285
|
+
for (const [key, { line, values }] of wanted) {
|
|
2286
|
+
const current = existing.get(key);
|
|
2287
|
+
try {
|
|
2288
|
+
if (!current) {
|
|
2289
|
+
insertOne(runner, table, values, caller.actor, false, caller, nextId++);
|
|
2290
|
+
counts.inserted++;
|
|
2291
|
+
continue;
|
|
2292
|
+
}
|
|
2293
|
+
const changed = columns
|
|
2294
|
+
.filter(c => !sameStored(c, current[c.name] ?? null, values.get(c.name) ?? null))
|
|
2295
|
+
.map(c => c.name);
|
|
2296
|
+
if (changed.length === 0) {
|
|
2297
|
+
counts.unchanged++;
|
|
2298
|
+
continue;
|
|
2299
|
+
}
|
|
2300
|
+
runner.all(
|
|
2301
|
+
`UPDATE ${q(table.name)} SET ${changed.map(n => `${q(n)} = ?`).join(', ')}, version = version + 1, updated_at = ${DATA_NOW_SQL}, updated_by = ? WHERE id = ?`,
|
|
2302
|
+
[...changed.map(n => values.get(n) ?? null), caller.actor, Number(current.id)]
|
|
2303
|
+
);
|
|
2304
|
+
counts.updated++;
|
|
2305
|
+
} catch (error) {
|
|
2306
|
+
throw atLine(line, translateSqliteError(schema, table, error));
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
throw translateSqliteError(schema, table, error);
|
|
2311
|
+
}
|
|
2312
|
+
runner.noteWrite?.({
|
|
2313
|
+
table: table.name,
|
|
2314
|
+
rowsWritten: (runner.usageSoFar?.().rowsWritten ?? 0) - before
|
|
2315
|
+
});
|
|
2316
|
+
collectTriggerEvents(runner, schema, caller);
|
|
2317
|
+
const state: DataTableSync = {
|
|
2318
|
+
at: nowStamp(),
|
|
2319
|
+
status: 'ok',
|
|
2320
|
+
...counts,
|
|
2321
|
+
error: null,
|
|
2322
|
+
version: typeof args.version === 'string' ? args.version : (table.sync?.version ?? null)
|
|
2323
|
+
};
|
|
2324
|
+
saveSyncState(runner, table, state, args.title);
|
|
2325
|
+
return state;
|
|
2326
|
+
});
|
|
2327
|
+
};
|
|
2328
|
+
|
|
2329
|
+
// Keeps why a sync was refused on the table, where the owner sees it. The
|
|
2330
|
+
// rows are as they were.
|
|
2331
|
+
export const recordSyncFailure = (
|
|
2332
|
+
runner: SqlRunner,
|
|
2333
|
+
schema: DataSchema,
|
|
2334
|
+
args: { table: string; error: string; title?: string | null; version?: string | null }
|
|
2335
|
+
): DataTableSync | null => {
|
|
2336
|
+
const table = schema.tables.find(t => t.name === args?.table);
|
|
2337
|
+
if (!table?.source) return null;
|
|
2338
|
+
// An older file's problem says nothing about the rows now.
|
|
2339
|
+
if (staleVersion(table, args.version)) return table.sync ?? null;
|
|
2340
|
+
const state: DataTableSync = {
|
|
2341
|
+
at: nowStamp(),
|
|
2342
|
+
status: 'failed',
|
|
2343
|
+
inserted: 0,
|
|
2344
|
+
updated: 0,
|
|
2345
|
+
deleted: 0,
|
|
2346
|
+
unchanged: 0,
|
|
2347
|
+
error: String(args.error ?? '').slice(0, 1000) || 'The sync failed.',
|
|
2348
|
+
// The rows are still from the last file applied.
|
|
2349
|
+
version: table.sync?.version ?? null
|
|
2350
|
+
};
|
|
2351
|
+
saveSyncState(runner, table, state, args.title);
|
|
2352
|
+
return state;
|
|
2353
|
+
};
|
|
2354
|
+
|
|
1928
2355
|
// Delete preview
|
|
1929
2356
|
|
|
1930
2357
|
// What deleting `ids` of a table, or all its rows, would do to the rows that
|
|
@@ -2380,6 +2807,7 @@ export const createTable = (
|
|
|
2380
2807
|
const access = normaliseAccess(input.access, 'read');
|
|
2381
2808
|
const customerScoped = flag(input.customerScoped, 'customerScoped');
|
|
2382
2809
|
const history = input.history === undefined ? true : flag(input.history, 'history');
|
|
2810
|
+
const source = normaliseSource(input.source, name, columns, customerScoped);
|
|
2383
2811
|
|
|
2384
2812
|
return runner.transaction(() => {
|
|
2385
2813
|
const position = Number(
|
|
@@ -2399,7 +2827,7 @@ export const createTable = (
|
|
|
2399
2827
|
);
|
|
2400
2828
|
const tableId = Number(
|
|
2401
2829
|
runner.all(
|
|
2402
|
-
'INSERT INTO _ganju_tables (id, name, label, description, access, customer_scoped, history, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
|
|
2830
|
+
'INSERT INTO _ganju_tables (id, name, label, description, access, customer_scoped, history, position, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
|
|
2403
2831
|
[
|
|
2404
2832
|
nextId,
|
|
2405
2833
|
name,
|
|
@@ -2408,7 +2836,8 @@ export const createTable = (
|
|
|
2408
2836
|
access,
|
|
2409
2837
|
customerScoped ? 1 : 0,
|
|
2410
2838
|
history ? 1 : 0,
|
|
2411
|
-
position
|
|
2839
|
+
position,
|
|
2840
|
+
source ? JSON.stringify(source) : null
|
|
2412
2841
|
]
|
|
2413
2842
|
)[0].id
|
|
2414
2843
|
);
|
|
@@ -2435,7 +2864,7 @@ export const updateTable = (
|
|
|
2435
2864
|
assertDdlAllowed(caller);
|
|
2436
2865
|
const table = tableNamed(schema, name);
|
|
2437
2866
|
if (!isPlainObject(patch)) throw new DataError('The change is an object.');
|
|
2438
|
-
const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history'];
|
|
2867
|
+
const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history', 'source'];
|
|
2439
2868
|
for (const key of Object.keys(patch)) {
|
|
2440
2869
|
if (!allowed.includes(key)) {
|
|
2441
2870
|
throw new DataError(`"${key}" can't be changed here; tables take ${allowed.join(', ')}.`);
|
|
@@ -2470,6 +2899,8 @@ export const updateTable = (
|
|
|
2470
2899
|
params.push(normaliseAccess(patch.access, table.access));
|
|
2471
2900
|
}
|
|
2472
2901
|
if ('customerScoped' in patch) {
|
|
2902
|
+
const keepsSource = 'source' in patch ? patch.source !== null && patch.source !== undefined : !!table.source;
|
|
2903
|
+
if (patch.customerScoped === true && keepsSource) throw syncedScopedError(table.name);
|
|
2473
2904
|
sets.push('customer_scoped = ?');
|
|
2474
2905
|
params.push(flag(patch.customerScoped, 'customerScoped') ? 1 : 0);
|
|
2475
2906
|
}
|
|
@@ -2477,6 +2908,14 @@ export const updateTable = (
|
|
|
2477
2908
|
sets.push('history = ?');
|
|
2478
2909
|
params.push(flag(patch.history, 'history') ? 1 : 0);
|
|
2479
2910
|
}
|
|
2911
|
+
if ('source' in patch) {
|
|
2912
|
+
const scoped = 'customerScoped' in patch ? patch.customerScoped === true : table.customerScoped;
|
|
2913
|
+
const source = normaliseSource(patch.source, current, table.columns, scoped);
|
|
2914
|
+
sets.push('source = ?');
|
|
2915
|
+
params.push(source ? JSON.stringify(source) : null);
|
|
2916
|
+
// A new file, sheet or key starts over: the last sync was of another.
|
|
2917
|
+
if (!sameSource(table.source, source)) sets.push('sync_state = NULL');
|
|
2918
|
+
}
|
|
2480
2919
|
if (sets.length) {
|
|
2481
2920
|
runner.all(`UPDATE _ganju_tables SET ${sets.join(', ')} WHERE id = ?`, [...params, table.id]);
|
|
2482
2921
|
}
|
|
@@ -2936,8 +3375,32 @@ export const updateColumn = (
|
|
|
2936
3375
|
if (draft.name !== column.name && table.columns.some(c => c.name === draft.name)) {
|
|
2937
3376
|
throw new DataError(`${table.name} already has a column "${draft.name}".`, 'conflict');
|
|
2938
3377
|
}
|
|
3378
|
+
// A time trigger counts from a date or datetime; anything else has no time.
|
|
3379
|
+
if (draft.type !== 'date' && draft.type !== 'datetime') {
|
|
3380
|
+
const timed = timeTriggersCountingFrom(runner, table.id, column.name);
|
|
3381
|
+
if (timed.length) {
|
|
3382
|
+
throw new DataError(
|
|
3383
|
+
`${table.name}.${column.name} stays a date or datetime while time triggers count from it (${timed.join(', ')}); change or delete them first.`,
|
|
3384
|
+
'constraint',
|
|
3385
|
+
{ triggers: timed }
|
|
3386
|
+
);
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
const isSyncKey = table.source?.key === column.name;
|
|
3390
|
+
if (isSyncKey && !draft.unique) {
|
|
3391
|
+
throw new DataError(
|
|
3392
|
+
`${table.name}.${column.name} is the key the sheet sync matches rows by, so it stays unique.`,
|
|
3393
|
+
'constraint'
|
|
3394
|
+
);
|
|
3395
|
+
}
|
|
2939
3396
|
|
|
2940
3397
|
if (columnChangeNeedsRebuild(schema, table.name, column.name, patch)) {
|
|
3398
|
+
if (isSyncKey && draft.name !== column.name) {
|
|
3399
|
+
throw new DataError(
|
|
3400
|
+
`${table.name}.${column.name} is the sheet sync's key: rename it on its own, then make the other changes.`,
|
|
3401
|
+
'constraint'
|
|
3402
|
+
);
|
|
3403
|
+
}
|
|
2941
3404
|
// A rename renames the column in its triggers too; a copy can't, so the
|
|
2942
3405
|
// two go one after the other.
|
|
2943
3406
|
const triggers = draft.name !== column.name ? triggersUsingColumn(runner, table.id, column.name) : [];
|
|
@@ -2966,6 +3429,12 @@ export const updateColumn = (
|
|
|
2966
3429
|
`ALTER TABLE ${q(table.name)} RENAME COLUMN ${q(column.name)} TO ${q(draft.name)}`
|
|
2967
3430
|
);
|
|
2968
3431
|
renameColumnInTriggers(runner, table.id, column.name, draft.name);
|
|
3432
|
+
if (isSyncKey && table.source) {
|
|
3433
|
+
runner.all('UPDATE _ganju_tables SET source = ? WHERE id = ?', [
|
|
3434
|
+
JSON.stringify({ ...table.source, key: draft.name }),
|
|
3435
|
+
table.id
|
|
3436
|
+
]);
|
|
3437
|
+
}
|
|
2969
3438
|
}
|
|
2970
3439
|
updateColumnMeta(runner, schema, table, column.id, draft);
|
|
2971
3440
|
touchTable(runner, table.id);
|
|
@@ -2989,6 +3458,12 @@ export const dropColumn = (
|
|
|
2989
3458
|
throw new DataError(`${table.name} needs at least one column; drop the table instead.`);
|
|
2990
3459
|
}
|
|
2991
3460
|
assertNoTriggerUses(runner, table, column.name);
|
|
3461
|
+
if (table.source?.key === column.name) {
|
|
3462
|
+
throw new DataError(
|
|
3463
|
+
`${table.name}.${column.name} is the key the sheet sync matches rows by; stop syncing the table, or pick another key, first.`,
|
|
3464
|
+
'constraint'
|
|
3465
|
+
);
|
|
3466
|
+
}
|
|
2992
3467
|
if (dropColumnNeedsRebuild(schema, table.name, column.name)) {
|
|
2993
3468
|
return rebuildTable(
|
|
2994
3469
|
runner,
|