@ganju/utils 0.0.15 → 0.0.16
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 +23 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +95 -8
- package/dist/data.d.ts +9 -0
- package/dist/data.d.ts.map +1 -0
- package/dist/data.js +78 -0
- package/dist/dataApi.d.ts +511 -0
- package/dist/dataApi.d.ts.map +1 -0
- package/dist/dataApi.js +355 -0
- package/dist/dataCompiler.d.ts +68 -0
- package/dist/dataCompiler.d.ts.map +1 -0
- package/dist/dataCompiler.js +726 -0
- package/dist/dataDdl.d.ts +29 -0
- package/dist/dataDdl.d.ts.map +1 -0
- package/dist/dataDdl.js +354 -0
- package/dist/dataEngine.d.ts +143 -0
- package/dist/dataEngine.d.ts.map +1 -0
- package/dist/dataEngine.js +2022 -0
- package/dist/dataIdentifiers.d.ts +8 -0
- package/dist/dataIdentifiers.d.ts.map +1 -0
- package/dist/dataIdentifiers.js +55 -0
- package/dist/dataImport.d.ts +77 -0
- package/dist/dataImport.d.ts.map +1 -0
- package/dist/dataImport.js +450 -0
- package/dist/dataRpc.d.ts +131 -0
- package/dist/dataRpc.d.ts.map +1 -0
- package/dist/dataRpc.js +2 -0
- package/dist/dataTypes.d.ts +157 -0
- package/dist/dataTypes.d.ts.map +1 -0
- package/dist/dataTypes.js +160 -0
- package/dist/index.d.ts +308 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/oauth.d.ts +14 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +42 -2
- package/dist/resourceRerank.d.ts +3 -2
- package/dist/resourceRerank.d.ts.map +1 -1
- package/dist/resourceRerank.js +5 -2
- package/dist/schema.d.ts +285 -4
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +203 -0
- package/package.json +17 -2
- package/src/constants.ts +108 -8
- package/src/data.ts +74 -0
- package/src/dataApi.ts +428 -0
- package/src/dataCompiler.ts +948 -0
- package/src/dataDdl.ts +419 -0
- package/src/dataEngine.ts +2753 -0
- package/src/dataIdentifiers.ts +63 -0
- package/src/dataImport.ts +554 -0
- package/src/dataRpc.ts +121 -0
- package/src/dataTypes.ts +312 -0
- package/src/index.ts +4 -0
- package/src/oauth.ts +42 -1
- package/src/resourceRerank.ts +5 -2
- package/src/schema.ts +246 -0
|
@@ -0,0 +1,2753 @@
|
|
|
1
|
+
// What a project's database does, written against a tiny SQL runner so the
|
|
2
|
+
// same code runs inside the Durable Object (apps/data) and on node:sqlite in
|
|
3
|
+
// the tests and the tool-design eval. The runner executes; everything about
|
|
4
|
+
// which SQL is allowed and how results look is decided here.
|
|
5
|
+
import { identifierIssue, quoteIdentifier as q } from './dataIdentifiers';
|
|
6
|
+
import {
|
|
7
|
+
DATA_NOW_SQL,
|
|
8
|
+
NUMERIC_TYPES,
|
|
9
|
+
SEARCHABLE_TYPES,
|
|
10
|
+
UNIQUE_TYPES,
|
|
11
|
+
columnDefinitionSql,
|
|
12
|
+
conversionSql,
|
|
13
|
+
createTableSql,
|
|
14
|
+
desiredIndexes,
|
|
15
|
+
desiredTriggers,
|
|
16
|
+
ftsCreateSql,
|
|
17
|
+
ftsTableName,
|
|
18
|
+
invalidAfterConversionSql,
|
|
19
|
+
isIndexedColumn,
|
|
20
|
+
searchableColumns,
|
|
21
|
+
triggerPrefix
|
|
22
|
+
} from './dataDdl';
|
|
23
|
+
import {
|
|
24
|
+
accessOf,
|
|
25
|
+
assertParamCount,
|
|
26
|
+
canRead,
|
|
27
|
+
chunkRows,
|
|
28
|
+
columnFor,
|
|
29
|
+
compileWhere,
|
|
30
|
+
decodeCursor,
|
|
31
|
+
encodeCursor,
|
|
32
|
+
fromStored,
|
|
33
|
+
ftsMatch,
|
|
34
|
+
isScoped,
|
|
35
|
+
isSystemColumn,
|
|
36
|
+
joinsParams,
|
|
37
|
+
joinsSql,
|
|
38
|
+
keysetCondition,
|
|
39
|
+
participantOf,
|
|
40
|
+
relationJoin,
|
|
41
|
+
resolvePath,
|
|
42
|
+
tableFor,
|
|
43
|
+
tidyArgs,
|
|
44
|
+
toStored,
|
|
45
|
+
unknownColumn,
|
|
46
|
+
visibleTables,
|
|
47
|
+
zoned
|
|
48
|
+
} from './dataCompiler';
|
|
49
|
+
import type { CompileContext, SortKey, SqlJoin, SqlValue } from './dataCompiler';
|
|
50
|
+
import {
|
|
51
|
+
toTableInput,
|
|
52
|
+
DATA_ACCESS_LEVELS,
|
|
53
|
+
DATA_COLUMN_TYPES,
|
|
54
|
+
DATA_DEFAULT_PAGE,
|
|
55
|
+
DATA_HISTORY_DAYS,
|
|
56
|
+
DATA_LARGE_TABLE_ROWS,
|
|
57
|
+
DATA_MAX_AGGREGATES,
|
|
58
|
+
DATA_MAX_COLUMNS,
|
|
59
|
+
DATA_MAX_DESCRIPTION,
|
|
60
|
+
DATA_MAX_ENUM_VALUES,
|
|
61
|
+
DATA_MAX_GROUPS,
|
|
62
|
+
DATA_MAX_GROUP_BY,
|
|
63
|
+
DATA_MAX_INSERT_ROWS,
|
|
64
|
+
DATA_MAX_LABEL,
|
|
65
|
+
DATA_MAX_PARAMS,
|
|
66
|
+
DATA_MAX_PAGE,
|
|
67
|
+
DATA_MAX_RESULT_BYTES,
|
|
68
|
+
DATA_MAX_ROWS_PER_WRITE,
|
|
69
|
+
DATA_MAX_SORT_KEYS,
|
|
70
|
+
DATA_MAX_TABLES,
|
|
71
|
+
DATA_MAX_WRITE_OPERATIONS,
|
|
72
|
+
DATA_ON_DELETE,
|
|
73
|
+
DATA_COPY_STEP_ROWS,
|
|
74
|
+
DATA_IMPACT_MAX_IDS,
|
|
75
|
+
DATA_IMPACT_MAX_ROWS,
|
|
76
|
+
DATA_REBUILD_MAX_ROWS,
|
|
77
|
+
DataError
|
|
78
|
+
} from './dataTypes';
|
|
79
|
+
import type {
|
|
80
|
+
DataAccess,
|
|
81
|
+
DataCaller,
|
|
82
|
+
DataColumn,
|
|
83
|
+
DataColumnInput,
|
|
84
|
+
DataColumnType,
|
|
85
|
+
DataDefaultValue,
|
|
86
|
+
DataDeleteImpact,
|
|
87
|
+
DataOnDelete,
|
|
88
|
+
DataSchema,
|
|
89
|
+
DataTable,
|
|
90
|
+
DataTableCopy,
|
|
91
|
+
DataTableInput
|
|
92
|
+
} from './dataTypes';
|
|
93
|
+
|
|
94
|
+
export type SqlRow = Record<string, SqlValue>;
|
|
95
|
+
|
|
96
|
+
export interface SqlRunner {
|
|
97
|
+
all(sql: string, params?: readonly SqlValue[]): SqlRow[];
|
|
98
|
+
// Rows one at a time, for reads that stop at a size: a page of large rows
|
|
99
|
+
// must not be loaded whole before the 1 MB cap applies.
|
|
100
|
+
iterate(sql: string, params?: readonly SqlValue[]): Iterable<SqlRow>;
|
|
101
|
+
transaction<T>(fn: () => T): T;
|
|
102
|
+
databaseSize(): number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const first = (runner: SqlRunner, sql: string, params?: readonly SqlValue[]) =>
|
|
106
|
+
runner.all(sql, params)[0];
|
|
107
|
+
|
|
108
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
109
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
110
|
+
|
|
111
|
+
// Internal tables
|
|
112
|
+
|
|
113
|
+
// Our own tables inside every database. Each entry is one version; the
|
|
114
|
+
// object runs the ones it hasn't on first use after a deploy, so they must
|
|
115
|
+
// be additive and fast.
|
|
116
|
+
export const INTERNAL_MIGRATIONS: readonly (readonly string[])[] = [
|
|
117
|
+
[
|
|
118
|
+
'CREATE TABLE IF NOT EXISTS _ganju_meta (key TEXT PRIMARY KEY, value TEXT) WITHOUT ROWID',
|
|
119
|
+
`CREATE TABLE _ganju_tables (
|
|
120
|
+
id INTEGER PRIMARY KEY,
|
|
121
|
+
name TEXT NOT NULL UNIQUE,
|
|
122
|
+
label TEXT,
|
|
123
|
+
description TEXT,
|
|
124
|
+
display_column_id INTEGER,
|
|
125
|
+
access TEXT NOT NULL DEFAULT 'read',
|
|
126
|
+
customer_scoped INTEGER NOT NULL DEFAULT 0,
|
|
127
|
+
history INTEGER NOT NULL DEFAULT 1,
|
|
128
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
129
|
+
created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
|
|
130
|
+
updated_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL})
|
|
131
|
+
)`,
|
|
132
|
+
`CREATE TABLE _ganju_columns (
|
|
133
|
+
id INTEGER PRIMARY KEY,
|
|
134
|
+
table_id INTEGER NOT NULL REFERENCES _ganju_tables(id),
|
|
135
|
+
name TEXT NOT NULL,
|
|
136
|
+
label TEXT,
|
|
137
|
+
description TEXT,
|
|
138
|
+
type TEXT NOT NULL,
|
|
139
|
+
required INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
is_unique INTEGER NOT NULL DEFAULT 0,
|
|
141
|
+
filterable INTEGER NOT NULL DEFAULT 0,
|
|
142
|
+
searchable INTEGER NOT NULL DEFAULT 0,
|
|
143
|
+
personal INTEGER NOT NULL DEFAULT 0,
|
|
144
|
+
enum_values TEXT,
|
|
145
|
+
relation_table_id INTEGER REFERENCES _ganju_tables(id),
|
|
146
|
+
on_delete TEXT,
|
|
147
|
+
min REAL,
|
|
148
|
+
default_value TEXT,
|
|
149
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
150
|
+
created_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
|
|
151
|
+
updated_at TEXT NOT NULL DEFAULT (${DATA_NOW_SQL}),
|
|
152
|
+
UNIQUE (table_id, name)
|
|
153
|
+
)`,
|
|
154
|
+
`CREATE TABLE _ganju_row_log (
|
|
155
|
+
id INTEGER PRIMARY KEY,
|
|
156
|
+
table_id INTEGER NOT NULL,
|
|
157
|
+
row_id INTEGER NOT NULL,
|
|
158
|
+
op TEXT NOT NULL,
|
|
159
|
+
actor TEXT,
|
|
160
|
+
at TEXT NOT NULL,
|
|
161
|
+
changes TEXT NOT NULL
|
|
162
|
+
)`,
|
|
163
|
+
'CREATE INDEX _ganju_row_log_row ON _ganju_row_log(table_id, row_id)',
|
|
164
|
+
'CREATE TABLE _ganju_context (id INTEGER PRIMARY KEY CHECK (id = 1), actor TEXT)'
|
|
165
|
+
],
|
|
166
|
+
// Tables made before a new table got its first text column as the display
|
|
167
|
+
// column show ids in relations; give them that column now. A table made
|
|
168
|
+
// since with no display column asked for the id.
|
|
169
|
+
[
|
|
170
|
+
`UPDATE _ganju_tables SET display_column_id = (
|
|
171
|
+
SELECT c.id FROM _ganju_columns c
|
|
172
|
+
WHERE c.table_id = _ganju_tables.id AND c.type = 'text'
|
|
173
|
+
ORDER BY c.position, c.id LIMIT 1
|
|
174
|
+
) WHERE display_column_id IS NULL AND created_at < '2026-10-24T00:00:00Z'`
|
|
175
|
+
]
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
export const INTERNAL_SCHEMA_VERSION = INTERNAL_MIGRATIONS.length;
|
|
179
|
+
|
|
180
|
+
export const hasInternalTables = (runner: SqlRunner): boolean =>
|
|
181
|
+
runner.all(
|
|
182
|
+
"SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = '_ganju_meta'"
|
|
183
|
+
).length > 0;
|
|
184
|
+
|
|
185
|
+
export const getMeta = (runner: SqlRunner, key: string): string | null => {
|
|
186
|
+
const row = first(runner, 'SELECT value FROM _ganju_meta WHERE key = ?', [
|
|
187
|
+
key
|
|
188
|
+
]);
|
|
189
|
+
return row ? (row.value as string | null) : null;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const setMeta = (
|
|
193
|
+
runner: SqlRunner,
|
|
194
|
+
key: string,
|
|
195
|
+
value: string | null
|
|
196
|
+
): void => {
|
|
197
|
+
if (value === null) {
|
|
198
|
+
runner.all('DELETE FROM _ganju_meta WHERE key = ?', [key]);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
runner.all(
|
|
202
|
+
'INSERT INTO _ganju_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
|
203
|
+
[key, value]
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Brings our own tables up to date. Returns the version now in force.
|
|
208
|
+
export const migrateInternal = (runner: SqlRunner): number =>
|
|
209
|
+
runner.transaction(() => {
|
|
210
|
+
const current = hasInternalTables(runner)
|
|
211
|
+
? Number(getMeta(runner, 'schema_version') ?? 0)
|
|
212
|
+
: 0;
|
|
213
|
+
for (let v = current; v < INTERNAL_MIGRATIONS.length; v++) {
|
|
214
|
+
for (const statement of INTERNAL_MIGRATIONS[v]) runner.all(statement);
|
|
215
|
+
}
|
|
216
|
+
if (current < INTERNAL_MIGRATIONS.length) {
|
|
217
|
+
setMeta(runner, 'schema_version', String(INTERNAL_MIGRATIONS.length));
|
|
218
|
+
}
|
|
219
|
+
return INTERNAL_MIGRATIONS.length;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Schema
|
|
223
|
+
|
|
224
|
+
const parseJson = <T>(value: SqlValue): T | null => {
|
|
225
|
+
if (value === null) return null;
|
|
226
|
+
try {
|
|
227
|
+
return JSON.parse(String(value)) as T;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
export const loadSchema = (runner: SqlRunner): DataSchema => {
|
|
234
|
+
const tables = runner.all(
|
|
235
|
+
'SELECT t.*, d.name AS display_column FROM _ganju_tables t LEFT JOIN _ganju_columns d ON d.id = t.display_column_id ORDER BY t.position, t.id'
|
|
236
|
+
);
|
|
237
|
+
const columns = runner.all(
|
|
238
|
+
'SELECT c.*, r.name AS relation_table FROM _ganju_columns c LEFT JOIN _ganju_tables r ON r.id = c.relation_table_id ORDER BY c.table_id, c.position, c.id'
|
|
239
|
+
);
|
|
240
|
+
return {
|
|
241
|
+
tables: tables.map(row => ({
|
|
242
|
+
id: Number(row.id),
|
|
243
|
+
name: String(row.name),
|
|
244
|
+
label: (row.label as string | null) ?? null,
|
|
245
|
+
description: (row.description as string | null) ?? null,
|
|
246
|
+
displayColumn: (row.display_column as string | null) ?? null,
|
|
247
|
+
access: (row.access as DataAccess) ?? 'read',
|
|
248
|
+
customerScoped: row.customer_scoped === 1,
|
|
249
|
+
history: row.history === 1,
|
|
250
|
+
position: Number(row.position),
|
|
251
|
+
columns: columns
|
|
252
|
+
.filter(c => c.table_id === row.id)
|
|
253
|
+
.map(c => {
|
|
254
|
+
const stored = parseJson<{ v: DataDefaultValue }>(c.default_value);
|
|
255
|
+
return {
|
|
256
|
+
id: Number(c.id),
|
|
257
|
+
name: String(c.name),
|
|
258
|
+
type: c.type as DataColumnType,
|
|
259
|
+
label: (c.label as string | null) ?? null,
|
|
260
|
+
description: (c.description as string | null) ?? null,
|
|
261
|
+
required: c.required === 1,
|
|
262
|
+
unique: c.is_unique === 1,
|
|
263
|
+
filterable: c.filterable === 1,
|
|
264
|
+
searchable: c.searchable === 1,
|
|
265
|
+
personal: c.personal === 1,
|
|
266
|
+
values: parseJson<string[]>(c.enum_values),
|
|
267
|
+
table: (c.relation_table as string | null) ?? null,
|
|
268
|
+
onDelete: (c.on_delete as DataOnDelete | null) ?? null,
|
|
269
|
+
min: c.min === null ? null : Number(c.min),
|
|
270
|
+
default: stored ? stored.v : undefined,
|
|
271
|
+
position: Number(c.position)
|
|
272
|
+
};
|
|
273
|
+
})
|
|
274
|
+
}))
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Kept with the types, so a client can build it without the engine.
|
|
279
|
+
export { toTableInput };
|
|
280
|
+
|
|
281
|
+
const tableNamed = (schema: DataSchema, name: unknown): DataTable => {
|
|
282
|
+
const table = schema.tables.find(t => t.name === name);
|
|
283
|
+
if (!table) {
|
|
284
|
+
throw new DataError(
|
|
285
|
+
`Unknown table "${String(name)}". Tables: ${schema.tables.map(t => t.name).join(', ') || '(none)'}.`,
|
|
286
|
+
'not_found'
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return table;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const columnNamed = (table: DataTable, name: unknown): DataColumn => {
|
|
293
|
+
const column = table.columns.find(c => c.name === name);
|
|
294
|
+
if (!column) throw unknownColumn(table, String(name));
|
|
295
|
+
return column;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// Validation of definitions
|
|
299
|
+
|
|
300
|
+
const cleanText = (
|
|
301
|
+
value: unknown,
|
|
302
|
+
max: number,
|
|
303
|
+
label: string
|
|
304
|
+
): string | null => {
|
|
305
|
+
if (value === undefined || value === null) return null;
|
|
306
|
+
if (typeof value !== 'string') throw new DataError(`${label} is text.`);
|
|
307
|
+
const text = value.trim();
|
|
308
|
+
if (text.length > max) {
|
|
309
|
+
throw new DataError(`${label} is up to ${max} characters.`);
|
|
310
|
+
}
|
|
311
|
+
return text || null;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const flag = (value: unknown, label: string): boolean => {
|
|
315
|
+
if (value === undefined || value === null) return false;
|
|
316
|
+
if (typeof value !== 'boolean') {
|
|
317
|
+
throw new DataError(`${label} is true or false.`);
|
|
318
|
+
}
|
|
319
|
+
return value;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
type ColumnDraft = Omit<DataColumn, 'id' | 'position'>;
|
|
323
|
+
|
|
324
|
+
// Checks one column definition against the schema it will join. `selfTable`
|
|
325
|
+
// is the table the column belongs to, so a relation can point at it.
|
|
326
|
+
export const normaliseColumn = (
|
|
327
|
+
input: unknown,
|
|
328
|
+
schema: DataSchema,
|
|
329
|
+
selfTable: string
|
|
330
|
+
): ColumnDraft => {
|
|
331
|
+
if (!isPlainObject(input)) {
|
|
332
|
+
throw new DataError('A column is an object: { "name", "type", ... }.');
|
|
333
|
+
}
|
|
334
|
+
const name = input.name;
|
|
335
|
+
const nameIssue = identifierIssue(name, 'column');
|
|
336
|
+
if (nameIssue) throw new DataError(nameIssue);
|
|
337
|
+
const label = `${selfTable}.${name as string}`;
|
|
338
|
+
const type = input.type;
|
|
339
|
+
if (!(DATA_COLUMN_TYPES as readonly unknown[]).includes(type)) {
|
|
340
|
+
throw new DataError(
|
|
341
|
+
`${label}: "type" is one of ${DATA_COLUMN_TYPES.join(', ')}.`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
const column: ColumnDraft = {
|
|
345
|
+
name: name as string,
|
|
346
|
+
type: type as DataColumnType,
|
|
347
|
+
label: cleanText(input.label, DATA_MAX_LABEL, `${label} label`),
|
|
348
|
+
description: cleanText(
|
|
349
|
+
input.description,
|
|
350
|
+
DATA_MAX_DESCRIPTION,
|
|
351
|
+
`${label} description`
|
|
352
|
+
),
|
|
353
|
+
required: flag(input.required, `${label} required`),
|
|
354
|
+
unique: flag(input.unique, `${label} unique`),
|
|
355
|
+
filterable: flag(input.filterable, `${label} filterable`),
|
|
356
|
+
searchable: flag(input.searchable, `${label} searchable`),
|
|
357
|
+
personal: flag(input.personal, `${label} personal`),
|
|
358
|
+
values: null,
|
|
359
|
+
table: null,
|
|
360
|
+
onDelete: null,
|
|
361
|
+
min: null,
|
|
362
|
+
default: undefined
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
if (column.type === 'enum') {
|
|
366
|
+
const values = input.values;
|
|
367
|
+
if (
|
|
368
|
+
!Array.isArray(values) ||
|
|
369
|
+
values.length === 0 ||
|
|
370
|
+
values.length > DATA_MAX_ENUM_VALUES
|
|
371
|
+
) {
|
|
372
|
+
throw new DataError(
|
|
373
|
+
`${label}: an enum lists 1 to ${DATA_MAX_ENUM_VALUES} "values".`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
const seen = new Set<string>();
|
|
377
|
+
column.values = values.map(value => {
|
|
378
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 100) {
|
|
379
|
+
throw new DataError(`${label}: each value is text, 1 to 100 characters.`);
|
|
380
|
+
}
|
|
381
|
+
const key = value.trim().toLowerCase();
|
|
382
|
+
if (seen.has(key)) throw new DataError(`${label}: "${value}" is listed twice.`);
|
|
383
|
+
seen.add(key);
|
|
384
|
+
return value.trim();
|
|
385
|
+
});
|
|
386
|
+
} else if (input.values !== undefined && input.values !== null) {
|
|
387
|
+
throw new DataError(`${label}: only enum columns take "values".`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (column.type === 'relation') {
|
|
391
|
+
const target = input.table;
|
|
392
|
+
if (
|
|
393
|
+
typeof target !== 'string' ||
|
|
394
|
+
(target !== selfTable && !schema.tables.some(t => t.name === target))
|
|
395
|
+
) {
|
|
396
|
+
throw new DataError(
|
|
397
|
+
`${label}: a relation names the table it points at in "table". Tables: ${schema.tables.map(t => t.name).join(', ') || '(none)'}.`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const onDelete = input.onDelete ?? 'restrict';
|
|
401
|
+
if (!(DATA_ON_DELETE as readonly unknown[]).includes(onDelete)) {
|
|
402
|
+
throw new DataError(
|
|
403
|
+
`${label}: "onDelete" is one of ${DATA_ON_DELETE.join(', ')}.`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (onDelete === 'set_null' && column.required) {
|
|
407
|
+
throw new DataError(
|
|
408
|
+
`${label} is required, so deleting the row it points at can't clear it; use restrict or cascade.`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
column.table = target;
|
|
412
|
+
column.onDelete = onDelete as DataOnDelete;
|
|
413
|
+
} else if (input.table !== undefined && input.table !== null) {
|
|
414
|
+
throw new DataError(`${label}: only relation columns take "table".`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (input.min !== undefined && input.min !== null) {
|
|
418
|
+
if (!NUMERIC_TYPES.has(column.type)) {
|
|
419
|
+
throw new DataError(`${label}: only integer and decimal columns take "min".`);
|
|
420
|
+
}
|
|
421
|
+
if (typeof input.min !== 'number' || !Number.isFinite(input.min)) {
|
|
422
|
+
throw new DataError(`${label}: "min" is a number.`);
|
|
423
|
+
}
|
|
424
|
+
column.min = input.min;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (column.unique && !UNIQUE_TYPES.has(column.type)) {
|
|
428
|
+
throw new DataError(`${label}: a ${column.type} column can't be unique.`);
|
|
429
|
+
}
|
|
430
|
+
if (column.searchable && !SEARCHABLE_TYPES.has(column.type)) {
|
|
431
|
+
throw new DataError(
|
|
432
|
+
`${label}: only text columns can be searchable (text, long_text, email, url, phone, enum).`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
// The search table has a column of its own called rank.
|
|
436
|
+
if (column.searchable && column.name === 'rank') {
|
|
437
|
+
throw new DataError(
|
|
438
|
+
`${label}: a column named "rank" can't be searchable; rename it (e.g. "ranking").`
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (column.filterable && (column.type === 'json' || column.type === 'long_text')) {
|
|
442
|
+
throw new DataError(
|
|
443
|
+
`${label}: a ${column.type} column can't be filterable; mark it searchable instead.`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (input.default !== undefined && input.default !== null) {
|
|
448
|
+
if (column.type === 'relation' || column.type === 'file') {
|
|
449
|
+
throw new DataError(`${label}: a ${column.type} column can't have a default.`);
|
|
450
|
+
}
|
|
451
|
+
if (column.unique) {
|
|
452
|
+
throw new DataError(`${label}: a unique column can't have a default.`);
|
|
453
|
+
}
|
|
454
|
+
const fake: DataTable = {
|
|
455
|
+
id: 0,
|
|
456
|
+
name: selfTable,
|
|
457
|
+
label: null,
|
|
458
|
+
description: null,
|
|
459
|
+
displayColumn: null,
|
|
460
|
+
access: 'read',
|
|
461
|
+
customerScoped: false,
|
|
462
|
+
history: false,
|
|
463
|
+
position: 0,
|
|
464
|
+
columns: []
|
|
465
|
+
};
|
|
466
|
+
const stored = toStored(fake, { ...column, id: 0, position: 0 }, input.default);
|
|
467
|
+
if (column.min !== null && typeof stored === 'number' && stored < column.min) {
|
|
468
|
+
throw new DataError(`${label}: the default is below the minimum, ${column.min}.`);
|
|
469
|
+
}
|
|
470
|
+
column.default =
|
|
471
|
+
column.type === 'boolean'
|
|
472
|
+
? stored === 1
|
|
473
|
+
: column.type === 'json'
|
|
474
|
+
? (input.default as DataDefaultValue)
|
|
475
|
+
: (stored as DataDefaultValue);
|
|
476
|
+
}
|
|
477
|
+
return column;
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const normaliseAccess = (value: unknown, fallback: DataAccess): DataAccess => {
|
|
481
|
+
if (value === undefined || value === null) return fallback;
|
|
482
|
+
if (!(DATA_ACCESS_LEVELS as readonly unknown[]).includes(value)) {
|
|
483
|
+
throw new DataError(`"access" is one of ${DATA_ACCESS_LEVELS.join(', ')}.`);
|
|
484
|
+
}
|
|
485
|
+
return value as DataAccess;
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
// Keeping indexes, triggers and the search table in line with the metadata
|
|
489
|
+
|
|
490
|
+
const tableObjects = (runner: SqlRunner, type: string, table: string) =>
|
|
491
|
+
runner
|
|
492
|
+
.all(
|
|
493
|
+
'SELECT name, sql FROM sqlite_master WHERE type = ? AND tbl_name = ?',
|
|
494
|
+
[type, table]
|
|
495
|
+
)
|
|
496
|
+
.map(row => ({ name: String(row.name), sql: row.sql as string | null }));
|
|
497
|
+
|
|
498
|
+
const dropTriggers = (runner: SqlRunner, table: DataTable) => {
|
|
499
|
+
const prefix = triggerPrefix(table);
|
|
500
|
+
for (const row of runner.all(
|
|
501
|
+
"SELECT name FROM sqlite_master WHERE type = 'trigger' AND substr(name, 1, ?) = ?",
|
|
502
|
+
[prefix.length, prefix]
|
|
503
|
+
)) {
|
|
504
|
+
runner.all(`DROP TRIGGER IF EXISTS ${q(String(row.name))}`);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const duplicateValues = (
|
|
509
|
+
runner: SqlRunner,
|
|
510
|
+
table: DataTable,
|
|
511
|
+
column: DataColumn
|
|
512
|
+
) =>
|
|
513
|
+
runner
|
|
514
|
+
.all(
|
|
515
|
+
`SELECT ${q(column.name)} AS value, count(*) AS n FROM ${q(table.name)} WHERE ${q(column.name)} IS NOT NULL GROUP BY ${q(column.name)} HAVING count(*) > 1 ORDER BY n DESC LIMIT 10`
|
|
516
|
+
)
|
|
517
|
+
.map(row => `${JSON.stringify(row.value)} (${row.n} rows)`);
|
|
518
|
+
|
|
519
|
+
// Makes the table's indexes, triggers and search table match `table`.
|
|
520
|
+
// `previousName` is the name before a rename, whose search table goes.
|
|
521
|
+
const syncTableObjects = (
|
|
522
|
+
runner: SqlRunner,
|
|
523
|
+
table: DataTable,
|
|
524
|
+
previousName?: string
|
|
525
|
+
) => {
|
|
526
|
+
const wanted = desiredIndexes(table);
|
|
527
|
+
const wantedNames = new Set(wanted.map(i => i.name));
|
|
528
|
+
const existing = tableObjects(runner, 'index', table.name).filter(
|
|
529
|
+
i => i.name.startsWith('ix_') || i.name.startsWith('ux_')
|
|
530
|
+
);
|
|
531
|
+
for (const index of existing) {
|
|
532
|
+
if (!wantedNames.has(index.name)) {
|
|
533
|
+
runner.all(`DROP INDEX IF EXISTS ${q(index.name)}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const have = new Set(existing.map(i => i.name));
|
|
537
|
+
for (const index of wanted) {
|
|
538
|
+
if (have.has(index.name)) continue;
|
|
539
|
+
try {
|
|
540
|
+
runner.all(index.sql);
|
|
541
|
+
} catch (error) {
|
|
542
|
+
const column = table.columns.find(c => index.name.endsWith(`_${c.id}`));
|
|
543
|
+
if (column && /UNIQUE/i.test(String((error as Error)?.message))) {
|
|
544
|
+
throw new DataError(
|
|
545
|
+
`${table.name}.${column.name} can't be unique: these values repeat: ${duplicateValues(runner, table, column).join(', ')}.`,
|
|
546
|
+
'constraint'
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
dropTriggers(runner, table);
|
|
554
|
+
const wantedFts = ftsCreateSql(table);
|
|
555
|
+
const ftsName = ftsTableName(table);
|
|
556
|
+
const currentFts = first(
|
|
557
|
+
runner,
|
|
558
|
+
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
559
|
+
[ftsName]
|
|
560
|
+
);
|
|
561
|
+
if (previousName && previousName !== table.name) {
|
|
562
|
+
runner.all(`DROP TABLE IF EXISTS ${q(`${previousName}__fts`)}`);
|
|
563
|
+
}
|
|
564
|
+
if ((currentFts?.sql ?? null) !== wantedFts) {
|
|
565
|
+
runner.all(`DROP TABLE IF EXISTS ${q(ftsName)}`);
|
|
566
|
+
if (wantedFts) {
|
|
567
|
+
runner.all(wantedFts);
|
|
568
|
+
runner.all(`INSERT INTO ${q(ftsName)}(${q(ftsName)}) VALUES ('rebuild')`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
for (const trigger of desiredTriggers(table)) runner.all(trigger.sql);
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
// Errors
|
|
575
|
+
|
|
576
|
+
// SQLite's message, for the person or model that made the call.
|
|
577
|
+
export const translateSqliteError = (
|
|
578
|
+
schema: DataSchema,
|
|
579
|
+
table: DataTable,
|
|
580
|
+
error: unknown
|
|
581
|
+
): DataError => {
|
|
582
|
+
if (error instanceof DataError) return error;
|
|
583
|
+
const text = String((error as Error)?.message ?? error);
|
|
584
|
+
const columnById = (id: string) => {
|
|
585
|
+
for (const t of schema.tables) {
|
|
586
|
+
const column = t.columns.find(c => String(c.id) === id);
|
|
587
|
+
if (column) return { table: t, column };
|
|
588
|
+
}
|
|
589
|
+
return undefined;
|
|
590
|
+
};
|
|
591
|
+
let m = /NOT NULL constraint failed: [\w"]+\.(\w+)/.exec(text);
|
|
592
|
+
if (m) return new DataError(`${table.name}.${m[1]} is required.`, 'constraint');
|
|
593
|
+
m = /UNIQUE constraint failed: [\w"]+\.(\w+)/.exec(text);
|
|
594
|
+
if (m) {
|
|
595
|
+
return new DataError(
|
|
596
|
+
`Another row in ${table.name} already has this ${m[1]}.`,
|
|
597
|
+
'constraint'
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
m = /CHECK constraint failed: min_(\d+)/.exec(text);
|
|
601
|
+
if (m) {
|
|
602
|
+
const found = columnById(m[1]);
|
|
603
|
+
if (found) {
|
|
604
|
+
return new DataError(
|
|
605
|
+
`${found.table.name}.${found.column.name} can't go below ${found.column.min ?? 0}.`,
|
|
606
|
+
'constraint'
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
m = /CHECK constraint failed: chk_(\d+)/.exec(text);
|
|
611
|
+
if (m) {
|
|
612
|
+
const found = columnById(m[1]);
|
|
613
|
+
if (found) {
|
|
614
|
+
return new DataError(
|
|
615
|
+
`Invalid value for ${found.table.name}.${found.column.name} (${found.column.type}).`,
|
|
616
|
+
'constraint'
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (/FOREIGN KEY constraint failed/.test(text)) {
|
|
621
|
+
return new DataError(
|
|
622
|
+
`A relation in ${table.name} points at a row that doesn't exist, or other rows still point at this one.`,
|
|
623
|
+
'constraint'
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (/SQLITE_FULL|database or disk is full/i.test(text)) {
|
|
627
|
+
return new DataError(
|
|
628
|
+
'The database is full: delete or archive rows to make room.',
|
|
629
|
+
'full'
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
if (/too many SQL variables/i.test(text)) {
|
|
633
|
+
return new DataError('This call uses too many values at once.', 'too_large');
|
|
634
|
+
}
|
|
635
|
+
// Never SQLite's own text: it names our internal tables and constraints.
|
|
636
|
+
console.error('data-sqlite-error', text);
|
|
637
|
+
return new DataError(
|
|
638
|
+
`The database refused this change to ${table.name}; check the values against the table's columns.`,
|
|
639
|
+
'invalid'
|
|
640
|
+
);
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
// Rows out
|
|
644
|
+
|
|
645
|
+
const estimateRows = (runner: SqlRunner, table: DataTable): number =>
|
|
646
|
+
Number(first(runner, `SELECT max(id) AS n FROM ${q(table.name)}`)?.n ?? 0);
|
|
647
|
+
|
|
648
|
+
const assertBounded = (
|
|
649
|
+
runner: SqlRunner,
|
|
650
|
+
table: DataTable,
|
|
651
|
+
what: string,
|
|
652
|
+
column?: string
|
|
653
|
+
) => {
|
|
654
|
+
const rows = estimateRows(runner, table);
|
|
655
|
+
if (rows <= DATA_LARGE_TABLE_ROWS) return;
|
|
656
|
+
throw new DataError(
|
|
657
|
+
`${table.name} has about ${rows.toLocaleString('en-US')} rows, so ${what} needs a filter on an indexed column (id, or a column marked filterable, unique or a relation)${
|
|
658
|
+
column ? `, or "${column}" marked filterable so it is indexed` : ''
|
|
659
|
+
}.`,
|
|
660
|
+
'too_large',
|
|
661
|
+
{ suggestIndex: column ?? null }
|
|
662
|
+
);
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const relationLabels = (schema: DataSchema, table: DataTable) =>
|
|
666
|
+
table.columns
|
|
667
|
+
.filter(column => column.type === 'relation' && column.table)
|
|
668
|
+
.map(column => {
|
|
669
|
+
const target = schema.tables.find(t => t.name === column.table);
|
|
670
|
+
return { column, target, display: target?.displayColumn ?? 'id' };
|
|
671
|
+
})
|
|
672
|
+
.filter(
|
|
673
|
+
(
|
|
674
|
+
x
|
|
675
|
+
): x is { column: DataColumn; target: DataTable; display: string } =>
|
|
676
|
+
Boolean(x.target)
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
// The columns a row comes back with, plus each relation's label read from
|
|
680
|
+
// the related table as the caller may see it: none from a table hidden from
|
|
681
|
+
// it, and only its own customer's rows from a scoped one.
|
|
682
|
+
const selectList = (
|
|
683
|
+
schema: DataSchema,
|
|
684
|
+
caller: DataCaller,
|
|
685
|
+
table: DataTable,
|
|
686
|
+
joins: Map<string, SqlJoin>
|
|
687
|
+
) => {
|
|
688
|
+
const list = ['t.id', 't.created_at', 't.updated_at', 't.version'];
|
|
689
|
+
for (const column of table.columns) list.push(`t.${q(column.name)}`);
|
|
690
|
+
for (const { column, target, display } of relationLabels(schema, table)) {
|
|
691
|
+
const label = q(`__label_${column.name}`);
|
|
692
|
+
if (!canRead(caller, target)) {
|
|
693
|
+
list.push(`NULL AS ${label}`);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
const alias = `l_${column.name}`;
|
|
697
|
+
joins.set(alias, relationJoin(caller, target, alias, `t.${q(column.name)}`));
|
|
698
|
+
list.push(`${alias}.${q(display)} AS ${label}`);
|
|
699
|
+
}
|
|
700
|
+
return list;
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
type ShapedRow = Record<string, unknown> & { id: number };
|
|
704
|
+
|
|
705
|
+
// `version` comes back to the dashboard, the CLI and the owner's sessions,
|
|
706
|
+
// for edits that must not overwrite someone else's; never to a bot.
|
|
707
|
+
const shapeRow = (
|
|
708
|
+
schema: DataSchema,
|
|
709
|
+
table: DataTable,
|
|
710
|
+
raw: SqlRow,
|
|
711
|
+
caller: DataCaller
|
|
712
|
+
): ShapedRow => {
|
|
713
|
+
const timeZone = caller.timeZone;
|
|
714
|
+
const row: ShapedRow = { id: Number(raw.id) };
|
|
715
|
+
for (const column of table.columns) {
|
|
716
|
+
const value = fromStored(column, raw[column.name], timeZone);
|
|
717
|
+
if (column.type === 'relation') {
|
|
718
|
+
const target = schema.tables.find(t => t.name === column.table);
|
|
719
|
+
const display = target
|
|
720
|
+
? columnFor(target, target.displayColumn ?? 'id')
|
|
721
|
+
: undefined;
|
|
722
|
+
const label = raw[`__label_${column.name}`] ?? null;
|
|
723
|
+
row[column.name] =
|
|
724
|
+
value === null
|
|
725
|
+
? null
|
|
726
|
+
: {
|
|
727
|
+
id: value,
|
|
728
|
+
label:
|
|
729
|
+
display && label !== null
|
|
730
|
+
? fromStored(display, label, timeZone)
|
|
731
|
+
: label
|
|
732
|
+
};
|
|
733
|
+
} else {
|
|
734
|
+
row[column.name] = value;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
row.created_at = zoned(String(raw.created_at), timeZone);
|
|
738
|
+
row.updated_at = zoned(String(raw.updated_at), timeZone);
|
|
739
|
+
if (!caller.restricted && raw.version !== undefined) {
|
|
740
|
+
row.version = Number(raw.version);
|
|
741
|
+
}
|
|
742
|
+
return row;
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const SCOPE_NOTE =
|
|
746
|
+
"only this customer's own rows; other customers' rows are not visible";
|
|
747
|
+
|
|
748
|
+
// describe
|
|
749
|
+
|
|
750
|
+
export const describe = (
|
|
751
|
+
runner: SqlRunner,
|
|
752
|
+
schema: DataSchema,
|
|
753
|
+
caller: DataCaller,
|
|
754
|
+
rawArgs: unknown = {}
|
|
755
|
+
) => {
|
|
756
|
+
const args = tidyArgs(rawArgs);
|
|
757
|
+
const tables =
|
|
758
|
+
args.table !== undefined && args.table !== null
|
|
759
|
+
? [tableFor(schema, caller, args.table)]
|
|
760
|
+
: visibleTables(schema, caller);
|
|
761
|
+
return {
|
|
762
|
+
tables: tables.map(table => {
|
|
763
|
+
// A scoped bot counts only its customer's rows (served by the
|
|
764
|
+
// participant index), never the table's size.
|
|
765
|
+
const scoped = isScoped(caller, table);
|
|
766
|
+
const estimate = scoped ? 0 : estimateRows(runner, table);
|
|
767
|
+
const rows = scoped
|
|
768
|
+
? Number(
|
|
769
|
+
first(
|
|
770
|
+
runner,
|
|
771
|
+
`SELECT count(*) AS n FROM ${q(table.name)} WHERE participant_id = ?`,
|
|
772
|
+
[participantOf(caller)]
|
|
773
|
+
)?.n ?? 0
|
|
774
|
+
)
|
|
775
|
+
: estimate <= DATA_LARGE_TABLE_ROWS
|
|
776
|
+
? Number(
|
|
777
|
+
first(runner, `SELECT count(*) AS n FROM ${q(table.name)}`)?.n ?? 0
|
|
778
|
+
)
|
|
779
|
+
: undefined;
|
|
780
|
+
return {
|
|
781
|
+
name: table.name,
|
|
782
|
+
...(table.label ? { label: table.label } : {}),
|
|
783
|
+
description: table.description,
|
|
784
|
+
access: accessOf(caller, table),
|
|
785
|
+
...(isScoped(caller, table) ? { onlyThisCustomersRows: true } : {}),
|
|
786
|
+
...(rows === undefined ? { rowsAbout: estimate } : { rows }),
|
|
787
|
+
displayColumn: table.displayColumn ?? 'id',
|
|
788
|
+
searchable: searchableColumns(table).map(c => c.name),
|
|
789
|
+
columns: [
|
|
790
|
+
{ name: 'id', type: 'integer' },
|
|
791
|
+
...table.columns.map(column => ({
|
|
792
|
+
name: column.name,
|
|
793
|
+
type: column.type,
|
|
794
|
+
...(column.label ? { label: column.label } : {}),
|
|
795
|
+
...(column.required ? { required: true } : {}),
|
|
796
|
+
...(column.unique ? { unique: true } : {}),
|
|
797
|
+
...(column.values ? { values: column.values } : {}),
|
|
798
|
+
...(column.type === 'relation' ? { references: column.table } : {}),
|
|
799
|
+
...(column.default !== undefined ? { default: column.default } : {}),
|
|
800
|
+
...(column.min !== null ? { min: column.min } : {}),
|
|
801
|
+
...(column.description ? { description: column.description } : {})
|
|
802
|
+
})),
|
|
803
|
+
{ name: 'created_at', type: 'datetime' },
|
|
804
|
+
{ name: 'updated_at', type: 'datetime' }
|
|
805
|
+
],
|
|
806
|
+
referencedBy: visibleTables(schema, caller).flatMap(other =>
|
|
807
|
+
other.columns
|
|
808
|
+
.filter(c => c.type === 'relation' && c.table === table.name)
|
|
809
|
+
.map(c => `${other.name}.${c.name}`)
|
|
810
|
+
)
|
|
811
|
+
};
|
|
812
|
+
})
|
|
813
|
+
};
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
// query
|
|
817
|
+
|
|
818
|
+
const searchJoinSql = (table: DataTable) => {
|
|
819
|
+
const fts = q(ftsTableName(table));
|
|
820
|
+
return `JOIN (SELECT rowid AS _r, bm25(${fts}) AS _rank FROM ${fts} WHERE ${fts} MATCH ?) s ON s._r = t.id`;
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
export const query = (
|
|
824
|
+
runner: SqlRunner,
|
|
825
|
+
schema: DataSchema,
|
|
826
|
+
caller: DataCaller,
|
|
827
|
+
rawArgs: unknown
|
|
828
|
+
) => {
|
|
829
|
+
if (!isPlainObject(rawArgs)) throw new DataError('Arguments are an object.');
|
|
830
|
+
const args = tidyArgs(rawArgs);
|
|
831
|
+
const table = tableFor(schema, caller, args.table);
|
|
832
|
+
const ctx: CompileContext = {
|
|
833
|
+
schema,
|
|
834
|
+
caller,
|
|
835
|
+
table,
|
|
836
|
+
joins: new Map(),
|
|
837
|
+
params: []
|
|
838
|
+
};
|
|
839
|
+
const indexed = new Set<string>();
|
|
840
|
+
const where = compileWhere(ctx, args.where, indexed);
|
|
841
|
+
if (isScoped(caller, table)) {
|
|
842
|
+
where.push('t.participant_id = ?');
|
|
843
|
+
ctx.params.push(participantOf(caller));
|
|
844
|
+
}
|
|
845
|
+
const searching =
|
|
846
|
+
args.search !== undefined && args.search !== null && args.search !== '';
|
|
847
|
+
if (searching && searchableColumns(table).length === 0) {
|
|
848
|
+
throw new DataError(
|
|
849
|
+
`"${table.name}" has no searchable columns; filter with "where" instead (e.g. { "name": { "contains": "..." } }).`
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (args.aggregate !== undefined && args.aggregate !== null) {
|
|
854
|
+
return aggregate(runner, ctx, args, where, searching, indexed.size > 0);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const limit = args.limit === undefined || args.limit === null ? DATA_DEFAULT_PAGE : args.limit;
|
|
858
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > DATA_MAX_PAGE) {
|
|
859
|
+
throw new DataError(`"limit" is 1 to ${DATA_MAX_PAGE}.`);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const sortList =
|
|
863
|
+
args.sort === undefined || args.sort === null
|
|
864
|
+
? []
|
|
865
|
+
: Array.isArray(args.sort)
|
|
866
|
+
? args.sort
|
|
867
|
+
: [args.sort];
|
|
868
|
+
if (sortList.length > DATA_MAX_SORT_KEYS) {
|
|
869
|
+
throw new DataError(`"sort" takes up to ${DATA_MAX_SORT_KEYS} columns.`);
|
|
870
|
+
}
|
|
871
|
+
const keys: (SortKey & { unindexed?: string })[] = [];
|
|
872
|
+
let endsUnique = false;
|
|
873
|
+
for (const entry of sortList) {
|
|
874
|
+
if (typeof entry !== 'string' || entry === '' || entry === '-') {
|
|
875
|
+
throw new DataError(
|
|
876
|
+
'"sort" is a list of column names; prefix with "-" for descending, e.g. ["-created_at"].'
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
const descending = entry.startsWith('-');
|
|
880
|
+
const resolved = resolvePath(ctx, descending ? entry.slice(1) : entry);
|
|
881
|
+
if (resolved.column.type === 'json') {
|
|
882
|
+
throw new DataError(`${resolved.column.name} is json and can't be sorted.`);
|
|
883
|
+
}
|
|
884
|
+
const servedByIndex =
|
|
885
|
+
resolved.own &&
|
|
886
|
+
(resolved.column.name === 'id' ||
|
|
887
|
+
(!isSystemColumn(resolved.column) && isIndexedColumn(resolved.column)));
|
|
888
|
+
keys.push({
|
|
889
|
+
sql: resolved.sql,
|
|
890
|
+
descending,
|
|
891
|
+
...(servedByIndex ? {} : { unindexed: resolved.column.name })
|
|
892
|
+
});
|
|
893
|
+
if (resolved.own && resolved.column.name === 'id') {
|
|
894
|
+
endsUnique = true;
|
|
895
|
+
break;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (keys.length === 0 && searching) keys.push({ sql: 's._rank', descending: false });
|
|
899
|
+
if (!endsUnique) keys.push({ sql: 't.id', descending: false });
|
|
900
|
+
|
|
901
|
+
const unindexedSort = keys.find(k => k.unindexed);
|
|
902
|
+
if (unindexedSort && indexed.size === 0 && !searching) {
|
|
903
|
+
assertBounded(runner, table, 'sorting it', unindexedSort.unindexed);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const signature = JSON.stringify([
|
|
907
|
+
table.name,
|
|
908
|
+
keys.map(k => `${k.descending ? '-' : ''}${k.sql}`)
|
|
909
|
+
]);
|
|
910
|
+
const cursor =
|
|
911
|
+
args.cursor !== undefined && args.cursor !== null && args.cursor !== ''
|
|
912
|
+
? decodeCursor(args.cursor, signature)
|
|
913
|
+
: null;
|
|
914
|
+
if (cursor && cursor.v.length !== keys.length) {
|
|
915
|
+
throw new DataError('"cursor" is not one this query returned.');
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const labelJoins = new Map<string, SqlJoin>();
|
|
919
|
+
const select = selectList(schema, caller, table, labelJoins);
|
|
920
|
+
keys.forEach((key, i) => select.push(`${key.sql} AS ${q(`__s${i}`)}`));
|
|
921
|
+
|
|
922
|
+
// Rows are read one at a time and the page stops at 1 MB, so a page of
|
|
923
|
+
// large rows is never held in memory whole.
|
|
924
|
+
const run = (match: string | null) => {
|
|
925
|
+
const fromParams: SqlValue[] = match ? [match] : [];
|
|
926
|
+
const searchJoin = match ? searchJoinSql(table) : '';
|
|
927
|
+
const conditions = [...where];
|
|
928
|
+
const countSql = `SELECT count(*) AS n FROM ${q(table.name)} t ${searchJoin} ${joinsSql(ctx.joins)} ${
|
|
929
|
+
conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
930
|
+
}`;
|
|
931
|
+
const pageParams: SqlValue[] = [];
|
|
932
|
+
if (cursor) conditions.push(keysetCondition(keys, cursor.v, pageParams));
|
|
933
|
+
const joins = new Map([...ctx.joins, ...labelJoins]);
|
|
934
|
+
const rowsSql = `SELECT ${select.join(', ')} FROM ${q(table.name)} t ${searchJoin} ${joinsSql(joins)} ${
|
|
935
|
+
conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
936
|
+
} ORDER BY ${keys.map(k => `${k.sql} ${k.descending ? 'DESC' : 'ASC'}`).join(', ')} LIMIT ?`;
|
|
937
|
+
const params = [
|
|
938
|
+
...fromParams,
|
|
939
|
+
...joinsParams(joins),
|
|
940
|
+
...ctx.params,
|
|
941
|
+
...pageParams,
|
|
942
|
+
limit + 1
|
|
943
|
+
];
|
|
944
|
+
assertParamCount(params);
|
|
945
|
+
const rows: ShapedRow[] = [];
|
|
946
|
+
let bytes = 0;
|
|
947
|
+
let last: SqlRow | undefined;
|
|
948
|
+
let more = false;
|
|
949
|
+
for (const raw of runner.iterate(rowsSql, params)) {
|
|
950
|
+
if (rows.length === limit) {
|
|
951
|
+
more = true;
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
const row = shapeRow(schema, table, raw, caller);
|
|
955
|
+
const size = JSON.stringify(row).length;
|
|
956
|
+
if (rows.length > 0 && bytes + size > DATA_MAX_RESULT_BYTES) {
|
|
957
|
+
more = true;
|
|
958
|
+
break;
|
|
959
|
+
}
|
|
960
|
+
bytes += size;
|
|
961
|
+
rows.push(row);
|
|
962
|
+
last = raw;
|
|
963
|
+
}
|
|
964
|
+
const countParams = [...fromParams, ...joinsParams(ctx.joins), ...ctx.params];
|
|
965
|
+
const total = cursor
|
|
966
|
+
? undefined
|
|
967
|
+
: Number(first(runner, countSql, countParams)?.n ?? 0);
|
|
968
|
+
return { rows, bytes, last, more, total };
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
let matched: 'all words' | 'any word' | undefined;
|
|
972
|
+
let result: ReturnType<typeof run>;
|
|
973
|
+
try {
|
|
974
|
+
if (searching) {
|
|
975
|
+
const any = cursor?.m === 'any';
|
|
976
|
+
result = run(ftsMatch(args.search, any));
|
|
977
|
+
matched = any ? 'any word' : 'all words';
|
|
978
|
+
if (!cursor && result.rows.length === 0) {
|
|
979
|
+
result = run(ftsMatch(args.search, true));
|
|
980
|
+
matched = 'any word';
|
|
981
|
+
}
|
|
982
|
+
} else {
|
|
983
|
+
result = run(null);
|
|
984
|
+
}
|
|
985
|
+
} catch (error) {
|
|
986
|
+
throw translateSqliteError(schema, table, error);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
const { rows: out, last, more } = result;
|
|
990
|
+
const includeTruncated =
|
|
991
|
+
args.include !== undefined && args.include !== null
|
|
992
|
+
? includeRelations(
|
|
993
|
+
runner,
|
|
994
|
+
schema,
|
|
995
|
+
caller,
|
|
996
|
+
table,
|
|
997
|
+
out,
|
|
998
|
+
args.include,
|
|
999
|
+
DATA_MAX_RESULT_BYTES - result.bytes
|
|
1000
|
+
)
|
|
1001
|
+
: false;
|
|
1002
|
+
return {
|
|
1003
|
+
table: table.name,
|
|
1004
|
+
...(isScoped(caller, table) ? { scope: SCOPE_NOTE } : {}),
|
|
1005
|
+
...(result.total !== undefined ? { total: result.total } : {}),
|
|
1006
|
+
...(matched ? { searchMatched: matched } : {}),
|
|
1007
|
+
rows: out,
|
|
1008
|
+
...(includeTruncated
|
|
1009
|
+
? {
|
|
1010
|
+
includeTruncated:
|
|
1011
|
+
'some related rows were left as { id, label } to keep the result under 1 MB; ask for fewer rows'
|
|
1012
|
+
}
|
|
1013
|
+
: {}),
|
|
1014
|
+
...(more && last
|
|
1015
|
+
? {
|
|
1016
|
+
nextCursor: encodeCursor({
|
|
1017
|
+
k: signature,
|
|
1018
|
+
v: keys.map((_, i) => last[`__s${i}`] ?? null),
|
|
1019
|
+
...(matched === 'any word' ? { m: 'any' as const } : {})
|
|
1020
|
+
})
|
|
1021
|
+
}
|
|
1022
|
+
: {})
|
|
1023
|
+
};
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
// Replaces each relation in `rows` named in `include` with the related row,
|
|
1027
|
+
// read as the caller may see it, within `budget` bytes. Returns whether any
|
|
1028
|
+
// was left as { id, label } to stay inside it.
|
|
1029
|
+
const includeRelations = (
|
|
1030
|
+
runner: SqlRunner,
|
|
1031
|
+
schema: DataSchema,
|
|
1032
|
+
caller: DataCaller,
|
|
1033
|
+
table: DataTable,
|
|
1034
|
+
rows: ShapedRow[],
|
|
1035
|
+
include: unknown,
|
|
1036
|
+
budget: number
|
|
1037
|
+
): boolean => {
|
|
1038
|
+
if (!Array.isArray(include)) {
|
|
1039
|
+
throw new DataError('"include" is a list of relation columns, e.g. ["customer"].');
|
|
1040
|
+
}
|
|
1041
|
+
let truncated = false;
|
|
1042
|
+
for (const name of include) {
|
|
1043
|
+
const column = typeof name === 'string' ? columnFor(table, name) : undefined;
|
|
1044
|
+
if (!column) throw unknownColumn(table, String(name));
|
|
1045
|
+
if (isSystemColumn(column) || column.type !== 'relation' || !column.table) {
|
|
1046
|
+
throw new DataError(
|
|
1047
|
+
`"${name}" is not a relation column; include takes relation columns.`
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
const target = tableFor(schema, caller, column.table);
|
|
1051
|
+
const ids = [
|
|
1052
|
+
...new Set(
|
|
1053
|
+
rows
|
|
1054
|
+
.map(r => (r[name] as { id?: number } | null)?.id)
|
|
1055
|
+
.filter((id): id is number => typeof id === 'number')
|
|
1056
|
+
)
|
|
1057
|
+
];
|
|
1058
|
+
if (ids.length === 0) continue;
|
|
1059
|
+
const scoped = isScoped(caller, target);
|
|
1060
|
+
const joins = new Map<string, SqlJoin>();
|
|
1061
|
+
const select = selectList(schema, caller, target, joins);
|
|
1062
|
+
const fixed = [...joinsParams(joins), ...(scoped ? [participantOf(caller)] : [])];
|
|
1063
|
+
// Up to 500 ids, so they go in pieces that each stay under 100 values.
|
|
1064
|
+
const size = DATA_MAX_PARAMS - fixed.length;
|
|
1065
|
+
const byId = new Map<number, ShapedRow>();
|
|
1066
|
+
let spent = 0;
|
|
1067
|
+
fetch: for (let i = 0; i < ids.length; i += size) {
|
|
1068
|
+
const slice = ids.slice(i, i + size);
|
|
1069
|
+
const params = [...joinsParams(joins), ...slice, ...(scoped ? [participantOf(caller)] : [])];
|
|
1070
|
+
assertParamCount(params);
|
|
1071
|
+
for (const raw of runner.iterate(
|
|
1072
|
+
`SELECT ${select.join(', ')} FROM ${q(target.name)} t ${joinsSql(joins)} WHERE t.id IN (${slice
|
|
1073
|
+
.map(() => '?')
|
|
1074
|
+
.join(', ')})${scoped ? ' AND t.participant_id = ?' : ''}`,
|
|
1075
|
+
params
|
|
1076
|
+
)) {
|
|
1077
|
+
const related = shapeRow(schema, target, raw, caller);
|
|
1078
|
+
spent += JSON.stringify(related).length;
|
|
1079
|
+
if (spent > budget) {
|
|
1080
|
+
truncated = true;
|
|
1081
|
+
break fetch;
|
|
1082
|
+
}
|
|
1083
|
+
byId.set(related.id, related);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
for (const row of rows) {
|
|
1087
|
+
const ref = row[name] as { id?: number } | null;
|
|
1088
|
+
if (ref?.id === undefined) continue;
|
|
1089
|
+
const related = byId.get(ref.id);
|
|
1090
|
+
if (!related) continue;
|
|
1091
|
+
const cost = JSON.stringify(related).length;
|
|
1092
|
+
if (cost > budget) {
|
|
1093
|
+
truncated = true;
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
budget -= cost;
|
|
1097
|
+
row[name] = related;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return truncated;
|
|
1101
|
+
};
|
|
1102
|
+
|
|
1103
|
+
const AGGREGATE_FUNCTIONS = ['count', 'sum', 'avg', 'min', 'max'] as const;
|
|
1104
|
+
|
|
1105
|
+
const aggregate = (
|
|
1106
|
+
runner: SqlRunner,
|
|
1107
|
+
ctx: CompileContext,
|
|
1108
|
+
args: Record<string, unknown>,
|
|
1109
|
+
where: string[],
|
|
1110
|
+
searching: boolean,
|
|
1111
|
+
indexedFilter: boolean
|
|
1112
|
+
) => {
|
|
1113
|
+
const { schema, caller, table } = ctx;
|
|
1114
|
+
const list = Array.isArray(args.aggregate) ? args.aggregate : [args.aggregate];
|
|
1115
|
+
if (list.length === 0 || list.length > DATA_MAX_AGGREGATES) {
|
|
1116
|
+
throw new DataError(
|
|
1117
|
+
`"aggregate" is a list of 1 to ${DATA_MAX_AGGREGATES} { "fn": ..., "column": ... }.`
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
const selects: string[] = [];
|
|
1121
|
+
const names: string[] = [];
|
|
1122
|
+
for (const item of list) {
|
|
1123
|
+
if (
|
|
1124
|
+
!isPlainObject(item) ||
|
|
1125
|
+
!(AGGREGATE_FUNCTIONS as readonly unknown[]).includes(item.fn)
|
|
1126
|
+
) {
|
|
1127
|
+
throw new DataError(
|
|
1128
|
+
`Each aggregate is { "fn": one of ${AGGREGATE_FUNCTIONS.join(', ')}, "column": ... }.`
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
const fn = item.fn as string;
|
|
1132
|
+
if (fn === 'count' && !item.column) {
|
|
1133
|
+
selects.push('count(*)');
|
|
1134
|
+
names.push('count');
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
if (typeof item.column !== 'string') {
|
|
1138
|
+
throw new DataError(`"${fn}" needs a "column".`);
|
|
1139
|
+
}
|
|
1140
|
+
const resolved = resolvePath(ctx, item.column);
|
|
1141
|
+
if (
|
|
1142
|
+
fn !== 'count' &&
|
|
1143
|
+
fn !== 'min' &&
|
|
1144
|
+
fn !== 'max' &&
|
|
1145
|
+
!NUMERIC_TYPES.has(resolved.column.type)
|
|
1146
|
+
) {
|
|
1147
|
+
throw new DataError(
|
|
1148
|
+
`${fn} needs a number column; "${item.column}" is ${resolved.column.type}.`
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
// { "fn": "sum", "column": "stock", "times": "unit_cost" }: the sum of a
|
|
1152
|
+
// product, e.g. stock value, which rows alone can't give in one call.
|
|
1153
|
+
if (item.times !== undefined && item.times !== null) {
|
|
1154
|
+
const other = resolvePath(ctx, item.times);
|
|
1155
|
+
if (
|
|
1156
|
+
!NUMERIC_TYPES.has(resolved.column.type) ||
|
|
1157
|
+
!NUMERIC_TYPES.has(other.column.type)
|
|
1158
|
+
) {
|
|
1159
|
+
throw new DataError('"times" multiplies two number columns.');
|
|
1160
|
+
}
|
|
1161
|
+
selects.push(`${fn}(${resolved.sql} * ${other.sql})`);
|
|
1162
|
+
names.push(
|
|
1163
|
+
`${fn}_${item.column.replace('.', '_')}_times_${String(item.times).replace('.', '_')}`
|
|
1164
|
+
);
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
selects.push(`${fn}(${resolved.sql})`);
|
|
1168
|
+
names.push(`${fn}_${item.column.replace('.', '_')}`);
|
|
1169
|
+
}
|
|
1170
|
+
const groupBy =
|
|
1171
|
+
args.groupBy === undefined || args.groupBy === null
|
|
1172
|
+
? []
|
|
1173
|
+
: Array.isArray(args.groupBy)
|
|
1174
|
+
? args.groupBy
|
|
1175
|
+
: [args.groupBy];
|
|
1176
|
+
if (groupBy.length > DATA_MAX_GROUP_BY) {
|
|
1177
|
+
throw new DataError(`"groupBy" takes up to ${DATA_MAX_GROUP_BY} columns.`);
|
|
1178
|
+
}
|
|
1179
|
+
const groups = groupBy.map(path => ({
|
|
1180
|
+
path: String(path),
|
|
1181
|
+
...resolvePath(ctx, path)
|
|
1182
|
+
}));
|
|
1183
|
+
if (!indexedFilter && !searching) {
|
|
1184
|
+
assertBounded(
|
|
1185
|
+
runner,
|
|
1186
|
+
table,
|
|
1187
|
+
'an aggregate over it',
|
|
1188
|
+
groups.find(g => g.own && !isSystemColumn(g.column))?.column.name
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
const groupSelects: string[] = [];
|
|
1192
|
+
groups.forEach((g, i) => {
|
|
1193
|
+
groupSelects.push(`${g.sql} AS ${q(`__g${i}`)}`);
|
|
1194
|
+
if (g.column.type === 'relation' && !isSystemColumn(g.column) && g.column.table) {
|
|
1195
|
+
const target = schema.tables.find(t => t.name === (g.column as DataColumn).table);
|
|
1196
|
+
if (target && canRead(caller, target)) {
|
|
1197
|
+
const alias = `g_${i}`;
|
|
1198
|
+
ctx.joins.set(alias, relationJoin(caller, target, alias, g.sql));
|
|
1199
|
+
groupSelects.push(
|
|
1200
|
+
`${alias}.${q(target.displayColumn ?? 'id')} AS ${q(`__gl${i}`)}`
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
const match = searching ? ftsMatch(args.search, false) : null;
|
|
1206
|
+
const from = `FROM ${q(table.name)} t ${match ? searchJoinSql(table) : ''} ${joinsSql(ctx.joins)}`;
|
|
1207
|
+
const params = [...(match ? [match] : []), ...joinsParams(ctx.joins), ...ctx.params];
|
|
1208
|
+
assertParamCount(params);
|
|
1209
|
+
const sql = `SELECT ${[
|
|
1210
|
+
...groupSelects,
|
|
1211
|
+
...selects.map((s, i) => `${s} AS ${q(`__a${i}`)}`)
|
|
1212
|
+
].join(', ')} ${from} ${where.length ? `WHERE ${where.join(' AND ')}` : ''}${
|
|
1213
|
+
groups.length
|
|
1214
|
+
? ` GROUP BY ${groups.map((_, i) => q(`__g${i}`)).join(', ')} ORDER BY ${q('__a0')} DESC`
|
|
1215
|
+
: ''
|
|
1216
|
+
} LIMIT ${DATA_MAX_GROUPS + 1}`;
|
|
1217
|
+
let raw: SqlRow[];
|
|
1218
|
+
try {
|
|
1219
|
+
raw = runner.all(sql, params);
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
throw translateSqliteError(schema, table, error);
|
|
1222
|
+
}
|
|
1223
|
+
const truncated = raw.length > DATA_MAX_GROUPS;
|
|
1224
|
+
const rows = raw.slice(0, DATA_MAX_GROUPS).map(r => {
|
|
1225
|
+
const out: Record<string, unknown> = {};
|
|
1226
|
+
groups.forEach((g, i) => {
|
|
1227
|
+
const value = fromStored(g.column, r[`__g${i}`], caller.timeZone);
|
|
1228
|
+
out[g.path] =
|
|
1229
|
+
g.column.type === 'relation' && value !== null
|
|
1230
|
+
? { id: value, label: r[`__gl${i}`] ?? null }
|
|
1231
|
+
: value;
|
|
1232
|
+
});
|
|
1233
|
+
names.forEach((name, i) => {
|
|
1234
|
+
const value = r[`__a${i}`];
|
|
1235
|
+
out[name] =
|
|
1236
|
+
typeof value === 'number' ? Math.round(value * 100) / 100 : value;
|
|
1237
|
+
});
|
|
1238
|
+
return out;
|
|
1239
|
+
});
|
|
1240
|
+
return {
|
|
1241
|
+
table: table.name,
|
|
1242
|
+
...(isScoped(caller, table) ? { scope: SCOPE_NOTE } : {}),
|
|
1243
|
+
...(groups.length ? { groups: rows } : (rows[0] ?? {})),
|
|
1244
|
+
...(truncated
|
|
1245
|
+
? {
|
|
1246
|
+
groupsTruncated: `only the ${DATA_MAX_GROUPS} largest groups by the first aggregate; filter to see the rest`
|
|
1247
|
+
}
|
|
1248
|
+
: {})
|
|
1249
|
+
};
|
|
1250
|
+
};
|
|
1251
|
+
|
|
1252
|
+
// write
|
|
1253
|
+
|
|
1254
|
+
const ACTOR_PATTERN =
|
|
1255
|
+
/^(user|cli|mcp|channel|function|trigger|system):[^\s]{1,200}$/;
|
|
1256
|
+
|
|
1257
|
+
export const assertActor = (caller: DataCaller) => {
|
|
1258
|
+
if (typeof caller?.actor !== 'string' || !ACTOR_PATTERN.test(caller.actor)) {
|
|
1259
|
+
throw new DataError(
|
|
1260
|
+
'A write needs an actor (user:, cli:, mcp:, channel:, function:, trigger: or system: followed by an id).',
|
|
1261
|
+
'forbidden'
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
};
|
|
1265
|
+
|
|
1266
|
+
export const assertWritable = (
|
|
1267
|
+
runner: SqlRunner,
|
|
1268
|
+
caller: DataCaller,
|
|
1269
|
+
addsData: boolean
|
|
1270
|
+
) => {
|
|
1271
|
+
assertActor(caller);
|
|
1272
|
+
if (caller.limits?.readOnly) {
|
|
1273
|
+
throw new DataError(
|
|
1274
|
+
"This organization's plan no longer includes Data, so the database is read-only. Upgrade to Pro to change it.",
|
|
1275
|
+
'read_only'
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
const max = caller.limits?.maxBytes;
|
|
1279
|
+
if (addsData && typeof max === 'number' && runner.databaseSize() >= max) {
|
|
1280
|
+
throw new DataError(
|
|
1281
|
+
'The database is full: delete or archive rows to make room. Reads, deletes and exports still work.',
|
|
1282
|
+
'full',
|
|
1283
|
+
{ size: runner.databaseSize(), limit: max }
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
};
|
|
1287
|
+
|
|
1288
|
+
// Every write names its actor here first, so row history (kept by triggers)
|
|
1289
|
+
// records who made each change, cascades included.
|
|
1290
|
+
const setContextActor = (runner: SqlRunner, actor: string) => {
|
|
1291
|
+
runner.all(
|
|
1292
|
+
'INSERT INTO _ganju_context (id, actor) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET actor = excluded.actor WHERE actor IS NOT excluded.actor',
|
|
1293
|
+
[actor]
|
|
1294
|
+
);
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
type WriteResult =
|
|
1298
|
+
| { op: 'insert'; table: string; id: number }
|
|
1299
|
+
| { op: 'insert'; table: string; ids: number[] }
|
|
1300
|
+
| { op: 'update' | 'delete'; table: string; changed: number };
|
|
1301
|
+
|
|
1302
|
+
const resolveRefs = (
|
|
1303
|
+
value: unknown,
|
|
1304
|
+
results: readonly WriteResult[],
|
|
1305
|
+
index: number
|
|
1306
|
+
): unknown => {
|
|
1307
|
+
if (isPlainObject(value) && Object.keys(value).length === 1 && '$ref' in value) {
|
|
1308
|
+
const ref = value.$ref;
|
|
1309
|
+
const target =
|
|
1310
|
+
typeof ref === 'number' && Number.isInteger(ref) && ref >= 0 && ref < index
|
|
1311
|
+
? results[ref]
|
|
1312
|
+
: undefined;
|
|
1313
|
+
if (!target || target.op !== 'insert' || !('id' in target)) {
|
|
1314
|
+
throw new DataError(
|
|
1315
|
+
`{ "$ref": ${JSON.stringify(ref)} } must point at an earlier single-row insert in this list (0-based).`
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
return target.id;
|
|
1319
|
+
}
|
|
1320
|
+
return value;
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
const insertValues = (
|
|
1324
|
+
table: DataTable,
|
|
1325
|
+
values: unknown,
|
|
1326
|
+
results: readonly WriteResult[],
|
|
1327
|
+
index: number,
|
|
1328
|
+
timeZone?: string
|
|
1329
|
+
): Map<string, SqlValue> => {
|
|
1330
|
+
if (!isPlainObject(values)) {
|
|
1331
|
+
throw new DataError('An insert takes "values", an object of column: value.');
|
|
1332
|
+
}
|
|
1333
|
+
const out = new Map<string, SqlValue>();
|
|
1334
|
+
for (const [name, raw] of Object.entries(values)) {
|
|
1335
|
+
if (name === 'id' || name === 'created_at' || name === 'updated_at') {
|
|
1336
|
+
throw new DataError(`${table.name}.${name} is set by the database; leave it out.`);
|
|
1337
|
+
}
|
|
1338
|
+
const column = table.columns.find(c => c.name === name);
|
|
1339
|
+
if (!column) throw unknownColumn(table, name);
|
|
1340
|
+
out.set(name, toStored(table, column, resolveRefs(raw, results, index), timeZone));
|
|
1341
|
+
}
|
|
1342
|
+
const missing = table.columns.filter(
|
|
1343
|
+
c =>
|
|
1344
|
+
c.required &&
|
|
1345
|
+
(c.default === undefined || c.default === null) &&
|
|
1346
|
+
!out.has(c.name)
|
|
1347
|
+
);
|
|
1348
|
+
if (missing.length) {
|
|
1349
|
+
throw new DataError(
|
|
1350
|
+
`Insert into ${table.name} is missing required ${missing.map(c => c.name).join(', ')}.`
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
return out;
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
// A bot may only point a relation at a row it could read: none in a table
|
|
1357
|
+
// hidden from it, and only its own customer's in a scoped one. Otherwise it
|
|
1358
|
+
// could link an order to another customer and read their name back as the
|
|
1359
|
+
// label.
|
|
1360
|
+
const assertRelationsVisible = (
|
|
1361
|
+
runner: SqlRunner,
|
|
1362
|
+
schema: DataSchema,
|
|
1363
|
+
caller: DataCaller,
|
|
1364
|
+
table: DataTable,
|
|
1365
|
+
values: Map<string, SqlValue>
|
|
1366
|
+
) => {
|
|
1367
|
+
if (!caller.restricted) return;
|
|
1368
|
+
for (const [name, value] of values) {
|
|
1369
|
+
if (value === null) continue;
|
|
1370
|
+
const column = table.columns.find(c => c.name === name);
|
|
1371
|
+
if (column?.type !== 'relation' || !column.table) continue;
|
|
1372
|
+
const target = schema.tables.find(t => t.name === column.table);
|
|
1373
|
+
if (!target) continue;
|
|
1374
|
+
if (!canRead(caller, target)) {
|
|
1375
|
+
throw new DataError(
|
|
1376
|
+
`${table.name}.${name} points at a table the assistant can't use.`,
|
|
1377
|
+
'forbidden'
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
if (!isScoped(caller, target)) continue;
|
|
1381
|
+
const found = first(
|
|
1382
|
+
runner,
|
|
1383
|
+
`SELECT 1 AS found FROM ${q(target.name)} WHERE id = ? AND participant_id = ?`,
|
|
1384
|
+
[value, participantOf(caller)]
|
|
1385
|
+
);
|
|
1386
|
+
if (!found) {
|
|
1387
|
+
throw new DataError(
|
|
1388
|
+
`${table.name}.${name}: there is no row ${value} in ${target.name}.`,
|
|
1389
|
+
'not_found'
|
|
1390
|
+
);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
const targetWhere = (
|
|
1396
|
+
ctx: CompileContext,
|
|
1397
|
+
op: Record<string, unknown>
|
|
1398
|
+
): string => {
|
|
1399
|
+
const { table, caller } = ctx;
|
|
1400
|
+
if (op.id === undefined && op.where === undefined) {
|
|
1401
|
+
throw new DataError(`${op.op} on ${table.name} needs "id" or "where".`);
|
|
1402
|
+
}
|
|
1403
|
+
const clauses: string[] = [];
|
|
1404
|
+
if (op.id !== undefined) {
|
|
1405
|
+
if (typeof op.id !== 'number' || !Number.isInteger(op.id)) {
|
|
1406
|
+
throw new DataError('"id" is a whole number.');
|
|
1407
|
+
}
|
|
1408
|
+
clauses.push('t.id = ?');
|
|
1409
|
+
ctx.params.push(op.id);
|
|
1410
|
+
}
|
|
1411
|
+
clauses.push(...compileWhere(ctx, op.where));
|
|
1412
|
+
if (clauses.length === 0) {
|
|
1413
|
+
throw new DataError(`${op.op} on ${table.name} needs a non-empty "where".`);
|
|
1414
|
+
}
|
|
1415
|
+
if (ctx.joins.size > 0) {
|
|
1416
|
+
throw new DataError(
|
|
1417
|
+
`${op.op}: "where" on a write can't use relation paths; filter on this table's own columns.`
|
|
1418
|
+
);
|
|
1419
|
+
}
|
|
1420
|
+
if (op.version !== undefined) {
|
|
1421
|
+
if (typeof op.version !== 'number' || !Number.isInteger(op.version)) {
|
|
1422
|
+
throw new DataError('"version" is the whole number the row was read with.');
|
|
1423
|
+
}
|
|
1424
|
+
clauses.push('t.version = ?');
|
|
1425
|
+
ctx.params.push(op.version);
|
|
1426
|
+
}
|
|
1427
|
+
if (isScoped(caller, table)) {
|
|
1428
|
+
clauses.push('t.participant_id = ?');
|
|
1429
|
+
ctx.params.push(participantOf(caller));
|
|
1430
|
+
}
|
|
1431
|
+
return clauses.join(' AND ');
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
// The current values of the columns a guard named, for the row an id named,
|
|
1435
|
+
// so "not enough stock" says how much there is.
|
|
1436
|
+
const explainGuard = (
|
|
1437
|
+
runner: SqlRunner,
|
|
1438
|
+
ctx: CompileContext,
|
|
1439
|
+
op: Record<string, unknown>,
|
|
1440
|
+
names: string[]
|
|
1441
|
+
): string => {
|
|
1442
|
+
if (typeof op.id !== 'number') return '';
|
|
1443
|
+
const { table, caller } = ctx;
|
|
1444
|
+
const scoped = isScoped(caller, table);
|
|
1445
|
+
const row = first(
|
|
1446
|
+
runner,
|
|
1447
|
+
`SELECT * FROM ${q(table.name)} WHERE id = ?${scoped ? ' AND participant_id = ?' : ''}`,
|
|
1448
|
+
[op.id, ...(scoped ? [participantOf(caller)] : [])]
|
|
1449
|
+
);
|
|
1450
|
+
if (!row) return ` Row ${op.id} doesn't exist in ${table.name}.`;
|
|
1451
|
+
const shown = names.filter(n => n in row && table.columns.some(c => c.name === n));
|
|
1452
|
+
if (shown.length === 0) return '';
|
|
1453
|
+
return ` Current values: ${shown.map(n => `${n} = ${JSON.stringify(row[n])}`).join(', ')}.`;
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
const versionConflict = (
|
|
1457
|
+
runner: SqlRunner,
|
|
1458
|
+
schema: DataSchema,
|
|
1459
|
+
ctx: CompileContext,
|
|
1460
|
+
op: Record<string, unknown>
|
|
1461
|
+
): DataError | null => {
|
|
1462
|
+
if (op.version === undefined || typeof op.id !== 'number') return null;
|
|
1463
|
+
const { table, caller } = ctx;
|
|
1464
|
+
// The row as this caller may read it: a bot sending a stale version for
|
|
1465
|
+
// another customer's id learns nothing.
|
|
1466
|
+
const scoped = isScoped(caller, table);
|
|
1467
|
+
const joins = new Map<string, SqlJoin>();
|
|
1468
|
+
const select = selectList(schema, caller, table, joins);
|
|
1469
|
+
const row = first(
|
|
1470
|
+
runner,
|
|
1471
|
+
`SELECT ${select.join(', ')}, t.updated_by FROM ${q(table.name)} t ${joinsSql(joins)} WHERE t.id = ?${
|
|
1472
|
+
scoped ? ' AND t.participant_id = ?' : ''
|
|
1473
|
+
}`,
|
|
1474
|
+
[...joinsParams(joins), op.id, ...(scoped ? [participantOf(caller)] : [])]
|
|
1475
|
+
);
|
|
1476
|
+
if (!row || Number(row.version) === op.version) return null;
|
|
1477
|
+
return new DataError(
|
|
1478
|
+
`Row ${op.id} in ${table.name} was changed since it was read (now version ${row.version}).`,
|
|
1479
|
+
'conflict',
|
|
1480
|
+
{
|
|
1481
|
+
current: shapeRow(schema, table, row, caller),
|
|
1482
|
+
version: Number(row.version),
|
|
1483
|
+
updatedBy: row.updated_by
|
|
1484
|
+
}
|
|
1485
|
+
);
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1488
|
+
const runOperation = (
|
|
1489
|
+
runner: SqlRunner,
|
|
1490
|
+
schema: DataSchema,
|
|
1491
|
+
caller: DataCaller,
|
|
1492
|
+
rawOp: unknown,
|
|
1493
|
+
index: number,
|
|
1494
|
+
results: WriteResult[]
|
|
1495
|
+
): WriteResult => {
|
|
1496
|
+
if (!isPlainObject(rawOp)) throw new DataError('An operation is an object.');
|
|
1497
|
+
const kind = rawOp.op;
|
|
1498
|
+
if (kind !== 'insert' && kind !== 'update' && kind !== 'delete') {
|
|
1499
|
+
throw new DataError('"op" is insert, update or delete.');
|
|
1500
|
+
}
|
|
1501
|
+
// An update or delete can name a row inserted earlier in the same write,
|
|
1502
|
+
// into the same table. In values a $ref fills a relation to another table;
|
|
1503
|
+
// as the row's own id, another table's id would quietly hit an unrelated
|
|
1504
|
+
// row.
|
|
1505
|
+
let op = rawOp;
|
|
1506
|
+
if (kind !== 'insert' && rawOp.id !== undefined) {
|
|
1507
|
+
const id = resolveRefs(rawOp.id, results, index);
|
|
1508
|
+
if (id !== rawOp.id) {
|
|
1509
|
+
const source = results[(rawOp.id as { $ref: number }).$ref];
|
|
1510
|
+
if (source.table !== rawOp.table) {
|
|
1511
|
+
throw new DataError(
|
|
1512
|
+
`"id": { "$ref": ${(rawOp.id as { $ref: number }).$ref} } points at a row inserted into ${source.table}; a $ref as the id must name an insert into ${String(rawOp.table)}.`
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
op = { ...rawOp, id };
|
|
1517
|
+
}
|
|
1518
|
+
const table = tableFor(schema, caller, op.table, kind === 'insert' ? 'add' : 'full');
|
|
1519
|
+
const actor = caller.actor;
|
|
1520
|
+
const scoped = isScoped(caller, table);
|
|
1521
|
+
|
|
1522
|
+
if (kind === 'insert') {
|
|
1523
|
+
const many = op.rows !== undefined;
|
|
1524
|
+
if (many && op.values !== undefined) {
|
|
1525
|
+
throw new DataError('An insert takes "values" (one row) or "rows" (a list), not both.');
|
|
1526
|
+
}
|
|
1527
|
+
const list = many ? op.rows : [op.values];
|
|
1528
|
+
if (!Array.isArray(list) || list.length === 0 || list.length > DATA_MAX_INSERT_ROWS) {
|
|
1529
|
+
throw new DataError(`"rows" is a list of 1 to ${DATA_MAX_INSERT_ROWS} rows.`);
|
|
1530
|
+
}
|
|
1531
|
+
const prepared = list.map((values, i) => {
|
|
1532
|
+
try {
|
|
1533
|
+
const stored = insertValues(table, values, results, index, caller.timeZone);
|
|
1534
|
+
assertRelationsVisible(runner, schema, caller, table, stored);
|
|
1535
|
+
return stored;
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
if (many && error instanceof DataError) {
|
|
1538
|
+
throw new DataError(`Row ${i}: ${error.message}`, error.code, error.details);
|
|
1539
|
+
}
|
|
1540
|
+
throw error;
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
// One column list for the whole batch, so rows that leave a column out
|
|
1544
|
+
// get NULL there rather than its default only when another row sets it.
|
|
1545
|
+
const names = [...new Set(prepared.flatMap(values => [...values.keys()]))];
|
|
1546
|
+
const columns = [...names, 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
|
|
1547
|
+
const ids: number[] = [];
|
|
1548
|
+
try {
|
|
1549
|
+
for (const chunk of chunkRows(prepared, columns.length)) {
|
|
1550
|
+
const usesDefaults = chunk.some(values => names.some(n => !values.has(n)));
|
|
1551
|
+
if (usesDefaults && chunk.length > 1) {
|
|
1552
|
+
// Rows with different columns go one by one, so each missing
|
|
1553
|
+
// column takes its default.
|
|
1554
|
+
for (const values of chunk) ids.push(insertOne(runner, table, values, actor, scoped, caller));
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
const params: SqlValue[] = [];
|
|
1558
|
+
for (const values of chunk) {
|
|
1559
|
+
for (const name of names) params.push(values.get(name) ?? null);
|
|
1560
|
+
params.push(actor, actor);
|
|
1561
|
+
if (scoped) params.push(participantOf(caller));
|
|
1562
|
+
}
|
|
1563
|
+
const rows = runner.all(
|
|
1564
|
+
`INSERT INTO ${q(table.name)} (${columns.map(q).join(', ')}) VALUES ${chunk
|
|
1565
|
+
.map(() => `(${columns.map(() => '?').join(', ')})`)
|
|
1566
|
+
.join(', ')} RETURNING id`,
|
|
1567
|
+
params
|
|
1568
|
+
);
|
|
1569
|
+
ids.push(...rows.map(r => Number(r.id)));
|
|
1570
|
+
}
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
throw translateSqliteError(schema, table, error);
|
|
1573
|
+
}
|
|
1574
|
+
return many
|
|
1575
|
+
? { op: 'insert', table: table.name, ids }
|
|
1576
|
+
: { op: 'insert', table: table.name, id: ids[0] };
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
const ctx: CompileContext = { schema, caller, table, joins: new Map(), params: [] };
|
|
1580
|
+
const where = targetWhere(ctx, op);
|
|
1581
|
+
const expect =
|
|
1582
|
+
op.expect === undefined ? (op.id !== undefined ? 1 : undefined) : op.expect;
|
|
1583
|
+
if (
|
|
1584
|
+
expect !== undefined &&
|
|
1585
|
+
(typeof expect !== 'number' || !Number.isInteger(expect) || expect < 0)
|
|
1586
|
+
) {
|
|
1587
|
+
throw new DataError('"expect" is the number of rows that must change.');
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// An update or delete by "where" counts what it would touch first, so a
|
|
1591
|
+
// runaway one is refused before it writes (and bills) anything.
|
|
1592
|
+
const assertBoundedTarget = () => {
|
|
1593
|
+
if (op.id !== undefined) return [] as number[];
|
|
1594
|
+
assertParamCount(ctx.params);
|
|
1595
|
+
const ids = runner
|
|
1596
|
+
.all(`SELECT t.id FROM ${q(table.name)} t WHERE ${where} LIMIT ${DATA_MAX_ROWS_PER_WRITE + 1}`, ctx.params)
|
|
1597
|
+
.map(r => Number(r.id));
|
|
1598
|
+
if (ids.length > DATA_MAX_ROWS_PER_WRITE) {
|
|
1599
|
+
throw new DataError(
|
|
1600
|
+
`This ${kind} would ${kind === 'update' ? 'change' : 'remove'} more than ${DATA_MAX_ROWS_PER_WRITE.toLocaleString('en-US')} rows; narrow the "where".`,
|
|
1601
|
+
'too_large'
|
|
1602
|
+
);
|
|
1603
|
+
}
|
|
1604
|
+
return ids;
|
|
1605
|
+
};
|
|
1606
|
+
|
|
1607
|
+
let changed: number;
|
|
1608
|
+
if (kind === 'update') {
|
|
1609
|
+
if (!isPlainObject(op.set) || Object.keys(op.set).length === 0) {
|
|
1610
|
+
throw new DataError('An update takes "set", an object of column: value.');
|
|
1611
|
+
}
|
|
1612
|
+
const sets: string[] = [];
|
|
1613
|
+
const setParams: SqlValue[] = [];
|
|
1614
|
+
const absolute = new Map<string, SqlValue>();
|
|
1615
|
+
for (const [name, raw] of Object.entries(op.set)) {
|
|
1616
|
+
const column = table.columns.find(c => c.name === name);
|
|
1617
|
+
if (!column) {
|
|
1618
|
+
throw columnFor(table, name)
|
|
1619
|
+
? new DataError(`${table.name}.${name} is set by the database.`)
|
|
1620
|
+
: unknownColumn(table, name);
|
|
1621
|
+
}
|
|
1622
|
+
const value = resolveRefs(raw, results, index);
|
|
1623
|
+
if (isPlainObject(value) && column.type !== 'json') {
|
|
1624
|
+
const entries = Object.entries(value);
|
|
1625
|
+
const [how, amount] = entries[0] ?? [];
|
|
1626
|
+
if (entries.length !== 1 || (how !== 'increment' && how !== 'decrement')) {
|
|
1627
|
+
throw new DataError(
|
|
1628
|
+
`${table.name}.${name}: set a value, or { "increment": n } / { "decrement": n }.`
|
|
1629
|
+
);
|
|
1630
|
+
}
|
|
1631
|
+
if (!NUMERIC_TYPES.has(column.type) || typeof amount !== 'number' || !Number.isFinite(amount)) {
|
|
1632
|
+
throw new DataError(`${how} needs a number column and a number.`);
|
|
1633
|
+
}
|
|
1634
|
+
if (column.type === 'integer' && !Number.isInteger(amount)) {
|
|
1635
|
+
throw new DataError(`${table.name}.${name} is a whole number; ${how} by a whole number.`);
|
|
1636
|
+
}
|
|
1637
|
+
sets.push(`${q(name)} = ${q(name)} ${how === 'increment' ? '+' : '-'} ?`);
|
|
1638
|
+
setParams.push(amount);
|
|
1639
|
+
} else {
|
|
1640
|
+
const stored = toStored(table, column, value, caller.timeZone);
|
|
1641
|
+
sets.push(`${q(name)} = ?`);
|
|
1642
|
+
setParams.push(stored);
|
|
1643
|
+
absolute.set(name, stored);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
assertRelationsVisible(runner, schema, caller, table, absolute);
|
|
1647
|
+
assertBoundedTarget();
|
|
1648
|
+
sets.push('version = version + 1', `updated_at = ${DATA_NOW_SQL}`, 'updated_by = ?');
|
|
1649
|
+
setParams.push(caller.actor);
|
|
1650
|
+
const params = [...setParams, ...ctx.params];
|
|
1651
|
+
assertParamCount(params);
|
|
1652
|
+
try {
|
|
1653
|
+
changed = runner.all(
|
|
1654
|
+
`UPDATE ${q(table.name)} AS t SET ${sets.join(', ')} WHERE ${where} RETURNING id`,
|
|
1655
|
+
params
|
|
1656
|
+
).length;
|
|
1657
|
+
} catch (error) {
|
|
1658
|
+
const translated = translateSqliteError(schema, table, error);
|
|
1659
|
+
if (translated.message.includes("can't go below")) {
|
|
1660
|
+
throw new DataError(
|
|
1661
|
+
`${translated.message}${explainGuard(runner, ctx, op, Object.keys(op.set))}`,
|
|
1662
|
+
'guard'
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
throw translated;
|
|
1666
|
+
}
|
|
1667
|
+
} else {
|
|
1668
|
+
assertParamCount(ctx.params);
|
|
1669
|
+
const ids =
|
|
1670
|
+
op.id !== undefined
|
|
1671
|
+
? runner
|
|
1672
|
+
.all(`SELECT t.id FROM ${q(table.name)} t WHERE ${where}`, ctx.params)
|
|
1673
|
+
.map(r => Number(r.id))
|
|
1674
|
+
: assertBoundedTarget();
|
|
1675
|
+
// A readable "3 rows in order_lines still point at this order" before
|
|
1676
|
+
// SQLite's bare foreign key error. A row of the same table counts even
|
|
1677
|
+
// when this delete removes it too: SQLite checks a restrict as each row
|
|
1678
|
+
// goes, so a child still there when its parent is deleted refuses it.
|
|
1679
|
+
for (const other of schema.tables) {
|
|
1680
|
+
for (const c of other.columns) {
|
|
1681
|
+
if (c.type !== 'relation' || c.table !== table.name) continue;
|
|
1682
|
+
if ((c.onDelete ?? 'restrict') !== 'restrict' || ids.length === 0) continue;
|
|
1683
|
+
let n = 0;
|
|
1684
|
+
for (let i = 0; i < ids.length; i += DATA_MAX_PARAMS) {
|
|
1685
|
+
const slice = ids.slice(i, i + DATA_MAX_PARAMS);
|
|
1686
|
+
n += Number(
|
|
1687
|
+
first(
|
|
1688
|
+
runner,
|
|
1689
|
+
`SELECT count(*) AS n FROM ${q(other.name)} WHERE ${q(c.name)} IN (${slice.map(() => '?').join(', ')})`,
|
|
1690
|
+
slice
|
|
1691
|
+
)?.n ?? 0
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
if (n > 0) {
|
|
1695
|
+
throw new DataError(
|
|
1696
|
+
`Can't delete: ${n} row${n === 1 ? '' : 's'} in ${other.name} still point${n === 1 ? 's' : ''} at ${
|
|
1697
|
+
ids.length === 1 ? 'this row' : 'these rows'
|
|
1698
|
+
} (${other.name}.${c.name}).`,
|
|
1699
|
+
'constraint',
|
|
1700
|
+
{ table: other.name, column: c.name, count: n }
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
try {
|
|
1706
|
+
changed = runner.all(
|
|
1707
|
+
`DELETE FROM ${q(table.name)} AS t WHERE ${where} RETURNING id`,
|
|
1708
|
+
ctx.params
|
|
1709
|
+
).length;
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
throw translateSqliteError(schema, table, error);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (kind === 'update' && changed > DATA_MAX_ROWS_PER_WRITE) {
|
|
1716
|
+
throw new DataError(
|
|
1717
|
+
`This update would change more than ${DATA_MAX_ROWS_PER_WRITE.toLocaleString('en-US')} rows; narrow the "where".`,
|
|
1718
|
+
'too_large'
|
|
1719
|
+
);
|
|
1720
|
+
}
|
|
1721
|
+
if (expect !== undefined && changed !== expect) {
|
|
1722
|
+
const conflict = versionConflict(runner, schema, ctx, op);
|
|
1723
|
+
if (conflict) throw conflict;
|
|
1724
|
+
throw new DataError(
|
|
1725
|
+
`${kind} on ${table.name} changed ${changed} row${changed === 1 ? '' : 's'}, expected ${expect}; nothing was written.${explainGuard(
|
|
1726
|
+
runner,
|
|
1727
|
+
ctx,
|
|
1728
|
+
op,
|
|
1729
|
+
Object.keys(isPlainObject(op.where) ? op.where : {})
|
|
1730
|
+
)}`,
|
|
1731
|
+
'guard'
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
return { op: kind, table: table.name, changed };
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
const insertOne = (
|
|
1738
|
+
runner: SqlRunner,
|
|
1739
|
+
table: DataTable,
|
|
1740
|
+
values: Map<string, SqlValue>,
|
|
1741
|
+
actor: string,
|
|
1742
|
+
scoped: boolean,
|
|
1743
|
+
caller: DataCaller
|
|
1744
|
+
): number => {
|
|
1745
|
+
const names = [...values.keys(), 'created_by', 'updated_by', ...(scoped ? ['participant_id'] : [])];
|
|
1746
|
+
const params = [...values.values(), actor, actor, ...(scoped ? [participantOf(caller)] : [])];
|
|
1747
|
+
return Number(
|
|
1748
|
+
runner.all(
|
|
1749
|
+
`INSERT INTO ${q(table.name)} (${names.map(q).join(', ')}) VALUES (${names.map(() => '?').join(', ')}) RETURNING id`,
|
|
1750
|
+
params
|
|
1751
|
+
)[0]?.id
|
|
1752
|
+
);
|
|
1753
|
+
};
|
|
1754
|
+
|
|
1755
|
+
// A list of inserts, updates and deletes across tables, applied together or
|
|
1756
|
+
// not at all. A guard that fails rolls every operation back.
|
|
1757
|
+
export const write = (
|
|
1758
|
+
runner: SqlRunner,
|
|
1759
|
+
schema: DataSchema,
|
|
1760
|
+
caller: DataCaller,
|
|
1761
|
+
rawArgs: unknown
|
|
1762
|
+
) => {
|
|
1763
|
+
const operations = tidyArgs(rawArgs).operations;
|
|
1764
|
+
if (
|
|
1765
|
+
!Array.isArray(operations) ||
|
|
1766
|
+
operations.length === 0 ||
|
|
1767
|
+
operations.length > DATA_MAX_WRITE_OPERATIONS
|
|
1768
|
+
) {
|
|
1769
|
+
throw new DataError(
|
|
1770
|
+
`"operations" is a list of 1 to ${DATA_MAX_WRITE_OPERATIONS} insert, update or delete operations.`
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
const addsData = operations.some(
|
|
1774
|
+
op => !isPlainObject(op) || op.op !== 'delete'
|
|
1775
|
+
);
|
|
1776
|
+
assertWritable(runner, caller, addsData);
|
|
1777
|
+
return runner.transaction(() => {
|
|
1778
|
+
setContextActor(runner, caller.actor);
|
|
1779
|
+
const results: WriteResult[] = [];
|
|
1780
|
+
operations.forEach((op, index) => {
|
|
1781
|
+
try {
|
|
1782
|
+
results.push(runOperation(runner, schema, caller, op, index, results));
|
|
1783
|
+
} catch (error) {
|
|
1784
|
+
if (error instanceof DataError) {
|
|
1785
|
+
const label = isPlainObject(op) ? `${String(op.op)} ${String(op.table)}` : '?';
|
|
1786
|
+
throw new DataError(
|
|
1787
|
+
`Operation ${index} (${label}): ${error.message}`,
|
|
1788
|
+
error.code,
|
|
1789
|
+
{ ...error.details, operation: index }
|
|
1790
|
+
);
|
|
1791
|
+
}
|
|
1792
|
+
throw error;
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
return { ok: true as const, results };
|
|
1796
|
+
});
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
// Delete preview
|
|
1800
|
+
|
|
1801
|
+
// What deleting `ids` of a table, or all its rows, would do to the rows that
|
|
1802
|
+
// point at them, counted the way SQLite applies it: cascades are followed to
|
|
1803
|
+
// the end in one recursive query, which stops at DATA_IMPACT_MAX_ROWS rows.
|
|
1804
|
+
// Without ids the table's own rows are never read: every non-empty relation
|
|
1805
|
+
// to it points at a deleted row, and the cascades start from those. `rows`
|
|
1806
|
+
// is then an estimate. For the owner: a bot's deletes go through the
|
|
1807
|
+
// confirmation rules instead.
|
|
1808
|
+
export const deleteImpact = (
|
|
1809
|
+
runner: SqlRunner,
|
|
1810
|
+
schema: DataSchema,
|
|
1811
|
+
caller: DataCaller,
|
|
1812
|
+
rawArgs: unknown
|
|
1813
|
+
): DataDeleteImpact => {
|
|
1814
|
+
assertActor(caller);
|
|
1815
|
+
if (caller.restricted) {
|
|
1816
|
+
throw new DataError('A delete preview is for the owner.', 'forbidden');
|
|
1817
|
+
}
|
|
1818
|
+
const args = tidyArgs(rawArgs);
|
|
1819
|
+
const table = tableFor(schema, caller, args.table);
|
|
1820
|
+
let ids: number[] | null = null;
|
|
1821
|
+
if (args.ids !== undefined && args.ids !== null) {
|
|
1822
|
+
if (
|
|
1823
|
+
!Array.isArray(args.ids) ||
|
|
1824
|
+
!args.ids.every(id => Number.isSafeInteger(id) && (id as number) > 0)
|
|
1825
|
+
) {
|
|
1826
|
+
throw new DataError('"ids" lists row ids; leave it out for every row.');
|
|
1827
|
+
}
|
|
1828
|
+
if (args.ids.length > DATA_IMPACT_MAX_IDS) {
|
|
1829
|
+
throw new DataError(`A delete preview takes up to ${DATA_IMPACT_MAX_IDS} ids.`, 'too_large');
|
|
1830
|
+
}
|
|
1831
|
+
ids = [...new Set(args.ids as number[])];
|
|
1832
|
+
}
|
|
1833
|
+
const incoming = (name: string) =>
|
|
1834
|
+
schema.tables.flatMap(other =>
|
|
1835
|
+
other.columns
|
|
1836
|
+
.filter(c => c.type === 'relation' && c.table === name)
|
|
1837
|
+
.map(column => ({ other, column, onDelete: column.onDelete ?? 'restrict' }))
|
|
1838
|
+
);
|
|
1839
|
+
|
|
1840
|
+
// The tables a cascade reaches, each at its nearest depth.
|
|
1841
|
+
const depth = new Map<number, number>([[table.id, 0]]);
|
|
1842
|
+
const reached = [table];
|
|
1843
|
+
for (let i = 0; i < reached.length; i++) {
|
|
1844
|
+
for (const edge of incoming(reached[i].name)) {
|
|
1845
|
+
if (edge.onDelete !== 'cascade' || depth.has(edge.other.id)) continue;
|
|
1846
|
+
depth.set(edge.other.id, depth.get(reached[i].id)! + 1);
|
|
1847
|
+
reached.push(edge.other);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
const doomedIn = (t: DataTable) => `(SELECT id FROM doomed WHERE t = ${t.id})`;
|
|
1851
|
+
// The rows of `other` whose `column` points at a deleted row of `parent`.
|
|
1852
|
+
const pointing = (parent: DataTable, column: DataColumn) =>
|
|
1853
|
+
!ids && parent.id === table.id
|
|
1854
|
+
? `c.${q(column.name)} IS NOT NULL`
|
|
1855
|
+
: `c.${q(column.name)} IN ${doomedIn(parent)}`;
|
|
1856
|
+
const base = ids
|
|
1857
|
+
? [`SELECT ${table.id}, id FROM ${q(table.name)} WHERE id IN (SELECT value FROM json_each(?))`]
|
|
1858
|
+
: incoming(table.name)
|
|
1859
|
+
.filter(edge => edge.onDelete === 'cascade' && edge.other.id !== table.id)
|
|
1860
|
+
.map(
|
|
1861
|
+
({ other, column }) =>
|
|
1862
|
+
`SELECT ${other.id}, c.id FROM ${q(other.name)} c WHERE ${pointing(table, column)}`
|
|
1863
|
+
);
|
|
1864
|
+
const recursive = reached.flatMap(parent =>
|
|
1865
|
+
incoming(parent.name)
|
|
1866
|
+
.filter(edge => edge.onDelete === 'cascade')
|
|
1867
|
+
.map(
|
|
1868
|
+
({ other, column }) =>
|
|
1869
|
+
`SELECT ${other.id}, c.id FROM ${q(other.name)} c JOIN doomed d ON d.t = ${parent.id} AND c.${q(column.name)} = d.id`
|
|
1870
|
+
)
|
|
1871
|
+
);
|
|
1872
|
+
// Without ids, a cascade or a set null on the table's own rows doesn't
|
|
1873
|
+
// matter: they all go. A restrict to itself still refuses the delete.
|
|
1874
|
+
const edges = reached.flatMap(parent =>
|
|
1875
|
+
incoming(parent.name)
|
|
1876
|
+
.filter(edge => ids || edge.other.id !== table.id || edge.onDelete === 'restrict')
|
|
1877
|
+
.map(edge => ({ ...edge, parent }))
|
|
1878
|
+
);
|
|
1879
|
+
const counts = [
|
|
1880
|
+
`SELECT -1 AS k, count(*) AS n FROM doomed`,
|
|
1881
|
+
`SELECT -2 AS k, count(*) AS n FROM doomed WHERE t = ${table.id}`,
|
|
1882
|
+
...edges.map(
|
|
1883
|
+
({ other, column, onDelete, parent }, i) =>
|
|
1884
|
+
`SELECT ${i} AS k, count(*) AS n FROM ${q(other.name)} c WHERE ${pointing(parent, column)}${
|
|
1885
|
+
// A cleared row that is deleted anyway isn't cleared. A restrict
|
|
1886
|
+
// counts every row, even one the same delete removes: SQLite
|
|
1887
|
+
// checks it as each row goes, before the rest are gone.
|
|
1888
|
+
onDelete === 'set_null' ? ` AND c.id NOT IN ${doomedIn(other)}` : ''
|
|
1889
|
+
}`
|
|
1890
|
+
)
|
|
1891
|
+
];
|
|
1892
|
+
const sql = `WITH RECURSIVE doomed(t, id) AS MATERIALIZED (
|
|
1893
|
+
${base.length ? base.join('\n UNION ') : 'SELECT 0, 0 WHERE 0'}
|
|
1894
|
+
${recursive.map(s => `UNION ${s}`).join('\n ')}
|
|
1895
|
+
LIMIT ${DATA_IMPACT_MAX_ROWS}
|
|
1896
|
+
)
|
|
1897
|
+
${counts.join('\nUNION ALL ')}`;
|
|
1898
|
+
const n = new Map(
|
|
1899
|
+
runner.all(sql, ids ? [JSON.stringify(ids)] : []).map(row => [Number(row.k), Number(row.n)])
|
|
1900
|
+
);
|
|
1901
|
+
const effects = edges
|
|
1902
|
+
.map(({ other, column, onDelete, parent }, i) => ({
|
|
1903
|
+
table: other.name,
|
|
1904
|
+
column: column.name,
|
|
1905
|
+
onDelete,
|
|
1906
|
+
rows: n.get(i) ?? 0,
|
|
1907
|
+
depth: depth.get(parent.id)!
|
|
1908
|
+
}))
|
|
1909
|
+
.filter(effect => effect.rows > 0)
|
|
1910
|
+
.sort((a, b) => a.depth - b.depth);
|
|
1911
|
+
return {
|
|
1912
|
+
table: table.name,
|
|
1913
|
+
rows: ids ? (n.get(-2) ?? 0) : estimateRows(runner, table),
|
|
1914
|
+
effects,
|
|
1915
|
+
blocked: effects.some(e => e.onDelete === 'restrict'),
|
|
1916
|
+
truncated: (n.get(-1) ?? 0) >= DATA_IMPACT_MAX_ROWS
|
|
1917
|
+
};
|
|
1918
|
+
};
|
|
1919
|
+
|
|
1920
|
+
// Row history
|
|
1921
|
+
|
|
1922
|
+
export const rowHistory = (
|
|
1923
|
+
runner: SqlRunner,
|
|
1924
|
+
schema: DataSchema,
|
|
1925
|
+
caller: DataCaller,
|
|
1926
|
+
args: { table: string; id: number; limit?: number; cursor?: number }
|
|
1927
|
+
) => {
|
|
1928
|
+
const table = tableFor(schema, caller, args.table);
|
|
1929
|
+
if (!Number.isInteger(args.id)) throw new DataError('"id" is a whole number.');
|
|
1930
|
+
// A bot reads the history of its own customer's rows only, and of rows
|
|
1931
|
+
// that still exist: a deleted row's owner can't be told apart.
|
|
1932
|
+
if (
|
|
1933
|
+
isScoped(caller, table) &&
|
|
1934
|
+
!first(
|
|
1935
|
+
runner,
|
|
1936
|
+
`SELECT 1 AS found FROM ${q(table.name)} WHERE id = ? AND participant_id = ?`,
|
|
1937
|
+
[args.id, participantOf(caller)]
|
|
1938
|
+
)
|
|
1939
|
+
) {
|
|
1940
|
+
throw new DataError(`There is no row ${args.id} in ${table.name}.`, 'not_found');
|
|
1941
|
+
}
|
|
1942
|
+
const limit = Math.min(Math.max(args.limit ?? 50, 1), 100);
|
|
1943
|
+
const entries = runner.all(
|
|
1944
|
+
`SELECT id, op, actor, at, changes FROM _ganju_row_log WHERE table_id = ? AND row_id = ?${
|
|
1945
|
+
args.cursor ? ' AND id < ?' : ''
|
|
1946
|
+
} ORDER BY id DESC LIMIT ?`,
|
|
1947
|
+
[table.id, args.id, ...(args.cursor ? [args.cursor] : []), limit + 1]
|
|
1948
|
+
);
|
|
1949
|
+
const page = entries.slice(0, limit);
|
|
1950
|
+
return {
|
|
1951
|
+
table: table.name,
|
|
1952
|
+
id: args.id,
|
|
1953
|
+
entries: page.map(e => ({
|
|
1954
|
+
op: e.op,
|
|
1955
|
+
actor: e.actor,
|
|
1956
|
+
at: zoned(String(e.at), caller.timeZone),
|
|
1957
|
+
changes: parseJson<Record<string, [unknown, unknown]>>(e.changes) ?? {}
|
|
1958
|
+
})),
|
|
1959
|
+
...(entries.length > limit ? { nextCursor: Number(page[page.length - 1].id) } : {})
|
|
1960
|
+
};
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1963
|
+
// Deletes history older than the retention window, a bounded batch at a
|
|
1964
|
+
// time. Returns how many entries went, so the caller knows whether to go on.
|
|
1965
|
+
export const pruneHistory = (
|
|
1966
|
+
runner: SqlRunner,
|
|
1967
|
+
now: Date = new Date(),
|
|
1968
|
+
batch = 5_000
|
|
1969
|
+
): number => {
|
|
1970
|
+
const cutoff = new Date(now.getTime() - DATA_HISTORY_DAYS * 86_400_000)
|
|
1971
|
+
.toISOString()
|
|
1972
|
+
.replace(/\.\d{3}Z$/, 'Z');
|
|
1973
|
+
return runner.all(
|
|
1974
|
+
'DELETE FROM _ganju_row_log WHERE id IN (SELECT id FROM _ganju_row_log WHERE at < ? ORDER BY id LIMIT ?) RETURNING id',
|
|
1975
|
+
[cutoff, batch]
|
|
1976
|
+
).length;
|
|
1977
|
+
};
|
|
1978
|
+
|
|
1979
|
+
// Schema changes
|
|
1980
|
+
|
|
1981
|
+
const insertColumnMeta = (
|
|
1982
|
+
runner: SqlRunner,
|
|
1983
|
+
schema: DataSchema,
|
|
1984
|
+
tableId: number,
|
|
1985
|
+
tableName: string,
|
|
1986
|
+
column: ColumnDraft,
|
|
1987
|
+
position: number,
|
|
1988
|
+
selfId: number
|
|
1989
|
+
): number => {
|
|
1990
|
+
const relationTableId =
|
|
1991
|
+
column.type === 'relation'
|
|
1992
|
+
? column.table === tableName
|
|
1993
|
+
? selfId
|
|
1994
|
+
: (schema.tables.find(t => t.name === column.table)?.id ?? null)
|
|
1995
|
+
: null;
|
|
1996
|
+
return Number(
|
|
1997
|
+
runner.all(
|
|
1998
|
+
`INSERT INTO _ganju_columns (table_id, name, label, description, type, required, is_unique, filterable, searchable, personal, enum_values, relation_table_id, on_delete, min, default_value, position)
|
|
1999
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
|
2000
|
+
[
|
|
2001
|
+
tableId,
|
|
2002
|
+
column.name,
|
|
2003
|
+
column.label,
|
|
2004
|
+
column.description,
|
|
2005
|
+
column.type,
|
|
2006
|
+
column.required ? 1 : 0,
|
|
2007
|
+
column.unique ? 1 : 0,
|
|
2008
|
+
column.filterable ? 1 : 0,
|
|
2009
|
+
column.searchable ? 1 : 0,
|
|
2010
|
+
column.personal ? 1 : 0,
|
|
2011
|
+
column.values ? JSON.stringify(column.values) : null,
|
|
2012
|
+
relationTableId,
|
|
2013
|
+
column.onDelete,
|
|
2014
|
+
column.min,
|
|
2015
|
+
column.default === undefined ? null : JSON.stringify({ v: column.default }),
|
|
2016
|
+
position
|
|
2017
|
+
]
|
|
2018
|
+
)[0].id
|
|
2019
|
+
);
|
|
2020
|
+
};
|
|
2021
|
+
|
|
2022
|
+
const updateColumnMeta = (
|
|
2023
|
+
runner: SqlRunner,
|
|
2024
|
+
schema: DataSchema,
|
|
2025
|
+
table: DataTable,
|
|
2026
|
+
id: number,
|
|
2027
|
+
column: ColumnDraft
|
|
2028
|
+
) => {
|
|
2029
|
+
const relationTableId =
|
|
2030
|
+
column.type === 'relation'
|
|
2031
|
+
? column.table === table.name
|
|
2032
|
+
? table.id
|
|
2033
|
+
: (schema.tables.find(t => t.name === column.table)?.id ?? null)
|
|
2034
|
+
: null;
|
|
2035
|
+
runner.all(
|
|
2036
|
+
`UPDATE _ganju_columns SET name = ?, label = ?, description = ?, type = ?, required = ?, is_unique = ?, filterable = ?, searchable = ?, personal = ?, enum_values = ?, relation_table_id = ?, on_delete = ?, min = ?, default_value = ?, updated_at = ${DATA_NOW_SQL} WHERE id = ?`,
|
|
2037
|
+
[
|
|
2038
|
+
column.name,
|
|
2039
|
+
column.label,
|
|
2040
|
+
column.description,
|
|
2041
|
+
column.type,
|
|
2042
|
+
column.required ? 1 : 0,
|
|
2043
|
+
column.unique ? 1 : 0,
|
|
2044
|
+
column.filterable ? 1 : 0,
|
|
2045
|
+
column.searchable ? 1 : 0,
|
|
2046
|
+
column.personal ? 1 : 0,
|
|
2047
|
+
column.values ? JSON.stringify(column.values) : null,
|
|
2048
|
+
relationTableId,
|
|
2049
|
+
column.onDelete,
|
|
2050
|
+
column.min,
|
|
2051
|
+
column.default === undefined ? null : JSON.stringify({ v: column.default }),
|
|
2052
|
+
id
|
|
2053
|
+
]
|
|
2054
|
+
);
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
const touchTable = (runner: SqlRunner, tableId: number) =>
|
|
2058
|
+
runner.all(`UPDATE _ganju_tables SET updated_at = ${DATA_NOW_SQL} WHERE id = ?`, [tableId]);
|
|
2059
|
+
|
|
2060
|
+
const setDisplayColumn = (
|
|
2061
|
+
runner: SqlRunner,
|
|
2062
|
+
tableId: number,
|
|
2063
|
+
name: string | null
|
|
2064
|
+
) => {
|
|
2065
|
+
runner.all(
|
|
2066
|
+
'UPDATE _ganju_tables SET display_column_id = (SELECT id FROM _ganju_columns WHERE table_id = ? AND name = ?) WHERE id = ?',
|
|
2067
|
+
[tableId, name, tableId]
|
|
2068
|
+
);
|
|
2069
|
+
};
|
|
2070
|
+
|
|
2071
|
+
const reload = (runner: SqlRunner, name: string) =>
|
|
2072
|
+
tableNamed(loadSchema(runner), name);
|
|
2073
|
+
|
|
2074
|
+
const assertDdlAllowed = (caller: DataCaller) => {
|
|
2075
|
+
assertActor(caller);
|
|
2076
|
+
if (caller.limits?.readOnly) {
|
|
2077
|
+
throw new DataError(
|
|
2078
|
+
"This organization's plan no longer includes Data, so the database is read-only. Upgrade to Pro to change it.",
|
|
2079
|
+
'read_only'
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
};
|
|
2083
|
+
|
|
2084
|
+
export const createTable = (
|
|
2085
|
+
runner: SqlRunner,
|
|
2086
|
+
schema: DataSchema,
|
|
2087
|
+
caller: DataCaller,
|
|
2088
|
+
input: unknown
|
|
2089
|
+
): DataTable => {
|
|
2090
|
+
assertDdlAllowed(caller);
|
|
2091
|
+
if (!isPlainObject(input)) throw new DataError('A table is an object: { "name", "columns" }.');
|
|
2092
|
+
const nameIssue = identifierIssue(input.name, 'table');
|
|
2093
|
+
if (nameIssue) throw new DataError(nameIssue);
|
|
2094
|
+
const name = input.name as string;
|
|
2095
|
+
if (schema.tables.some(t => t.name === name)) {
|
|
2096
|
+
throw new DataError(`There is already a table "${name}".`, 'conflict');
|
|
2097
|
+
}
|
|
2098
|
+
const maxTables = Math.min(caller.limits?.maxTables ?? DATA_MAX_TABLES, DATA_MAX_TABLES);
|
|
2099
|
+
if (schema.tables.length >= maxTables) {
|
|
2100
|
+
throw new DataError(`A database holds up to ${maxTables} tables.`, 'too_large', {
|
|
2101
|
+
limit: maxTables
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
if (!Array.isArray(input.columns) || input.columns.length === 0) {
|
|
2105
|
+
throw new DataError(`${name}: "columns" lists at least one column.`);
|
|
2106
|
+
}
|
|
2107
|
+
if (input.columns.length > DATA_MAX_COLUMNS) {
|
|
2108
|
+
throw new DataError(`A table has up to ${DATA_MAX_COLUMNS} columns.`, 'too_large');
|
|
2109
|
+
}
|
|
2110
|
+
const columns = input.columns.map(c => normaliseColumn(c, schema, name));
|
|
2111
|
+
const names = new Set<string>();
|
|
2112
|
+
for (const c of columns) {
|
|
2113
|
+
if (names.has(c.name)) throw new DataError(`${name}.${c.name} is listed twice.`);
|
|
2114
|
+
names.add(c.name);
|
|
2115
|
+
}
|
|
2116
|
+
// "id" is what a table without a display column shows anyway. Left out,
|
|
2117
|
+
// it is the first text column, so relations to the table show "Margherita"
|
|
2118
|
+
// rather than an id; null keeps the id.
|
|
2119
|
+
const displayColumn =
|
|
2120
|
+
input.displayColumn === 'id'
|
|
2121
|
+
? null
|
|
2122
|
+
: input.displayColumn === undefined
|
|
2123
|
+
? (columns.find(c => c.type === 'text')?.name ?? null)
|
|
2124
|
+
: input.displayColumn;
|
|
2125
|
+
if (displayColumn !== null && !names.has(String(displayColumn))) {
|
|
2126
|
+
throw new DataError(`"displayColumn" is one of ${name}'s columns.`);
|
|
2127
|
+
}
|
|
2128
|
+
const access = normaliseAccess(input.access, 'read');
|
|
2129
|
+
const customerScoped = flag(input.customerScoped, 'customerScoped');
|
|
2130
|
+
const history = input.history === undefined ? true : flag(input.history, 'history');
|
|
2131
|
+
|
|
2132
|
+
return runner.transaction(() => {
|
|
2133
|
+
const position = Number(
|
|
2134
|
+
first(runner, 'SELECT coalesce(max(position), -1) + 1 AS p FROM _ganju_tables')?.p ?? 0
|
|
2135
|
+
);
|
|
2136
|
+
// Never an id a dropped table had: its row history stays in the log
|
|
2137
|
+
// (a restore to the drop's bookmark brings both back), and a new table
|
|
2138
|
+
// reusing the id would show that history as its own.
|
|
2139
|
+
const nextId = Number(
|
|
2140
|
+
first(
|
|
2141
|
+
runner,
|
|
2142
|
+
`SELECT max(
|
|
2143
|
+
coalesce((SELECT max(id) FROM _ganju_tables), 0),
|
|
2144
|
+
coalesce((SELECT max(table_id) FROM _ganju_row_log), 0)
|
|
2145
|
+
) + 1 AS id`
|
|
2146
|
+
)?.id ?? 1
|
|
2147
|
+
);
|
|
2148
|
+
const tableId = Number(
|
|
2149
|
+
runner.all(
|
|
2150
|
+
'INSERT INTO _ganju_tables (id, name, label, description, access, customer_scoped, history, position) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
|
|
2151
|
+
[
|
|
2152
|
+
nextId,
|
|
2153
|
+
name,
|
|
2154
|
+
cleanText(input.label, DATA_MAX_LABEL, `${name} label`),
|
|
2155
|
+
cleanText(input.description, DATA_MAX_DESCRIPTION, `${name} description`),
|
|
2156
|
+
access,
|
|
2157
|
+
customerScoped ? 1 : 0,
|
|
2158
|
+
history ? 1 : 0,
|
|
2159
|
+
position
|
|
2160
|
+
]
|
|
2161
|
+
)[0].id
|
|
2162
|
+
);
|
|
2163
|
+
const stored: DataColumn[] = columns.map((c, i) => ({
|
|
2164
|
+
...c,
|
|
2165
|
+
id: insertColumnMeta(runner, schema, tableId, name, c, i, tableId),
|
|
2166
|
+
position: i
|
|
2167
|
+
}));
|
|
2168
|
+
if (displayColumn !== null) setDisplayColumn(runner, tableId, String(displayColumn));
|
|
2169
|
+
runner.all(createTableSql(name, stored));
|
|
2170
|
+
const table = reload(runner, name);
|
|
2171
|
+
syncTableObjects(runner, table);
|
|
2172
|
+
return table;
|
|
2173
|
+
});
|
|
2174
|
+
};
|
|
2175
|
+
|
|
2176
|
+
export const updateTable = (
|
|
2177
|
+
runner: SqlRunner,
|
|
2178
|
+
schema: DataSchema,
|
|
2179
|
+
caller: DataCaller,
|
|
2180
|
+
name: string,
|
|
2181
|
+
patch: unknown
|
|
2182
|
+
): DataTable => {
|
|
2183
|
+
assertDdlAllowed(caller);
|
|
2184
|
+
const table = tableNamed(schema, name);
|
|
2185
|
+
if (!isPlainObject(patch)) throw new DataError('The change is an object.');
|
|
2186
|
+
const allowed = ['name', 'label', 'description', 'displayColumn', 'access', 'customerScoped', 'history'];
|
|
2187
|
+
for (const key of Object.keys(patch)) {
|
|
2188
|
+
if (!allowed.includes(key)) {
|
|
2189
|
+
throw new DataError(`"${key}" can't be changed here; tables take ${allowed.join(', ')}.`);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return runner.transaction(() => {
|
|
2193
|
+
let current = table.name;
|
|
2194
|
+
if (patch.name !== undefined && patch.name !== table.name) {
|
|
2195
|
+
const issue = identifierIssue(patch.name, 'table');
|
|
2196
|
+
if (issue) throw new DataError(issue);
|
|
2197
|
+
if (schema.tables.some(t => t.name === patch.name)) {
|
|
2198
|
+
throw new DataError(`There is already a table "${String(patch.name)}".`, 'conflict');
|
|
2199
|
+
}
|
|
2200
|
+
dropTriggers(runner, table);
|
|
2201
|
+
runner.all(`DROP TABLE IF EXISTS ${q(ftsTableName(table))}`);
|
|
2202
|
+
runner.all(`ALTER TABLE ${q(table.name)} RENAME TO ${q(patch.name as string)}`);
|
|
2203
|
+
runner.all('UPDATE _ganju_tables SET name = ? WHERE id = ?', [patch.name as string, table.id]);
|
|
2204
|
+
current = patch.name as string;
|
|
2205
|
+
}
|
|
2206
|
+
const sets: string[] = [];
|
|
2207
|
+
const params: SqlValue[] = [];
|
|
2208
|
+
if ('label' in patch) {
|
|
2209
|
+
sets.push('label = ?');
|
|
2210
|
+
params.push(cleanText(patch.label, DATA_MAX_LABEL, 'label'));
|
|
2211
|
+
}
|
|
2212
|
+
if ('description' in patch) {
|
|
2213
|
+
sets.push('description = ?');
|
|
2214
|
+
params.push(cleanText(patch.description, DATA_MAX_DESCRIPTION, 'description'));
|
|
2215
|
+
}
|
|
2216
|
+
if ('access' in patch) {
|
|
2217
|
+
sets.push('access = ?');
|
|
2218
|
+
params.push(normaliseAccess(patch.access, table.access));
|
|
2219
|
+
}
|
|
2220
|
+
if ('customerScoped' in patch) {
|
|
2221
|
+
sets.push('customer_scoped = ?');
|
|
2222
|
+
params.push(flag(patch.customerScoped, 'customerScoped') ? 1 : 0);
|
|
2223
|
+
}
|
|
2224
|
+
if ('history' in patch) {
|
|
2225
|
+
sets.push('history = ?');
|
|
2226
|
+
params.push(flag(patch.history, 'history') ? 1 : 0);
|
|
2227
|
+
}
|
|
2228
|
+
if (sets.length) {
|
|
2229
|
+
runner.all(`UPDATE _ganju_tables SET ${sets.join(', ')} WHERE id = ?`, [...params, table.id]);
|
|
2230
|
+
}
|
|
2231
|
+
if ('displayColumn' in patch) {
|
|
2232
|
+
const display = patch.displayColumn === 'id' ? null : patch.displayColumn;
|
|
2233
|
+
if (display !== null && !table.columns.some(c => c.name === display)) {
|
|
2234
|
+
throw new DataError(`"displayColumn" is one of ${table.name}'s columns.`);
|
|
2235
|
+
}
|
|
2236
|
+
setDisplayColumn(runner, table.id, display === null ? null : String(display));
|
|
2237
|
+
}
|
|
2238
|
+
touchTable(runner, table.id);
|
|
2239
|
+
const updated = reload(runner, current);
|
|
2240
|
+
syncTableObjects(runner, updated, table.name);
|
|
2241
|
+
return updated;
|
|
2242
|
+
});
|
|
2243
|
+
};
|
|
2244
|
+
|
|
2245
|
+
export const dropTable = (
|
|
2246
|
+
runner: SqlRunner,
|
|
2247
|
+
schema: DataSchema,
|
|
2248
|
+
caller: DataCaller,
|
|
2249
|
+
name: string
|
|
2250
|
+
): { dropped: string } => {
|
|
2251
|
+
assertDdlAllowed(caller);
|
|
2252
|
+
const table = tableNamed(schema, name);
|
|
2253
|
+
const referencedBy = schema.tables.flatMap(other =>
|
|
2254
|
+
other.name === table.name
|
|
2255
|
+
? []
|
|
2256
|
+
: other.columns
|
|
2257
|
+
.filter(c => c.type === 'relation' && c.table === table.name)
|
|
2258
|
+
.map(c => `${other.name}.${c.name}`)
|
|
2259
|
+
);
|
|
2260
|
+
if (referencedBy.length) {
|
|
2261
|
+
throw new DataError(
|
|
2262
|
+
`${table.name} can't be dropped while ${referencedBy.join(', ')} point${referencedBy.length === 1 ? 's' : ''} at it; drop or change those columns first.`,
|
|
2263
|
+
'constraint',
|
|
2264
|
+
{ referencedBy }
|
|
2265
|
+
);
|
|
2266
|
+
}
|
|
2267
|
+
runner.transaction(() => {
|
|
2268
|
+
dropTriggers(runner, table);
|
|
2269
|
+
runner.all(`DROP TABLE IF EXISTS ${q(ftsTableName(table))}`);
|
|
2270
|
+
runner.all(`DROP TABLE ${q(table.name)}`);
|
|
2271
|
+
runner.all('UPDATE _ganju_tables SET display_column_id = NULL WHERE id = ?', [table.id]);
|
|
2272
|
+
runner.all('DELETE FROM _ganju_columns WHERE table_id = ?', [table.id]);
|
|
2273
|
+
runner.all('DELETE FROM _ganju_tables WHERE id = ?', [table.id]);
|
|
2274
|
+
});
|
|
2275
|
+
return { dropped: table.name };
|
|
2276
|
+
};
|
|
2277
|
+
|
|
2278
|
+
export const addColumn = (
|
|
2279
|
+
runner: SqlRunner,
|
|
2280
|
+
schema: DataSchema,
|
|
2281
|
+
caller: DataCaller,
|
|
2282
|
+
tableName: string,
|
|
2283
|
+
input: unknown
|
|
2284
|
+
): DataTable => {
|
|
2285
|
+
assertDdlAllowed(caller);
|
|
2286
|
+
const table = tableNamed(schema, tableName);
|
|
2287
|
+
if (table.columns.length >= DATA_MAX_COLUMNS) {
|
|
2288
|
+
throw new DataError(`A table has up to ${DATA_MAX_COLUMNS} columns.`, 'too_large');
|
|
2289
|
+
}
|
|
2290
|
+
const column = normaliseColumn(input, schema, table.name);
|
|
2291
|
+
if (table.columns.some(c => c.name === column.name)) {
|
|
2292
|
+
throw new DataError(`${table.name} already has a column "${column.name}".`, 'conflict');
|
|
2293
|
+
}
|
|
2294
|
+
if (column.required && (column.default === undefined || column.default === null)) {
|
|
2295
|
+
throw new DataError(
|
|
2296
|
+
`${table.name}.${column.name} is required, so existing rows need a value: give it a default, or add it optional, fill it in and then make it required.`
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
return runner.transaction(() => {
|
|
2300
|
+
const position = table.columns.length
|
|
2301
|
+
? Math.max(...table.columns.map(c => c.position)) + 1
|
|
2302
|
+
: 0;
|
|
2303
|
+
const id = insertColumnMeta(runner, schema, table.id, table.name, column, position, table.id);
|
|
2304
|
+
runner.all(
|
|
2305
|
+
`ALTER TABLE ${q(table.name)} ADD COLUMN ${columnDefinitionSql({ ...column, id, position })}`
|
|
2306
|
+
);
|
|
2307
|
+
touchTable(runner, table.id);
|
|
2308
|
+
const updated = reload(runner, table.name);
|
|
2309
|
+
syncTableObjects(runner, updated);
|
|
2310
|
+
return updated;
|
|
2311
|
+
});
|
|
2312
|
+
};
|
|
2313
|
+
|
|
2314
|
+
const REBUILD_KEYS = ['type', 'required', 'values', 'min', 'default', 'table', 'onDelete'] as const;
|
|
2315
|
+
|
|
2316
|
+
// Whether a column change copies the table. The database object asks before
|
|
2317
|
+
// running it: a copy has to switch foreign keys off first, at the start of
|
|
2318
|
+
// its own request.
|
|
2319
|
+
export const columnChangeNeedsRebuild = (
|
|
2320
|
+
schema: DataSchema,
|
|
2321
|
+
tableName: string,
|
|
2322
|
+
columnName: string,
|
|
2323
|
+
patch: unknown
|
|
2324
|
+
): boolean => {
|
|
2325
|
+
const table = schema.tables.find(t => t.name === tableName);
|
|
2326
|
+
const column = table?.columns.find(c => c.name === columnName);
|
|
2327
|
+
if (!column || !isPlainObject(patch)) return false;
|
|
2328
|
+
return REBUILD_KEYS.some(key => {
|
|
2329
|
+
if (!(key in patch)) return false;
|
|
2330
|
+
const next = patch[key];
|
|
2331
|
+
const now = column[key as keyof DataColumn];
|
|
2332
|
+
return JSON.stringify(next ?? null) !== JSON.stringify(now ?? null);
|
|
2333
|
+
});
|
|
2334
|
+
};
|
|
2335
|
+
|
|
2336
|
+
export const dropColumnNeedsRebuild = (
|
|
2337
|
+
schema: DataSchema,
|
|
2338
|
+
tableName: string,
|
|
2339
|
+
columnName: string
|
|
2340
|
+
): boolean => {
|
|
2341
|
+
const column = schema.tables
|
|
2342
|
+
.find(t => t.name === tableName)
|
|
2343
|
+
?.columns.find(c => c.name === columnName);
|
|
2344
|
+
return column?.type === 'relation';
|
|
2345
|
+
};
|
|
2346
|
+
|
|
2347
|
+
// A table copy: `columns` are the table's columns as they will be, each
|
|
2348
|
+
// converted from the column with the same id as it is now. The copy goes into
|
|
2349
|
+
// `<table>__new`, which then replaces the table. The metadata changes only at
|
|
2350
|
+
// that swap, so until then the table reads as it was.
|
|
2351
|
+
interface CopyColumn {
|
|
2352
|
+
draft: ColumnDraft;
|
|
2353
|
+
id: number;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// A copy too large for one request, kept in `_ganju_meta` while the object's
|
|
2357
|
+
// alarm runs it a step at a time.
|
|
2358
|
+
interface CopyJob extends DataTableCopy {
|
|
2359
|
+
tableId: number;
|
|
2360
|
+
columns: CopyColumn[];
|
|
2361
|
+
droppedId: number | null;
|
|
2362
|
+
afterId: number;
|
|
2363
|
+
// Failed attempts at the current step: a step killed for running too long
|
|
2364
|
+
// never reaches its catch, so the next one counts it.
|
|
2365
|
+
attempts: number;
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
const COPY_KEY = 'table_copy';
|
|
2369
|
+
const COPY_MAX_ATTEMPTS = 3;
|
|
2370
|
+
|
|
2371
|
+
const copyTemp = (table: Pick<DataTable, 'name'>) => `${table.name}__new`;
|
|
2372
|
+
|
|
2373
|
+
const COPIED_SYSTEM_COLUMNS = ['created_at', 'updated_at', 'version', 'created_by', 'updated_by', 'participant_id'];
|
|
2374
|
+
|
|
2375
|
+
const copyColumns = (table: DataTable, columns: CopyColumn[]): DataColumn[] =>
|
|
2376
|
+
columns.map((c, position) => ({ ...c.draft, id: c.id, position }));
|
|
2377
|
+
|
|
2378
|
+
// The INSERT … SELECT that copies the rows matching `where` into the copy.
|
|
2379
|
+
const copyRowsSql = (table: DataTable, columns: CopyColumn[], where = '') => {
|
|
2380
|
+
const stored = copyColumns(table, columns);
|
|
2381
|
+
const targets = ['id', ...stored.map(c => c.name), ...COPIED_SYSTEM_COLUMNS];
|
|
2382
|
+
const sources = [
|
|
2383
|
+
't.id',
|
|
2384
|
+
...columns.map((c, i) => {
|
|
2385
|
+
const from = table.columns.find(col => col.id === c.id)!;
|
|
2386
|
+
return conversionSql(from.type, stored[i], `t.${q(from.name)}`);
|
|
2387
|
+
}),
|
|
2388
|
+
...COPIED_SYSTEM_COLUMNS.map(name => `t.${name}`)
|
|
2389
|
+
];
|
|
2390
|
+
return `INSERT INTO ${q(copyTemp(table))} (${targets.map(q).join(', ')}) SELECT ${sources.join(', ')} FROM ${q(table.name)} t${where}`;
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
// Any row that wouldn't survive the conversion stops the change before
|
|
2394
|
+
// anything is written.
|
|
2395
|
+
const assertRowsConvert = (runner: SqlRunner, table: DataTable, columns: CopyColumn[]) => {
|
|
2396
|
+
const rules = (c: ColumnDraft) =>
|
|
2397
|
+
JSON.stringify([c.type, c.required, c.values, c.min, c.table]);
|
|
2398
|
+
for (const c of columns) {
|
|
2399
|
+
const from = table.columns.find(col => col.id === c.id)!;
|
|
2400
|
+
if (rules(from) === rules(c.draft)) continue;
|
|
2401
|
+
const to: DataColumn = { ...c.draft, id: c.id, position: 0 };
|
|
2402
|
+
const src = `t.${q(from.name)}`;
|
|
2403
|
+
const converted = conversionSql(from.type, to, src);
|
|
2404
|
+
const bad = runner.all(
|
|
2405
|
+
`SELECT t.id, ${src} AS value FROM ${q(table.name)} t WHERE ${invalidAfterConversionSql(to, src, converted)} LIMIT 10`
|
|
2406
|
+
);
|
|
2407
|
+
if (bad.length) {
|
|
2408
|
+
throw new DataError(
|
|
2409
|
+
`${table.name}.${from.name} can't become ${to.type}${to.required ? ', required' : ''}: ${bad.length === 10 ? 'these rows (and maybe more)' : 'these rows'} don't fit: ${bad
|
|
2410
|
+
.map(r => `id ${r.id} = ${JSON.stringify(r.value)}`)
|
|
2411
|
+
.join(', ')}. Nothing was changed.`,
|
|
2412
|
+
'constraint',
|
|
2413
|
+
{ rows: bad.map(r => ({ id: r.id, value: r.value })) }
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
// Replaces the table with its full copy and moves the metadata to match. Runs
|
|
2420
|
+
// inside the caller's transaction, with foreign keys off for the request
|
|
2421
|
+
// (dropping the table would otherwise cascade into the rows pointing at it);
|
|
2422
|
+
// a broken relation found after the swap throws, rolling it back.
|
|
2423
|
+
const swapInCopy = (
|
|
2424
|
+
runner: SqlRunner,
|
|
2425
|
+
schema: DataSchema,
|
|
2426
|
+
table: DataTable,
|
|
2427
|
+
columns: CopyColumn[],
|
|
2428
|
+
droppedId: number | null
|
|
2429
|
+
): DataTable => {
|
|
2430
|
+
dropTriggers(runner, table);
|
|
2431
|
+
runner.all(`DROP TABLE IF EXISTS ${q(ftsTableName(table))}`);
|
|
2432
|
+
if (droppedId !== null) {
|
|
2433
|
+
const dropped = table.columns.find(c => c.id === droppedId);
|
|
2434
|
+
if (dropped && table.displayColumn === dropped.name) setDisplayColumn(runner, table.id, null);
|
|
2435
|
+
runner.all('DELETE FROM _ganju_columns WHERE id = ?', [droppedId]);
|
|
2436
|
+
}
|
|
2437
|
+
columns.forEach((c, position) => {
|
|
2438
|
+
updateColumnMeta(runner, schema, table, c.id, c.draft);
|
|
2439
|
+
runner.all('UPDATE _ganju_columns SET position = ? WHERE id = ?', [position, c.id]);
|
|
2440
|
+
});
|
|
2441
|
+
runner.all(`DROP TABLE ${q(table.name)}`);
|
|
2442
|
+
runner.all(`ALTER TABLE ${q(copyTemp(table))} RENAME TO ${q(table.name)}`);
|
|
2443
|
+
touchTable(runner, table.id);
|
|
2444
|
+
const updated = reload(runner, table.name);
|
|
2445
|
+
syncTableObjects(runner, updated);
|
|
2446
|
+
const checked = [
|
|
2447
|
+
updated.name,
|
|
2448
|
+
...schema.tables
|
|
2449
|
+
.filter(t => t.name !== updated.name && t.columns.some(c => c.type === 'relation' && c.table === updated.name))
|
|
2450
|
+
.map(t => t.name)
|
|
2451
|
+
];
|
|
2452
|
+
for (const name of checked) {
|
|
2453
|
+
const violations = runner.all(`PRAGMA foreign_key_check(${q(name)})`);
|
|
2454
|
+
if (violations.length) {
|
|
2455
|
+
throw new DataError(
|
|
2456
|
+
`The change would leave ${violations.length} row${violations.length === 1 ? '' : 's'} in ${name} pointing at nothing; nothing was changed.`,
|
|
2457
|
+
'constraint'
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
return updated;
|
|
2462
|
+
};
|
|
2463
|
+
|
|
2464
|
+
// Copies the table with its columns changed to `columns`; the caller has
|
|
2465
|
+
// switched foreign keys off for this request. Up to DATA_REBUILD_MAX_ROWS rows
|
|
2466
|
+
// it is one transaction. A larger table only gets its empty copy here, and
|
|
2467
|
+
// the job is left for copyTableStep: the table stays as it is until the
|
|
2468
|
+
// swap, and readTableCopy says how far it got.
|
|
2469
|
+
const rebuildTable = (
|
|
2470
|
+
runner: SqlRunner,
|
|
2471
|
+
schema: DataSchema,
|
|
2472
|
+
caller: DataCaller,
|
|
2473
|
+
table: DataTable,
|
|
2474
|
+
columns: CopyColumn[],
|
|
2475
|
+
change: { column: string; kind: DataTableCopy['kind'] },
|
|
2476
|
+
droppedId: number | null = null
|
|
2477
|
+
): DataTable => {
|
|
2478
|
+
const rows = Number(first(runner, `SELECT count(*) AS n FROM ${q(table.name)}`)?.n ?? 0);
|
|
2479
|
+
assertRowsConvert(runner, table, columns);
|
|
2480
|
+
const temp = copyTemp(table);
|
|
2481
|
+
if (rows <= DATA_REBUILD_MAX_ROWS) {
|
|
2482
|
+
return runner.transaction(() => {
|
|
2483
|
+
runner.all(`DROP TABLE IF EXISTS ${q(temp)}`);
|
|
2484
|
+
runner.all(createTableSql(temp, copyColumns(table, columns)));
|
|
2485
|
+
runner.all(copyRowsSql(table, columns));
|
|
2486
|
+
return swapInCopy(runner, schema, table, columns, droppedId);
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
return runner.transaction(() => {
|
|
2490
|
+
runner.all(`DROP TABLE IF EXISTS ${q(temp)}`);
|
|
2491
|
+
runner.all(createTableSql(temp, copyColumns(table, columns)));
|
|
2492
|
+
const job: CopyJob = {
|
|
2493
|
+
table: table.name,
|
|
2494
|
+
column: change.column,
|
|
2495
|
+
kind: change.kind,
|
|
2496
|
+
rows,
|
|
2497
|
+
copied: 0,
|
|
2498
|
+
startedAt: new Date().toISOString(),
|
|
2499
|
+
actor: caller.actor,
|
|
2500
|
+
status: 'running',
|
|
2501
|
+
error: null,
|
|
2502
|
+
tableId: table.id,
|
|
2503
|
+
columns,
|
|
2504
|
+
droppedId,
|
|
2505
|
+
afterId: 0,
|
|
2506
|
+
attempts: 0
|
|
2507
|
+
};
|
|
2508
|
+
setMeta(runner, COPY_KEY, JSON.stringify(job));
|
|
2509
|
+
return table;
|
|
2510
|
+
});
|
|
2511
|
+
};
|
|
2512
|
+
|
|
2513
|
+
const readCopyJob = (runner: SqlRunner): CopyJob | null => {
|
|
2514
|
+
const raw = getMeta(runner, COPY_KEY);
|
|
2515
|
+
if (!raw) return null;
|
|
2516
|
+
try {
|
|
2517
|
+
return JSON.parse(raw) as CopyJob;
|
|
2518
|
+
} catch {
|
|
2519
|
+
return null;
|
|
2520
|
+
}
|
|
2521
|
+
};
|
|
2522
|
+
|
|
2523
|
+
// The background table copy, running or failed, if there is one.
|
|
2524
|
+
export const readTableCopy = (runner: SqlRunner): DataTableCopy | null => {
|
|
2525
|
+
const job = readCopyJob(runner);
|
|
2526
|
+
if (!job) return null;
|
|
2527
|
+
return {
|
|
2528
|
+
table: job.table,
|
|
2529
|
+
column: job.column,
|
|
2530
|
+
kind: job.kind,
|
|
2531
|
+
rows: job.rows,
|
|
2532
|
+
copied: job.copied,
|
|
2533
|
+
startedAt: job.startedAt,
|
|
2534
|
+
actor: job.actor,
|
|
2535
|
+
status: job.status,
|
|
2536
|
+
error: job.error
|
|
2537
|
+
};
|
|
2538
|
+
};
|
|
2539
|
+
|
|
2540
|
+
// Whether a write would change rows of the table being copied, which the
|
|
2541
|
+
// copy would then miss: an insert, update or delete on it, or a delete in a
|
|
2542
|
+
// table it points at with cascade or set null, directly or through a chain of
|
|
2543
|
+
// cascades. Only those wait for the copy. Every other write goes on: row ids
|
|
2544
|
+
// survive the copy, so a relation pointing into the table stays valid.
|
|
2545
|
+
export const writeReachesCopy = (
|
|
2546
|
+
schema: DataSchema,
|
|
2547
|
+
copy: Pick<DataTableCopy, 'table'>,
|
|
2548
|
+
rawArgs: unknown
|
|
2549
|
+
): boolean => {
|
|
2550
|
+
const reaching = new Set([copy.table]);
|
|
2551
|
+
const queue = [copy.table];
|
|
2552
|
+
while (queue.length) {
|
|
2553
|
+
const name = queue.shift()!;
|
|
2554
|
+
const table = schema.tables.find(t => t.name === name);
|
|
2555
|
+
for (const column of table?.columns ?? []) {
|
|
2556
|
+
if (column.type !== 'relation' || !column.table) continue;
|
|
2557
|
+
const onDelete = column.onDelete ?? 'restrict';
|
|
2558
|
+
// A cleared relation changes the copied table's own rows only.
|
|
2559
|
+
const reaches =
|
|
2560
|
+
onDelete === 'cascade' || (onDelete === 'set_null' && name === copy.table);
|
|
2561
|
+
if (reaches && !reaching.has(column.table)) {
|
|
2562
|
+
reaching.add(column.table);
|
|
2563
|
+
queue.push(column.table);
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
let operations: unknown;
|
|
2568
|
+
try {
|
|
2569
|
+
operations = tidyArgs(rawArgs).operations;
|
|
2570
|
+
} catch {
|
|
2571
|
+
return false;
|
|
2572
|
+
}
|
|
2573
|
+
if (!Array.isArray(operations)) return false;
|
|
2574
|
+
return operations.some(
|
|
2575
|
+
op =>
|
|
2576
|
+
isPlainObject(op) &&
|
|
2577
|
+
(op.table === copy.table || (op.op === 'delete' && reaching.has(String(op.table))))
|
|
2578
|
+
);
|
|
2579
|
+
};
|
|
2580
|
+
|
|
2581
|
+
// Forgets a failed copy once the owner has seen it, or a new change starts.
|
|
2582
|
+
export const clearFailedTableCopy = (runner: SqlRunner): void => {
|
|
2583
|
+
if (readCopyJob(runner)?.status === 'failed') setMeta(runner, COPY_KEY, null);
|
|
2584
|
+
};
|
|
2585
|
+
|
|
2586
|
+
// One step of the background copy: DATA_COPY_STEP_ROWS rows, or the swap
|
|
2587
|
+
// once every row is across. Foreign keys are off for the request, as for
|
|
2588
|
+
// the inline copy. A step that fails ends the job: the copy is dropped, the
|
|
2589
|
+
// table is left as it was, and readTableCopy carries the reason.
|
|
2590
|
+
export const copyTableStep = (
|
|
2591
|
+
runner: SqlRunner,
|
|
2592
|
+
schema: DataSchema
|
|
2593
|
+
): 'more' | 'done' | 'failed' | 'idle' => {
|
|
2594
|
+
const job = readCopyJob(runner);
|
|
2595
|
+
if (!job || job.status !== 'running') return 'idle';
|
|
2596
|
+
const fail = (message: string) => {
|
|
2597
|
+
runner.transaction(() => {
|
|
2598
|
+
runner.all(`DROP TABLE IF EXISTS ${q(copyTemp({ name: job.table }))}`);
|
|
2599
|
+
setMeta(
|
|
2600
|
+
runner,
|
|
2601
|
+
COPY_KEY,
|
|
2602
|
+
JSON.stringify({ ...job, status: 'failed', error: message })
|
|
2603
|
+
);
|
|
2604
|
+
});
|
|
2605
|
+
return 'failed' as const;
|
|
2606
|
+
};
|
|
2607
|
+
const table = schema.tables.find(t => t.id === job.tableId);
|
|
2608
|
+
if (!table) return fail(`${job.table} is gone.`);
|
|
2609
|
+
if (job.attempts >= COPY_MAX_ATTEMPTS) {
|
|
2610
|
+
return fail(`Copying ${table.name} stopped ${job.attempts} times at the same point. Nothing was changed.`);
|
|
2611
|
+
}
|
|
2612
|
+
setMeta(runner, COPY_KEY, JSON.stringify({ ...job, attempts: job.attempts + 1 }));
|
|
2613
|
+
try {
|
|
2614
|
+
const next = first(
|
|
2615
|
+
runner,
|
|
2616
|
+
`SELECT max(id) AS upper, count(*) AS n FROM (SELECT id FROM ${q(table.name)} WHERE id > ? ORDER BY id LIMIT ?)`,
|
|
2617
|
+
[job.afterId, DATA_COPY_STEP_ROWS]
|
|
2618
|
+
);
|
|
2619
|
+
const n = Number(next?.n ?? 0);
|
|
2620
|
+
if (n === 0) {
|
|
2621
|
+
runner.transaction(() => {
|
|
2622
|
+
swapInCopy(runner, schema, table, job.columns, job.droppedId);
|
|
2623
|
+
setMeta(runner, COPY_KEY, null);
|
|
2624
|
+
});
|
|
2625
|
+
return 'done';
|
|
2626
|
+
}
|
|
2627
|
+
const upper = Number(next!.upper);
|
|
2628
|
+
runner.transaction(() => {
|
|
2629
|
+
runner.all(copyRowsSql(table, job.columns, ' WHERE t.id > ? AND t.id <= ?'), [
|
|
2630
|
+
job.afterId,
|
|
2631
|
+
upper
|
|
2632
|
+
]);
|
|
2633
|
+
setMeta(
|
|
2634
|
+
runner,
|
|
2635
|
+
COPY_KEY,
|
|
2636
|
+
JSON.stringify({ ...job, afterId: upper, copied: job.copied + n, attempts: 0 })
|
|
2637
|
+
);
|
|
2638
|
+
});
|
|
2639
|
+
return 'more';
|
|
2640
|
+
} catch (error) {
|
|
2641
|
+
return fail(translateSqliteError(schema, table, error).message);
|
|
2642
|
+
}
|
|
2643
|
+
};
|
|
2644
|
+
|
|
2645
|
+
export const updateColumn = (
|
|
2646
|
+
runner: SqlRunner,
|
|
2647
|
+
schema: DataSchema,
|
|
2648
|
+
caller: DataCaller,
|
|
2649
|
+
tableName: string,
|
|
2650
|
+
columnName: string,
|
|
2651
|
+
patch: unknown
|
|
2652
|
+
): DataTable => {
|
|
2653
|
+
assertDdlAllowed(caller);
|
|
2654
|
+
const table = tableNamed(schema, tableName);
|
|
2655
|
+
const column = columnNamed(table, columnName);
|
|
2656
|
+
if (!isPlainObject(patch)) throw new DataError('The change is an object.');
|
|
2657
|
+
const merged = { ...toTableInput(table).columns.find(c => c.name === column.name)!, ...patch };
|
|
2658
|
+
for (const key of Object.keys(merged)) {
|
|
2659
|
+
if ((merged as Record<string, unknown>)[key] === null) delete (merged as Record<string, unknown>)[key];
|
|
2660
|
+
}
|
|
2661
|
+
if (merged.type !== 'enum') delete merged.values;
|
|
2662
|
+
if (merged.type !== 'relation') {
|
|
2663
|
+
delete merged.table;
|
|
2664
|
+
delete merged.onDelete;
|
|
2665
|
+
}
|
|
2666
|
+
if (!NUMERIC_TYPES.has(merged.type as DataColumnType)) delete merged.min;
|
|
2667
|
+
const draft = normaliseColumn(merged, schema, table.name);
|
|
2668
|
+
if (draft.name !== column.name && table.columns.some(c => c.name === draft.name)) {
|
|
2669
|
+
throw new DataError(`${table.name} already has a column "${draft.name}".`, 'conflict');
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
if (columnChangeNeedsRebuild(schema, table.name, column.name, patch)) {
|
|
2673
|
+
return rebuildTable(
|
|
2674
|
+
runner,
|
|
2675
|
+
schema,
|
|
2676
|
+
caller,
|
|
2677
|
+
table,
|
|
2678
|
+
table.columns.map(c => ({ draft: c.id === column.id ? draft : c, id: c.id })),
|
|
2679
|
+
{ column: column.name, kind: 'change' }
|
|
2680
|
+
);
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
return runner.transaction(() => {
|
|
2684
|
+
if (draft.name !== column.name) {
|
|
2685
|
+
dropTriggers(runner, table);
|
|
2686
|
+
runner.all(`DROP TABLE IF EXISTS ${q(ftsTableName(table))}`);
|
|
2687
|
+
runner.all(
|
|
2688
|
+
`ALTER TABLE ${q(table.name)} RENAME COLUMN ${q(column.name)} TO ${q(draft.name)}`
|
|
2689
|
+
);
|
|
2690
|
+
}
|
|
2691
|
+
updateColumnMeta(runner, schema, table, column.id, draft);
|
|
2692
|
+
touchTable(runner, table.id);
|
|
2693
|
+
const updated = reload(runner, table.name);
|
|
2694
|
+
syncTableObjects(runner, updated);
|
|
2695
|
+
return updated;
|
|
2696
|
+
});
|
|
2697
|
+
};
|
|
2698
|
+
|
|
2699
|
+
export const dropColumn = (
|
|
2700
|
+
runner: SqlRunner,
|
|
2701
|
+
schema: DataSchema,
|
|
2702
|
+
caller: DataCaller,
|
|
2703
|
+
tableName: string,
|
|
2704
|
+
columnName: string
|
|
2705
|
+
): DataTable => {
|
|
2706
|
+
assertDdlAllowed(caller);
|
|
2707
|
+
const table = tableNamed(schema, tableName);
|
|
2708
|
+
const column = columnNamed(table, columnName);
|
|
2709
|
+
if (table.columns.length === 1) {
|
|
2710
|
+
throw new DataError(`${table.name} needs at least one column; drop the table instead.`);
|
|
2711
|
+
}
|
|
2712
|
+
if (dropColumnNeedsRebuild(schema, table.name, column.name)) {
|
|
2713
|
+
return rebuildTable(
|
|
2714
|
+
runner,
|
|
2715
|
+
schema,
|
|
2716
|
+
caller,
|
|
2717
|
+
table,
|
|
2718
|
+
table.columns.filter(c => c.id !== column.id).map(c => ({ draft: c, id: c.id })),
|
|
2719
|
+
{ column: column.name, kind: 'drop' },
|
|
2720
|
+
column.id
|
|
2721
|
+
);
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
return runner.transaction(() => {
|
|
2725
|
+
dropTriggers(runner, table);
|
|
2726
|
+
runner.all(`DROP TABLE IF EXISTS ${q(ftsTableName(table))}`);
|
|
2727
|
+
for (const index of tableObjects(runner, 'index', table.name)) {
|
|
2728
|
+
if (index.name.endsWith(`_${column.id}`) && (index.name.startsWith('ix_') || index.name.startsWith('ux_'))) {
|
|
2729
|
+
runner.all(`DROP INDEX IF EXISTS ${q(index.name)}`);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
runner.all(`ALTER TABLE ${q(table.name)} DROP COLUMN ${q(column.name)}`);
|
|
2733
|
+
if (table.displayColumn === column.name) setDisplayColumn(runner, table.id, null);
|
|
2734
|
+
runner.all('DELETE FROM _ganju_columns WHERE id = ?', [column.id]);
|
|
2735
|
+
touchTable(runner, table.id);
|
|
2736
|
+
const updated = reload(runner, table.name);
|
|
2737
|
+
syncTableObjects(runner, updated);
|
|
2738
|
+
return updated;
|
|
2739
|
+
});
|
|
2740
|
+
};
|
|
2741
|
+
|
|
2742
|
+
// Export
|
|
2743
|
+
|
|
2744
|
+
// A table's rows as stored, every column included, from after `afterId`,
|
|
2745
|
+
// one at a time: an export stops reading once it has a step's worth.
|
|
2746
|
+
export const exportRows = (
|
|
2747
|
+
runner: SqlRunner,
|
|
2748
|
+
table: DataTable,
|
|
2749
|
+
afterId: number
|
|
2750
|
+
): Iterable<SqlRow> =>
|
|
2751
|
+
runner.iterate(`SELECT * FROM ${q(table.name)} WHERE id > ? ORDER BY id`, [
|
|
2752
|
+
afterId
|
|
2753
|
+
]);
|