@ganju/utils 0.0.20 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/constants.d.ts +16 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +43 -0
  4. package/dist/data.d.ts +7 -5
  5. package/dist/data.d.ts.map +1 -1
  6. package/dist/data.js +27 -3
  7. package/dist/dataApi.d.ts +82 -6
  8. package/dist/dataApi.d.ts.map +1 -1
  9. package/dist/dataApi.js +47 -4
  10. package/dist/dataAttachment.d.ts +8 -0
  11. package/dist/dataAttachment.d.ts.map +1 -0
  12. package/dist/dataAttachment.js +19 -0
  13. package/dist/dataCompiler.d.ts +4 -0
  14. package/dist/dataCompiler.d.ts.map +1 -1
  15. package/dist/dataCompiler.js +27 -2
  16. package/dist/dataDdl.d.ts.map +1 -1
  17. package/dist/dataDdl.js +5 -0
  18. package/dist/dataEngine.d.ts +25 -3
  19. package/dist/dataEngine.d.ts.map +1 -1
  20. package/dist/dataEngine.js +508 -14
  21. package/dist/dataImport.d.ts +3 -0
  22. package/dist/dataImport.d.ts.map +1 -1
  23. package/dist/dataImport.js +12 -2
  24. package/dist/dataIndexAdvice.d.ts +36 -0
  25. package/dist/dataIndexAdvice.d.ts.map +1 -0
  26. package/dist/dataIndexAdvice.js +89 -0
  27. package/dist/dataRpc.d.ts +20 -1
  28. package/dist/dataRpc.d.ts.map +1 -1
  29. package/dist/dataSheet.d.ts +34 -0
  30. package/dist/dataSheet.d.ts.map +1 -0
  31. package/dist/dataSheet.js +100 -0
  32. package/dist/dataTools.js +3 -3
  33. package/dist/dataTriggers.d.ts +66 -4
  34. package/dist/dataTriggers.d.ts.map +1 -1
  35. package/dist/dataTriggers.js +446 -20
  36. package/dist/dataTypes.d.ts +69 -0
  37. package/dist/dataTypes.d.ts.map +1 -1
  38. package/dist/dataTypes.js +21 -1
  39. package/dist/index.d.ts +91 -7
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -0
  42. package/dist/localizeDataError.d.ts.map +1 -1
  43. package/dist/localizeDataError.js +109 -3
  44. package/dist/schema.d.ts +67 -6
  45. package/dist/schema.d.ts.map +1 -1
  46. package/dist/schema.js +57 -2
  47. package/dist/untrustedContent.d.ts +7 -0
  48. package/dist/untrustedContent.d.ts.map +1 -1
  49. package/dist/untrustedContent.js +13 -1
  50. package/dist/whatsappWebhook.d.ts +28 -0
  51. package/dist/whatsappWebhook.d.ts.map +1 -0
  52. package/dist/whatsappWebhook.js +30 -0
  53. package/package.json +1 -1
  54. package/src/constants.ts +46 -0
  55. package/src/data.ts +33 -3
  56. package/src/dataApi.ts +54 -4
  57. package/src/dataAttachment.ts +23 -0
  58. package/src/dataCompiler.ts +41 -1
  59. package/src/dataDdl.ts +7 -0
  60. package/src/dataEngine.ts +680 -15
  61. package/src/dataImport.ts +16 -1
  62. package/src/dataIndexAdvice.ts +127 -0
  63. package/src/dataRpc.ts +27 -2
  64. package/src/dataSheet.ts +147 -0
  65. package/src/dataTools.ts +3 -3
  66. package/src/dataTriggers.ts +599 -20
  67. package/src/dataTypes.ts +108 -0
  68. package/src/index.ts +15 -0
  69. package/src/localizeDataError.ts +183 -4
  70. package/src/schema.ts +60 -2
  71. package/src/untrustedContent.ts +15 -0
  72. package/src/whatsappWebhook.ts +55 -0
package/src/dataImport.ts CHANGED
@@ -482,6 +482,9 @@ export interface ImportedRow {
482
482
  errors: string[];
483
483
  // Values read with a guess; the row is still imported.
484
484
  warnings: NumberGuess[];
485
+ // The unique columns whose value an earlier line already has (each also
486
+ // one of `errors`): a sheet sync lets the database say so instead.
487
+ repeated: string[];
485
488
  }
486
489
 
487
490
  // Every row of the file as the values it will insert, with each problem
@@ -512,6 +515,7 @@ export const importRows = (
512
515
  const values: Record<string, unknown> = {};
513
516
  const errors = missing.map(c => `${c.name} is required.`);
514
517
  const warnings: NumberGuess[] = [];
518
+ const repeated: string[] = [];
515
519
  mapping.forEach((name, i) => {
516
520
  if (name === null) return;
517
521
  const column = columns.get(name);
@@ -534,13 +538,14 @@ export const importRows = (
534
538
  const first = lines.get(key);
535
539
  if (first === undefined) lines.set(key, line);
536
540
  else {
541
+ repeated.push(column.name);
537
542
  errors.push(
538
543
  `${column.name}: "${text(row[i])}" is already on line ${first}, and this column takes each value once.`
539
544
  );
540
545
  }
541
546
  }
542
547
  });
543
- return { index, line, values, errors, warnings };
548
+ return { index, line, values, errors, warnings, repeated };
544
549
  });
545
550
  };
546
551
 
@@ -555,3 +560,13 @@ export const csvLine = (cells: ImportCell[]) =>
555
560
 
556
561
  export { detectSeparator, parseCsv } from './dataCsv';
557
562
  export type { ParsedCsv } from './dataCsv';
563
+ export {
564
+ DATA_CSV_MIME,
565
+ DATA_SPREADSHEET_MIME,
566
+ cellFromSheet,
567
+ isTabularMimeType,
568
+ readCsvText,
569
+ readSheet,
570
+ readTabularFile
571
+ } from './dataSheet';
572
+ export type { SheetJs, TabularFile } from './dataSheet';
@@ -0,0 +1,127 @@
1
+ // Which columns of a database are worth an index, and which indexes nobody
2
+ // uses, from the API's daily query stats and the database's schema. Pure, so
3
+ // the daily job and the tests apply the same rule.
4
+ import { constants } from './constants';
5
+ import { isIndexedColumn } from './dataDdl';
6
+ import type { DataSchema } from './dataTypes';
7
+
8
+ // A column's (or, with column '', a table's) sums over a window.
9
+ export interface IndexAdviceStat {
10
+ tableName: string;
11
+ columnName: string;
12
+ queries: number;
13
+ rowsRead: number;
14
+ rowsReturned: number;
15
+ rowsWritten: number;
16
+ }
17
+
18
+ export interface IndexAdviceCandidate {
19
+ tableName: string;
20
+ columnName: string;
21
+ kind: 'add' | 'unused';
22
+ queries: number;
23
+ avgRowsRead: number;
24
+ avgRowsReturned: number;
25
+ rowsWritten: number;
26
+ }
27
+
28
+ export interface IndexAdviceInput {
29
+ schema: DataSchema;
30
+ // Sums over the last 7 days, and over the last 30.
31
+ week: IndexAdviceStat[];
32
+ month: IndexAdviceStat[];
33
+ // Whether stats go back the whole 30 days, so "no query used it" means it.
34
+ monthCovered: boolean;
35
+ // "table.column" made filterable, or "table" created, in the last 30 days.
36
+ recentlyIndexed: Set<string>;
37
+ }
38
+
39
+ // Indexes the owner can add or remove: a column marked filterable. Unique and
40
+ // relation columns are indexed for what they are, and stay.
41
+ const canIndex = (type: string) => type !== 'json' && type !== 'long_text';
42
+
43
+ export const adviseIndexes = (
44
+ input: IndexAdviceInput
45
+ ): IndexAdviceCandidate[] => {
46
+ const out: IndexAdviceCandidate[] = [];
47
+ const tables = new Map(input.schema.tables.map(t => [t.name, t]));
48
+
49
+ for (const stat of input.week) {
50
+ if (!stat.columnName) continue;
51
+ const table = tables.get(stat.tableName);
52
+ const column = table?.columns.find(c => c.name === stat.columnName);
53
+ if (!column || isIndexedColumn(column) || !canIndex(column.type)) continue;
54
+ if (stat.queries < constants.DATA_INDEX_ADVICE_MIN_QUERIES) continue;
55
+ const avgRead = stat.rowsRead / stat.queries;
56
+ const avgReturned = stat.rowsReturned / stat.queries;
57
+ if (avgRead < constants.DATA_INDEX_ADVICE_MIN_ROWS_READ) continue;
58
+ // Scanning, not just a small table: many rows read for each one returned.
59
+ if (
60
+ stat.rowsRead <
61
+ constants.DATA_INDEX_ADVICE_MIN_READ_RATIO * stat.rowsReturned
62
+ ) {
63
+ continue;
64
+ }
65
+ out.push({
66
+ tableName: stat.tableName,
67
+ columnName: stat.columnName,
68
+ kind: constants.DATA_INDEX_ADVICE_ADD,
69
+ queries: stat.queries,
70
+ avgRowsRead: Math.round(avgRead),
71
+ avgRowsReturned: Math.round(avgReturned),
72
+ rowsWritten: 0
73
+ });
74
+ }
75
+
76
+ if (input.monthCovered) {
77
+ const used = new Set(
78
+ input.month
79
+ .filter(s => s.columnName && s.queries > 0)
80
+ .map(s => `${s.tableName}.${s.columnName}`)
81
+ );
82
+ for (const table of input.schema.tables) {
83
+ const totals = input.month.find(
84
+ s => s.tableName === table.name && s.columnName === ''
85
+ );
86
+ const written = totals?.rowsWritten ?? 0;
87
+ if (written < constants.DATA_INDEX_UNUSED_MIN_ROWS_WRITTEN) continue;
88
+ if (written <= (totals?.queries ?? 0)) continue;
89
+ if (input.recentlyIndexed.has(table.name)) continue;
90
+ for (const column of table.columns) {
91
+ if (!column.filterable || column.unique) continue;
92
+ if (column.type === 'relation' || column.type === 'file') continue;
93
+ const key = `${table.name}.${column.name}`;
94
+ if (used.has(key) || input.recentlyIndexed.has(key)) continue;
95
+ out.push({
96
+ tableName: table.name,
97
+ columnName: column.name,
98
+ kind: constants.DATA_INDEX_ADVICE_UNUSED,
99
+ queries: 0,
100
+ avgRowsRead: 0,
101
+ avgRowsReturned: 0,
102
+ rowsWritten: written
103
+ });
104
+ }
105
+ }
106
+ }
107
+ return out;
108
+ };
109
+
110
+ // A dismissed suggestion comes back once its numbers have doubled.
111
+ export const adviceDoubled = (
112
+ current: { queries: number; avgRowsRead: number; rowsWritten: number },
113
+ dismissed: {
114
+ queries: number | null;
115
+ avgRowsRead: number | null;
116
+ rowsWritten: number | null;
117
+ }
118
+ ): boolean =>
119
+ (dismissed.queries !== null &&
120
+ dismissed.queries > 0 &&
121
+ current.queries >= 2 * dismissed.queries) ||
122
+ (dismissed.avgRowsRead !== null &&
123
+ dismissed.avgRowsRead > 0 &&
124
+ current.avgRowsRead >= 2 * dismissed.avgRowsRead) ||
125
+ (dismissed.rowsWritten !== null &&
126
+ dismissed.rowsWritten > 0 &&
127
+ current.rowsWritten >= 2 * dismissed.rowsWritten);
package/src/dataRpc.ts CHANGED
@@ -4,11 +4,16 @@
4
4
  import type {
5
5
  DataCaller,
6
6
  DataDeleteImpact,
7
+ DataFileLinkChange,
8
+ DataFileLinks,
9
+ DataFileLinksReplaced,
7
10
  DataResult,
8
11
  DataSchema,
9
12
  DataTable,
10
- DataTableCopy
13
+ DataTableCopy,
14
+ DataTableSync
11
15
  } from './dataTypes';
16
+ import type { SheetSyncArgs } from './dataEngine';
12
17
  import type { DataQuery } from './dataQueries';
13
18
  import type { DataTrigger, DataTriggerWithSecret, SavedTrigger } from './dataTriggers';
14
19
 
@@ -56,7 +61,8 @@ export interface DatabaseInfo {
56
61
  timeZone: string | null;
57
62
  schemaVersion: number;
58
63
  size: number;
59
- tables: { name: string; label: string | null; columns: number }[];
64
+ // `rows` is about how many: the highest id, read from the index.
65
+ tables: { name: string; label: string | null; columns: number; rows: number }[];
60
66
  // A column change copying a large table in the background: table changes,
61
67
  // and writes that would change its rows, wait while it runs.
62
68
  copy: DataTableCopy | null;
@@ -106,6 +112,12 @@ export interface ProjectDatabaseRpc {
106
112
  dismissCopy(caller: DataCaller): R<{ copy: null }>;
107
113
  describe(caller: DataCaller, args?: unknown): R<unknown>;
108
114
  query(caller: DataCaller, args: unknown): R<unknown>;
115
+ // A row as a trigger event carries it (`id`, or the newest): for a test
116
+ // send and for a run whose event was too large to carry its row.
117
+ eventRow(
118
+ caller: DataCaller,
119
+ args: { table: string; id?: number }
120
+ ): R<Record<string, unknown> | null>;
109
121
  write(
110
122
  caller: DataCaller,
111
123
  args: unknown
@@ -114,6 +126,19 @@ export interface ProjectDatabaseRpc {
114
126
  caller: DataCaller,
115
127
  args: { table: string; ids?: number[] }
116
128
  ): R<DataDeleteImpact>;
129
+ // The rows that link knowledge items, and moving or clearing those links.
130
+ fileLinks(caller: DataCaller, args: { uris: string[] }): R<DataFileLinks>;
131
+ replaceFileLinks(
132
+ caller: DataCaller,
133
+ args: { changes: DataFileLinkChange[] }
134
+ ): R<DataFileLinksReplaced>;
135
+ // A synced table's rows made what its sheet says, all or nothing; and why
136
+ // a sync that never reached the rows was refused.
137
+ syncRows(caller: DataCaller, args: SheetSyncArgs): R<DataTableSync>;
138
+ recordSyncFailure(
139
+ caller: DataCaller,
140
+ args: { table: string; error: string; title?: string | null; version?: string | null }
141
+ ): R<DataTableSync | null>;
117
142
  history(
118
143
  caller: DataCaller,
119
144
  args: { table: string; id: number; limit?: number; cursor?: number }
@@ -0,0 +1,147 @@
1
+ // Reading a spreadsheet or CSV from the project's knowledge into header and
2
+ // rows, for data-admin's import and a table's sheet sync alike. SheetJS is
3
+ // passed in, so each worker loads it only when a file needs it.
4
+ import { parseCsv } from './dataCsv';
5
+ import { DATA_SHEET_MAX_BYTES, DataError } from './dataTypes';
6
+ import type { ImportCell } from './dataImport';
7
+
8
+ export const DATA_SPREADSHEET_MIME =
9
+ /spreadsheetml|ms-excel|opendocument\.spreadsheet/i;
10
+ export const DATA_CSV_MIME = /csv|text\/plain|tab-separated/i;
11
+
12
+ // Whether a knowledge item can be a table's source.
13
+ export const isTabularMimeType = (mimeType: string | null | undefined) =>
14
+ !!mimeType &&
15
+ (DATA_SPREADSHEET_MIME.test(mimeType) || DATA_CSV_MIME.test(mimeType));
16
+
17
+ // The parts of SheetJS used here, so this package doesn't depend on it.
18
+ export interface SheetJs {
19
+ read(
20
+ data: ArrayBuffer,
21
+ options: Record<string, unknown>
22
+ ): {
23
+ SheetNames: string[];
24
+ Sheets: Record<string, unknown>;
25
+ };
26
+ utils: {
27
+ sheet_to_json<T>(sheet: unknown, options: Record<string, unknown>): T[];
28
+ decode_range(ref: string): { s: { r: number } };
29
+ };
30
+ }
31
+
32
+ export interface TabularFile {
33
+ headers: string[];
34
+ rows: ImportCell[][];
35
+ // Each row's line in the file or sheet, counting the header.
36
+ lines: number[];
37
+ }
38
+
39
+ const pad = (n: number) => String(n).padStart(2, '0');
40
+
41
+ // A spreadsheet's own date as the text the database reads, as the
42
+ // dashboard's import does: a date alone when it has no time, else a
43
+ // wall-clock time.
44
+ export const cellFromSheet = (cell: unknown): ImportCell => {
45
+ if (cell instanceof Date) {
46
+ const date = `${cell.getFullYear()}-${pad(cell.getMonth() + 1)}-${pad(cell.getDate())}`;
47
+ if (!cell.getHours() && !cell.getMinutes() && !cell.getSeconds()) {
48
+ return date;
49
+ }
50
+ return `${date}T${pad(cell.getHours())}:${pad(cell.getMinutes())}:${pad(cell.getSeconds())}`;
51
+ }
52
+ return typeof cell === 'string' ||
53
+ typeof cell === 'number' ||
54
+ typeof cell === 'boolean'
55
+ ? cell
56
+ : null;
57
+ };
58
+
59
+ // One sheet of an Excel or OpenDocument file: the first unless named.
60
+ // Blank lines are left out.
61
+ export const readSheet = (
62
+ XLSX: SheetJs,
63
+ bytes: ArrayBuffer,
64
+ uri: string,
65
+ sheetName?: string | null
66
+ ): TabularFile => {
67
+ // Only the sheet being read is parsed: a workbook unpacks to many times
68
+ // its size, and a worker has 128 MiB.
69
+ const book = XLSX.read(bytes, {
70
+ cellDates: true,
71
+ dense: true,
72
+ sheets: sheetName ?? 0
73
+ });
74
+ const name = sheetName ?? book.SheetNames[0];
75
+ const sheet = name ? book.Sheets[name] : undefined;
76
+ if (!sheet) {
77
+ throw new DataError(
78
+ `${uri} has no sheet "${sheetName}". Sheets: ${book.SheetNames.join(', ')}.`,
79
+ 'not_found'
80
+ );
81
+ }
82
+ const all = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
83
+ header: 1,
84
+ raw: true,
85
+ defval: null,
86
+ blankrows: true
87
+ });
88
+ // The sheet's range may start below row 1.
89
+ const ref = (sheet as { '!ref'?: string })['!ref'];
90
+ const firstLine = ref ? XLSX.utils.decode_range(ref).s.r + 1 : 1;
91
+ const [header = [], ...body] = all;
92
+ const rows: ImportCell[][] = [];
93
+ const lines: number[] = [];
94
+ body.forEach((row, i) => {
95
+ const cells = row.map(cellFromSheet);
96
+ if (cells.every(c => c === null || c === '')) return;
97
+ rows.push(cells);
98
+ lines.push(firstLine + 1 + i);
99
+ });
100
+ return {
101
+ headers: header.map(h => (h === null ? '' : String(h))),
102
+ rows,
103
+ lines
104
+ };
105
+ };
106
+
107
+ // A CSV's text, with each row's line as a sheet's would be.
108
+ export const readCsvText = (text: string): TabularFile => {
109
+ const parsed = parseCsv(text);
110
+ return {
111
+ headers: parsed.headers,
112
+ rows: parsed.rows,
113
+ lines: parsed.lines
114
+ };
115
+ };
116
+
117
+ // A stored file, read by its type: a spreadsheet through SheetJS (loaded
118
+ // only then), anything else as CSV text. Over DATA_SHEET_MAX_BYTES it isn't
119
+ // read at all.
120
+ export const readTabularFile = async (file: {
121
+ bytes: ArrayBuffer;
122
+ mimeType: string | null;
123
+ uri: string;
124
+ sheet?: string | null;
125
+ loadSheetJs: () => Promise<SheetJs>;
126
+ }): Promise<TabularFile> => {
127
+ if (file.bytes.byteLength > DATA_SHEET_MAX_BYTES) {
128
+ throw new DataError(
129
+ `${file.uri} is over ${DATA_SHEET_MAX_BYTES / (1024 * 1024)} MB.`,
130
+ 'too_large'
131
+ );
132
+ }
133
+ if (file.mimeType && DATA_SPREADSHEET_MIME.test(file.mimeType)) {
134
+ return readSheet(
135
+ await file.loadSheetJs(),
136
+ file.bytes,
137
+ file.uri,
138
+ file.sheet
139
+ );
140
+ }
141
+ if (!file.mimeType || DATA_CSV_MIME.test(file.mimeType)) {
142
+ return readCsvText(new TextDecoder().decode(file.bytes));
143
+ }
144
+ throw new DataError(
145
+ `${file.uri} is ${file.mimeType}; a table reads CSV and Excel files.`
146
+ );
147
+ };
package/src/dataTools.ts CHANGED
@@ -61,7 +61,7 @@ const CHANGES = {
61
61
  };
62
62
 
63
63
  const WHERE_HELP =
64
- 'Filter object. { "column": value } matches exactly; { "column": { "op": value } } with op one of eq, ne, gt, gte, lt, lte, in, notIn (lists), contains, startsWith (text, case-insensitive), isNull (true/false). Several keys must all match; { "or": [ {...}, {...} ] } matches any. A relation column takes the related row\'s id, or filter on a column of the related table with "relation.column", e.g. { "product.name": "Margherita" }. Datetimes are ISO 8601; include the offset for local times, e.g. "2026-10-19T00:00:00-05:00".';
64
+ 'Filter object. { "column": value } matches exactly; { "column": { "op": value } } with op one of eq, ne, gt, gte, lt, lte, in, notIn (lists), contains, startsWith (text, case-insensitive), isNull (true/false). Several keys must all match; { "or": [ {...}, {...} ] } matches any. A relation column takes the related row\'s id, or filter on a column of the related table with "relation.column", e.g. { "product.name": "Margherita" }. A file column matches the item\'s uri. Datetimes are ISO 8601; include the offset for local times, e.g. "2026-10-19T00:00:00-05:00".';
65
65
 
66
66
  const accessOf = (caller: DataCaller, table: DataTable) =>
67
67
  caller.restricted ? table.access : 'full';
@@ -145,7 +145,7 @@ export const dataToolDefinitions = (
145
145
  title: DATA_TOOL_TITLES[DATA_TOOL_QUERY],
146
146
  description: [
147
147
  "Read rows from the project's database: structured records such as products, prices, stock, orders, bookings or customers. Use it for anything with fields and numbers; use the knowledge/resource tools for documents and pages.",
148
- 'Returns matching rows (relation columns come back as { id, label }), the total that match, and nextCursor when there are more. With "aggregate" it returns counts, sums, averages, minimums or maximums instead of rows, optionally per group: use that for "how many" and "how much" rather than adding rows up yourself.',
148
+ 'Returns matching rows (relation columns come back as { id, label }; file columns as { uri, title, mimeType }, or { uri, missing: true } when the item is gone: deliver one with send-resource and its uri, or read a document with read-resource), the total that match, and nextCursor when there are more. With "aggregate" it returns counts, sums, averages, minimums or maximums instead of rows, optionally per group: use that for "how many" and "how much" rather than adding rows up yourself.',
149
149
  "The database holds only what these tables and columns hold. When a question needs something they don't have (opening hours, parking, calories, reviews, forecasts), look in the knowledge tools if you have them; if nothing has it, say you don't have that information. Never guess it.",
150
150
  `Every table also has id, created_at and updated_at. Datetimes come back in the project's time zone (${timeZone}) with their offset; read them as local times.`,
151
151
  'Tables:',
@@ -242,7 +242,7 @@ export const dataToolDefinitions = (
242
242
  title: DATA_TOOL_TITLES[DATA_TOOL_WRITE],
243
243
  description: [
244
244
  "Add, change or delete rows in the project's database. All operations in one call succeed together or none is applied, so put everything that belongs together (an order, its lines and the stock they use) in one call.",
245
- '- insert: { "op": "insert", "table": "...", "values": { column: value } }. A relation column takes the related row\'s id; to point at a row inserted earlier in the same call use { "$ref": n }, n being that operation\'s position (0-based).',
245
+ '- insert: { "op": "insert", "table": "...", "values": { column: value } }. A relation column takes the related row\'s id; to point at a row inserted earlier in the same call use { "$ref": n }, n being that operation\'s position (0-based). A file column takes the uri of an item in the project\'s knowledge (from list-resources or search-resources), or null.',
246
246
  '- update: { "op": "update", "table": "...", "id": 12, "set": { column: value } }. For numbers, { "decrement": 2 } or { "increment": 2 } changes the value relative to what is stored. Instead of id you can give "where" (same format as data-query).',
247
247
  '- delete: { "op": "delete", "table": "...", "id": 12 } (or "where").',
248
248
  'Guards: an update or delete also takes "where" conditions that must hold, e.g. "where": { "stock": { "gte": 2 } }, and "expect", how many rows must change (1 by default when id is given). If a guard fails, nothing in the call is written and the error says why (e.g. how much stock is left).',