@ganju/utils 0.0.20 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/constants.d.ts +16 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +43 -0
- package/dist/data.d.ts +6 -4
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +13 -3
- package/dist/dataApi.d.ts +50 -4
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +36 -3
- package/dist/dataAttachment.d.ts +8 -0
- package/dist/dataAttachment.d.ts.map +1 -0
- package/dist/dataAttachment.js +19 -0
- package/dist/dataCompiler.d.ts +3 -0
- package/dist/dataCompiler.d.ts.map +1 -1
- package/dist/dataCompiler.js +25 -1
- package/dist/dataDdl.d.ts.map +1 -1
- package/dist/dataDdl.js +5 -0
- package/dist/dataEngine.d.ts +9 -3
- package/dist/dataEngine.d.ts.map +1 -1
- package/dist/dataEngine.js +141 -5
- package/dist/dataIndexAdvice.d.ts +36 -0
- package/dist/dataIndexAdvice.d.ts.map +1 -0
- package/dist/dataIndexAdvice.js +89 -0
- package/dist/dataRpc.d.ts +12 -1
- package/dist/dataRpc.d.ts.map +1 -1
- package/dist/dataTools.js +3 -3
- package/dist/dataTriggers.d.ts +23 -0
- package/dist/dataTriggers.d.ts.map +1 -1
- package/dist/dataTriggers.js +46 -2
- package/dist/dataTypes.d.ts +46 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/dataTypes.js +7 -1
- package/dist/index.d.ts +56 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/localizeDataError.d.ts.map +1 -1
- package/dist/localizeDataError.js +64 -2
- package/dist/schema.d.ts +34 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +32 -0
- package/dist/whatsappWebhook.d.ts +28 -0
- package/dist/whatsappWebhook.d.ts.map +1 -0
- package/dist/whatsappWebhook.js +30 -0
- package/package.json +1 -1
- package/src/constants.ts +46 -0
- package/src/data.ts +14 -0
- package/src/dataApi.ts +40 -2
- package/src/dataAttachment.ts +23 -0
- package/src/dataCompiler.ts +37 -0
- package/src/dataDdl.ts +7 -0
- package/src/dataEngine.ts +195 -5
- package/src/dataIndexAdvice.ts +127 -0
- package/src/dataRpc.ts +17 -1
- package/src/dataTools.ts +3 -3
- package/src/dataTriggers.ts +71 -1
- package/src/dataTypes.ts +62 -0
- package/src/index.ts +11 -0
- package/src/localizeDataError.ts +101 -3
- package/src/schema.ts +34 -0
- package/src/whatsappWebhook.ts +55 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface WhatsappWebhookChange<V> {
|
|
2
|
+
field?: string;
|
|
3
|
+
value?: V;
|
|
4
|
+
}
|
|
5
|
+
export interface WhatsappWebhookEnvelope<V> {
|
|
6
|
+
entry?: Array<{
|
|
7
|
+
id?: string;
|
|
8
|
+
changes?: Array<WhatsappWebhookChange<V>>;
|
|
9
|
+
}>;
|
|
10
|
+
}
|
|
11
|
+
export interface WhatsappAddressedValue {
|
|
12
|
+
metadata?: {
|
|
13
|
+
phone_number_id?: string;
|
|
14
|
+
};
|
|
15
|
+
messages?: unknown[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The change carrying a message for this channel's number, wherever Meta put
|
|
19
|
+
* it in the batch. A change that doesn't say which number it's for is kept.
|
|
20
|
+
*/
|
|
21
|
+
export declare const whatsappMessageChange: <V extends WhatsappAddressedValue>(payload: WhatsappWebhookEnvelope<V>, phoneNumberId: string) => V | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The changes with one of `fields` from this channel's business account. An
|
|
24
|
+
* entry without an id, or a channel that hasn't saved its account, keeps
|
|
25
|
+
* them: template events only touch the channel's own stored templates.
|
|
26
|
+
*/
|
|
27
|
+
export declare const whatsappAccountChanges: <V>(payload: WhatsappWebhookEnvelope<V>, businessAccountId: string | undefined, fields: readonly string[]) => WhatsappWebhookChange<V>[];
|
|
28
|
+
//# sourceMappingURL=whatsappWebhook.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"whatsappWebhook.d.ts","sourceRoot":"","sources":["../src/whatsappWebhook.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,qBAAqB,CAAC,CAAC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,CAAC,CAAC;CACX;AAED,MAAM,WAAW,uBAAuB,CAAC,CAAC;IACxC,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,CAAA;KAAE,CAAC,CAAC;CAC3E;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,QAAQ,CAAC,EAAE,OAAO,EAAE,CAAC;CACtB;AAED;;;GAGG;AACH,eAAO,MAAM,qBAAqB,GAAI,CAAC,SAAS,sBAAsB,EACpE,SAAS,uBAAuB,CAAC,CAAC,CAAC,EACnC,eAAe,MAAM,KACpB,CAAC,GAAG,SAQM,CAAC;AAEd;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,GAAI,CAAC,EACtC,SAAS,uBAAuB,CAAC,CAAC,CAAC,EACnC,mBAAmB,MAAM,GAAG,SAAS,EACrC,QAAQ,SAAS,MAAM,EAAE,KACxB,qBAAqB,CAAC,CAAC,CAAC,EAStB,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What one WhatsApp channel takes from a webhook. A Meta app sends every
|
|
3
|
+
// number's events to each of its callbacks, signed with the same app secret,
|
|
4
|
+
// so a channel keeps only what was addressed to its own number and business
|
|
5
|
+
// account; otherwise another channel's bot answers its messages.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.whatsappAccountChanges = exports.whatsappMessageChange = void 0;
|
|
8
|
+
/**
|
|
9
|
+
* The change carrying a message for this channel's number, wherever Meta put
|
|
10
|
+
* it in the batch. A change that doesn't say which number it's for is kept.
|
|
11
|
+
*/
|
|
12
|
+
const whatsappMessageChange = (payload, phoneNumberId) => (payload.entry ?? [])
|
|
13
|
+
.flatMap(entry => entry.changes ?? [])
|
|
14
|
+
.find(change => {
|
|
15
|
+
const value = change.value;
|
|
16
|
+
if (!value?.messages?.length)
|
|
17
|
+
return false;
|
|
18
|
+
const to = value.metadata?.phone_number_id;
|
|
19
|
+
return !to || to === phoneNumberId;
|
|
20
|
+
})?.value;
|
|
21
|
+
exports.whatsappMessageChange = whatsappMessageChange;
|
|
22
|
+
/**
|
|
23
|
+
* The changes with one of `fields` from this channel's business account. An
|
|
24
|
+
* entry without an id, or a channel that hasn't saved its account, keeps
|
|
25
|
+
* them: template events only touch the channel's own stored templates.
|
|
26
|
+
*/
|
|
27
|
+
const whatsappAccountChanges = (payload, businessAccountId, fields) => (payload.entry ?? [])
|
|
28
|
+
.filter(entry => !entry.id || !businessAccountId || entry.id === businessAccountId)
|
|
29
|
+
.flatMap(entry => (entry.changes ?? []).filter(change => !!change.field && fields.includes(change.field)));
|
|
30
|
+
exports.whatsappAccountChanges = whatsappAccountChanges;
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -2197,6 +2197,36 @@ const DATA_PURGE_BATCH = 20;
|
|
|
2197
2197
|
// Postgres write per query.
|
|
2198
2198
|
const DATA_ACTIVITY_THROTTLE_MS = 60 * 60 * 1000;
|
|
2199
2199
|
|
|
2200
|
+
// Index advice. A column is worth an index when, over a week, at least 50
|
|
2201
|
+
// queries filtered or sorted on it and they read on average 1,000 rows and
|
|
2202
|
+
// 20 rows for every row returned. An index no query used in 30 days on a
|
|
2203
|
+
// table written at least 1,000 rows (and more rows written than queries) is
|
|
2204
|
+
// flagged as unused. Never dropped automatically.
|
|
2205
|
+
const DATA_INDEX_ADVICE_WINDOW_DAYS = 7;
|
|
2206
|
+
const DATA_INDEX_ADVICE_MIN_QUERIES = 50;
|
|
2207
|
+
const DATA_INDEX_ADVICE_MIN_ROWS_READ = 1_000;
|
|
2208
|
+
const DATA_INDEX_ADVICE_MIN_READ_RATIO = 20;
|
|
2209
|
+
const DATA_INDEX_UNUSED_WINDOW_DAYS = 30;
|
|
2210
|
+
const DATA_INDEX_UNUSED_MIN_ROWS_WRITTEN = 1_000;
|
|
2211
|
+
// Daily stats are kept past the longest window, then removed.
|
|
2212
|
+
const DATA_QUERY_STAT_KEEP_DAYS = 35;
|
|
2213
|
+
const DATA_INDEX_ADVICE_ADD = 'add' as 'add';
|
|
2214
|
+
const DATA_INDEX_ADVICE_UNUSED = 'unused' as 'unused';
|
|
2215
|
+
const DATA_INDEX_ADVICE_OPEN = 'open' as 'open';
|
|
2216
|
+
const DATA_INDEX_ADVICE_DISMISSED = 'dismissed' as 'dismissed';
|
|
2217
|
+
|
|
2218
|
+
// The daily jobs (index advice and the abandoned-database flow) run on the
|
|
2219
|
+
// hourly cron at this UTC hour.
|
|
2220
|
+
const DATA_DAILY_JOB_HOUR_UTC = 13;
|
|
2221
|
+
|
|
2222
|
+
// Days on Free before a database is warned about, reminded about, and listed
|
|
2223
|
+
// for Ganju to delete by hand.
|
|
2224
|
+
const DATA_ABANDON_WARN_DAYS = 30;
|
|
2225
|
+
const DATA_ABANDON_REMIND_DAYS = 41;
|
|
2226
|
+
const DATA_ABANDON_DELETE_DAYS = 44;
|
|
2227
|
+
// How long the export taken before a database is deleted is kept.
|
|
2228
|
+
const DATA_ABANDON_EXPORT_KEEP_DAYS = 90;
|
|
2229
|
+
|
|
2200
2230
|
interface PlanLimits {
|
|
2201
2231
|
// null on any field means "no hard limit" for that plan.
|
|
2202
2232
|
maxProjects: number | null;
|
|
@@ -2588,6 +2618,22 @@ export const constants = {
|
|
|
2588
2618
|
DATA_DELETE_GRACE_DAYS,
|
|
2589
2619
|
DATA_PURGE_BATCH,
|
|
2590
2620
|
DATA_ACTIVITY_THROTTLE_MS,
|
|
2621
|
+
DATA_INDEX_ADVICE_WINDOW_DAYS,
|
|
2622
|
+
DATA_INDEX_ADVICE_MIN_QUERIES,
|
|
2623
|
+
DATA_INDEX_ADVICE_MIN_ROWS_READ,
|
|
2624
|
+
DATA_INDEX_ADVICE_MIN_READ_RATIO,
|
|
2625
|
+
DATA_INDEX_UNUSED_WINDOW_DAYS,
|
|
2626
|
+
DATA_INDEX_UNUSED_MIN_ROWS_WRITTEN,
|
|
2627
|
+
DATA_QUERY_STAT_KEEP_DAYS,
|
|
2628
|
+
DATA_INDEX_ADVICE_ADD,
|
|
2629
|
+
DATA_INDEX_ADVICE_UNUSED,
|
|
2630
|
+
DATA_INDEX_ADVICE_OPEN,
|
|
2631
|
+
DATA_INDEX_ADVICE_DISMISSED,
|
|
2632
|
+
DATA_DAILY_JOB_HOUR_UTC,
|
|
2633
|
+
DATA_ABANDON_WARN_DAYS,
|
|
2634
|
+
DATA_ABANDON_REMIND_DAYS,
|
|
2635
|
+
DATA_ABANDON_DELETE_DAYS,
|
|
2636
|
+
DATA_ABANDON_EXPORT_KEEP_DAYS,
|
|
2591
2637
|
ARTIFACT_DATABASE_STATUS_CREATING,
|
|
2592
2638
|
ARTIFACT_DATABASE_STATUS_READY,
|
|
2593
2639
|
ARTIFACT_DATABASE_STATUS_FAILED,
|
package/src/data.ts
CHANGED
|
@@ -26,6 +26,8 @@ export {
|
|
|
26
26
|
encodeCursor,
|
|
27
27
|
decodeCursor,
|
|
28
28
|
isSensitiveWrite,
|
|
29
|
+
fileUriOf,
|
|
30
|
+
unknownFileUris,
|
|
29
31
|
tidyArgs,
|
|
30
32
|
zoned
|
|
31
33
|
} from './dataCompiler';
|
|
@@ -47,6 +49,8 @@ export {
|
|
|
47
49
|
rowHistory,
|
|
48
50
|
pruneHistory,
|
|
49
51
|
deleteImpact,
|
|
52
|
+
fileLinks,
|
|
53
|
+
replaceFileLinks,
|
|
50
54
|
createTable,
|
|
51
55
|
updateTable,
|
|
52
56
|
dropTable,
|
|
@@ -64,6 +68,12 @@ export {
|
|
|
64
68
|
exportRows
|
|
65
69
|
} from './dataEngine';
|
|
66
70
|
export type { SqlRow, SqlRunner } from './dataEngine';
|
|
71
|
+
export { adviseIndexes, adviceDoubled } from './dataIndexAdvice';
|
|
72
|
+
export type {
|
|
73
|
+
IndexAdviceCandidate,
|
|
74
|
+
IndexAdviceInput,
|
|
75
|
+
IndexAdviceStat
|
|
76
|
+
} from './dataIndexAdvice';
|
|
67
77
|
export {
|
|
68
78
|
DATA_QUERY_PARAM_TYPES,
|
|
69
79
|
DATA_MAX_QUERIES,
|
|
@@ -118,7 +128,10 @@ export {
|
|
|
118
128
|
utf8Bytes,
|
|
119
129
|
conditionMatches,
|
|
120
130
|
renderTriggerText,
|
|
131
|
+
renderCustomerMessage,
|
|
132
|
+
readEventRow,
|
|
121
133
|
normaliseTrigger,
|
|
134
|
+
normaliseCustomerAction,
|
|
122
135
|
listTriggers,
|
|
123
136
|
getTrigger,
|
|
124
137
|
saveTrigger,
|
|
@@ -138,6 +151,7 @@ export type {
|
|
|
138
151
|
DataTriggerAction,
|
|
139
152
|
DataTriggerActionType,
|
|
140
153
|
DataTriggerCondition,
|
|
154
|
+
DataTriggerCustomerMessage,
|
|
141
155
|
DataTriggerEvent,
|
|
142
156
|
DataTriggerEventMessage,
|
|
143
157
|
DataTriggerInput,
|
package/src/dataApi.ts
CHANGED
|
@@ -59,6 +59,7 @@ const {
|
|
|
59
59
|
DATA_TRIGGER_INPUT,
|
|
60
60
|
DATA_TRIGGER_PATCH,
|
|
61
61
|
DATA_TRIGGER_DEPLOY_BODY,
|
|
62
|
+
DATA_TRIGGER_TEST_SEND_BODY,
|
|
62
63
|
DATA_TRIGGER_RUN_LIST_QUERY
|
|
63
64
|
} = Schema;
|
|
64
65
|
|
|
@@ -69,7 +70,7 @@ export const DATA_API_ROUTES = {
|
|
|
69
70
|
tag: 'Database',
|
|
70
71
|
summary: "The project's database",
|
|
71
72
|
description:
|
|
72
|
-
'Its status and size, and its tables when it is ready. `database` is null when the project has none.',
|
|
73
|
+
'Its status and size, and its tables when it is ready, with `indexAdvice`: the open index suggestions. `database` is null when the project has none.',
|
|
73
74
|
returns: '{ database }'
|
|
74
75
|
},
|
|
75
76
|
create: {
|
|
@@ -139,6 +140,24 @@ export const DATA_API_ROUTES = {
|
|
|
139
140
|
'A column change on a table over 100,000 rows copies it in the background; table changes, and writes that would change its rows, wait until it finishes (`copy` on `GET` shows how far it got). A copy that failed left the table as it was and stays in `copy` with its `error` until dismissed, or until the next column change.',
|
|
140
141
|
returns: '{ copy: null }'
|
|
141
142
|
},
|
|
143
|
+
applyIndexAdvice: {
|
|
144
|
+
method: 'post',
|
|
145
|
+
path: '/advice/:adviceId',
|
|
146
|
+
tag: 'Tables',
|
|
147
|
+
summary: 'Add a suggested index',
|
|
148
|
+
description:
|
|
149
|
+
"`GET` lists the open suggestions in `indexAdvice`: a column filtered or sorted on by 50 or more queries in a week that each read 1,000 or more rows, 20 for every row returned. This marks the column filterable, which indexes it, and records it in the change log. A suggestion of kind `unused` is only a flag; it can't be applied here, only dismissed.",
|
|
150
|
+
returns: '{ table }'
|
|
151
|
+
},
|
|
152
|
+
dismissIndexAdvice: {
|
|
153
|
+
method: 'delete',
|
|
154
|
+
path: '/advice/:adviceId',
|
|
155
|
+
tag: 'Tables',
|
|
156
|
+
summary: 'Dismiss an index suggestion',
|
|
157
|
+
description:
|
|
158
|
+
'It stays dismissed until its numbers double (queries, rows read per query, or rows written for an unused index).',
|
|
159
|
+
returns: '{ dismissed: true }'
|
|
160
|
+
},
|
|
142
161
|
listTables: {
|
|
143
162
|
method: 'get',
|
|
144
163
|
path: '/table',
|
|
@@ -207,7 +226,7 @@ export const DATA_API_ROUTES = {
|
|
|
207
226
|
tag: 'Rows',
|
|
208
227
|
summary: 'List, filter and search rows',
|
|
209
228
|
description:
|
|
210
|
-
'Up to 500 rows or 1 MB a page; `nextCursor` reads the next. `where` is JSON, e.g. `{"status":"open","price":{"lt":30000}}`. `sort` is a list of columns, `-` for descending. `q` searches the searchable columns.',
|
|
229
|
+
'Up to 500 rows or 1 MB a page; `nextCursor` reads the next. `where` is JSON, e.g. `{"status":"open","price":{"lt":30000}}`. `sort` is a list of columns, `-` for descending. `q` searches the searchable columns. A relation comes back as `{ id, label }`; a file column as `{ uri, id, title, mimeType }`, or `{ uri, missing: true }` when no resource has the uri any more. Writes take a file column\'s uri.',
|
|
211
230
|
query: DATA_ROW_LIST_QUERY,
|
|
212
231
|
returns: '{ table, rows, total?, nextCursor? }'
|
|
213
232
|
},
|
|
@@ -360,6 +379,25 @@ export const DATA_API_ROUTES = {
|
|
|
360
379
|
body: DATA_TRIGGER_DEPLOY_BODY,
|
|
361
380
|
returns: '{ triggers, removed, secrets, confirming? }'
|
|
362
381
|
},
|
|
382
|
+
listTestRecipients: {
|
|
383
|
+
method: 'get',
|
|
384
|
+
path: '/trigger/test-send',
|
|
385
|
+
tag: 'Triggers',
|
|
386
|
+
summary: 'Where a test send can go',
|
|
387
|
+
description:
|
|
388
|
+
"The caller's own chats with this project's bots: the ones they linked to their Ganju account by sending `/link` to the bot. Empty when there are none.",
|
|
389
|
+
returns: '{ recipients }'
|
|
390
|
+
},
|
|
391
|
+
testSend: {
|
|
392
|
+
method: 'post',
|
|
393
|
+
path: '/trigger/test-send',
|
|
394
|
+
tag: 'Triggers',
|
|
395
|
+
summary: 'Send a test of a customer message',
|
|
396
|
+
description:
|
|
397
|
+
"Sends a `customer` action, saved or not, to the caller's own chat with the bot instead of the row's customer: built from the row `rowId` (the table's newest row by default), the same as a real run. `participantId` picks one of `GET /trigger/test-send`'s recipients when there are several. With `asTemplate`, a WhatsApp template goes out even inside the 24-hour window. Not a run: it isn't listed with any trigger's runs and doesn't count toward the daily limit per customer. Five a minute per user. A channel's refusal (the template not approved, outside the window with no template, the daily limit) comes back as the error.",
|
|
398
|
+
body: DATA_TRIGGER_TEST_SEND_BODY,
|
|
399
|
+
returns: '{ sent, text, template, rowId, recipient }'
|
|
400
|
+
},
|
|
363
401
|
listTriggerRuns: {
|
|
364
402
|
method: 'get',
|
|
365
403
|
path: '/trigger/:name/run',
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Schema } from './schema';
|
|
2
|
+
|
|
3
|
+
export interface DataAttachment {
|
|
4
|
+
table: string;
|
|
5
|
+
column: string;
|
|
6
|
+
index: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// The Data upload a knowledge item came from, read from its metadata, or
|
|
10
|
+
// null for any other item.
|
|
11
|
+
export const dataAttachmentOf = (metadata: unknown): DataAttachment | null => {
|
|
12
|
+
if (!metadata || typeof metadata !== 'object') return null;
|
|
13
|
+
const parsed = Schema.DATA_ATTACHMENT_METADATA.safeParse(
|
|
14
|
+
(metadata as { dataAttachment?: unknown }).dataAttachment
|
|
15
|
+
);
|
|
16
|
+
return parsed.success ? parsed.data : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Uploads from Data are stored, not indexed, unless their column asks.
|
|
20
|
+
export const skipsIndexing = (metadata: unknown): boolean => {
|
|
21
|
+
const attachment = dataAttachmentOf(metadata);
|
|
22
|
+
return attachment !== null && !attachment.index;
|
|
23
|
+
};
|
package/src/dataCompiler.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DATA_ACCESS_RANK,
|
|
9
9
|
DATA_MAX_IN_VALUES,
|
|
10
10
|
DATA_MAX_PARAMS,
|
|
11
|
+
DATA_FILE_URI_MAX,
|
|
11
12
|
DATA_MAX_VALUE_BYTES,
|
|
12
13
|
DataError
|
|
13
14
|
} from './dataTypes';
|
|
@@ -260,6 +261,37 @@ const checkSize = (label: string, value: string) => {
|
|
|
260
261
|
return value;
|
|
261
262
|
};
|
|
262
263
|
|
|
264
|
+
// A knowledge item's uri, given as the text or as { uri } (how reads return
|
|
265
|
+
// it). Any scheme: knowledge holds resource://, website and drive uris.
|
|
266
|
+
export const fileUriOf = (value: unknown, label: string): string => {
|
|
267
|
+
const raw =
|
|
268
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
269
|
+
? (value as { uri?: unknown }).uri
|
|
270
|
+
: value;
|
|
271
|
+
const uri = typeof raw === 'string' ? raw.trim() : '';
|
|
272
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\/\S+$/i.test(uri)) {
|
|
273
|
+
throw new DataError(
|
|
274
|
+
`${label} takes a knowledge item's uri, e.g. resource://menu-photo.`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
if (uri.length > DATA_FILE_URI_MAX) {
|
|
278
|
+
throw new DataError(
|
|
279
|
+
`${label}: a uri is at most ${DATA_FILE_URI_MAX} characters.`,
|
|
280
|
+
'too_large'
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return uri;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// A write that links uris no item of the project has: the workers check,
|
|
287
|
+
// since the database object can't see knowledge.
|
|
288
|
+
export const unknownFileUris = (uris: string[]) =>
|
|
289
|
+
new DataError(
|
|
290
|
+
`No resource in this project has the uri ${uris.join(', ')}; link an existing resource by its uri.`,
|
|
291
|
+
'invalid',
|
|
292
|
+
{ uris }
|
|
293
|
+
);
|
|
294
|
+
|
|
263
295
|
// Turns a value from a caller into what the column stores, or explains why
|
|
264
296
|
// it can't. `timeZone` is the project's, for datetimes sent without an
|
|
265
297
|
// offset.
|
|
@@ -337,6 +369,8 @@ export const toStored = (
|
|
|
337
369
|
throw new DataError(`${label} must be a URL starting with http(s)://.`);
|
|
338
370
|
}
|
|
339
371
|
return checkSize(label, value.trim());
|
|
372
|
+
case 'file':
|
|
373
|
+
return fileUriOf(value, label);
|
|
340
374
|
case 'phone': {
|
|
341
375
|
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
342
376
|
throw new DataError(`${label} must be a phone number.`);
|
|
@@ -429,6 +463,8 @@ export interface CompileContext {
|
|
|
429
463
|
table: DataTable;
|
|
430
464
|
joins: Map<string, SqlJoin>;
|
|
431
465
|
params: SqlValue[];
|
|
466
|
+
// The queried table's own columns a `where` filters on, for index advice.
|
|
467
|
+
filtered?: Set<string>;
|
|
432
468
|
}
|
|
433
469
|
|
|
434
470
|
// "column" or "relation.column", adding the join a relation path needs.
|
|
@@ -548,6 +584,7 @@ export const compileWhere = (
|
|
|
548
584
|
continue;
|
|
549
585
|
}
|
|
550
586
|
const resolved = resolvePath(ctx, key);
|
|
587
|
+
if (resolved.own) ctx.filtered?.add(resolved.column.name);
|
|
551
588
|
// { "customer": { "name": "Ana" } } reads as "customer.name": models
|
|
552
589
|
// write relation filters this way unprompted.
|
|
553
590
|
if (
|
package/src/dataDdl.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// ours, and every owner-supplied value is a literal escaped by sqlLiteral.
|
|
4
4
|
import { changeCaptureTriggers } from './dataTriggers';
|
|
5
5
|
import { quoteIdentifier as q } from './dataIdentifiers';
|
|
6
|
+
import { DATA_FILE_URI_MAX } from './dataTypes';
|
|
6
7
|
import type {
|
|
7
8
|
DataColumn,
|
|
8
9
|
DataColumnType,
|
|
@@ -412,6 +413,12 @@ export const invalidAfterConversionSql = (
|
|
|
412
413
|
`NOT ((${converted}) LIKE 'http://%' OR (${converted}) LIKE 'https://%')`
|
|
413
414
|
);
|
|
414
415
|
}
|
|
416
|
+
// A file column holds a knowledge item's uri: scheme://…, no spaces.
|
|
417
|
+
if (to.type === 'file') {
|
|
418
|
+
parts.push(
|
|
419
|
+
`NOT ((${converted}) GLOB '[A-Za-z]*://?*' AND (${converted}) NOT GLOB '*[ ' || char(9) || char(10) || char(13) || ']*' AND length(${converted}) <= ${DATA_FILE_URI_MAX})`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
415
422
|
if (to.type === 'relation' && to.table) {
|
|
416
423
|
parts.push(
|
|
417
424
|
`((${converted}) IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ${q(to.table)} WHERE id = (${converted})))`
|
package/src/dataEngine.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
compileWhere,
|
|
31
31
|
decodeCursor,
|
|
32
32
|
encodeCursor,
|
|
33
|
+
fileUriOf,
|
|
33
34
|
fromStored,
|
|
34
35
|
ftsMatch,
|
|
35
36
|
isScoped,
|
|
@@ -74,6 +75,7 @@ import {
|
|
|
74
75
|
DATA_COPY_STEP_ROWS,
|
|
75
76
|
DATA_IMPACT_MAX_IDS,
|
|
76
77
|
DATA_IMPACT_MAX_ROWS,
|
|
78
|
+
DATA_FILE_LINKS_MAX_URIS,
|
|
77
79
|
DATA_REBUILD_MAX_ROWS,
|
|
78
80
|
DataError
|
|
79
81
|
} from './dataTypes';
|
|
@@ -85,11 +87,17 @@ import type {
|
|
|
85
87
|
DataColumnType,
|
|
86
88
|
DataDefaultValue,
|
|
87
89
|
DataDeleteImpact,
|
|
90
|
+
DataFileLinkChange,
|
|
91
|
+
DataFileLinks,
|
|
92
|
+
DataFileLinksReplaced,
|
|
88
93
|
DataOnDelete,
|
|
94
|
+
DataQueryShape,
|
|
89
95
|
DataSchema,
|
|
90
96
|
DataTable,
|
|
91
97
|
DataTableCopy,
|
|
92
|
-
DataTableInput
|
|
98
|
+
DataTableInput,
|
|
99
|
+
DataUsage,
|
|
100
|
+
DataWriteShape
|
|
93
101
|
} from './dataTypes';
|
|
94
102
|
|
|
95
103
|
export type SqlRow = Record<string, SqlValue>;
|
|
@@ -101,6 +109,11 @@ export interface SqlRunner {
|
|
|
101
109
|
iterate(sql: string, params?: readonly SqlValue[]): Iterable<SqlRow>;
|
|
102
110
|
transaction<T>(fn: () => T): T;
|
|
103
111
|
databaseSize(): number;
|
|
112
|
+
// Rows read and written so far in this call, and where a query or write
|
|
113
|
+
// notes what it did. Optional: tests and internal runners skip them.
|
|
114
|
+
usageSoFar?(): DataUsage;
|
|
115
|
+
noteQuery?(shape: DataQueryShape): void;
|
|
116
|
+
noteWrite?(shape: DataWriteShape): void;
|
|
104
117
|
}
|
|
105
118
|
|
|
106
119
|
const first = (runner: SqlRunner, sql: string, params?: readonly SqlValue[]) =>
|
|
@@ -483,9 +496,14 @@ export const normaliseColumn = (
|
|
|
483
496
|
if (column.unique && !UNIQUE_TYPES.has(column.type)) {
|
|
484
497
|
throw new DataError(`${label}: a ${column.type} column can't be unique.`);
|
|
485
498
|
}
|
|
486
|
-
|
|
499
|
+
// On a file column, searchable means its uploads are indexed in knowledge.
|
|
500
|
+
if (
|
|
501
|
+
column.searchable &&
|
|
502
|
+
!SEARCHABLE_TYPES.has(column.type) &&
|
|
503
|
+
column.type !== 'file'
|
|
504
|
+
) {
|
|
487
505
|
throw new DataError(
|
|
488
|
-
`${label}: only text columns can be searchable (text, long_text, email, url, phone, enum).`
|
|
506
|
+
`${label}: only text and file columns can be searchable (text, long_text, email, url, phone, enum, file).`
|
|
489
507
|
);
|
|
490
508
|
}
|
|
491
509
|
// The search table has a column of its own called rank.
|
|
@@ -786,6 +804,8 @@ const shapeRow = (
|
|
|
786
804
|
? fromStored(display, label, timeZone)
|
|
787
805
|
: label
|
|
788
806
|
};
|
|
807
|
+
} else if (column.type === 'file') {
|
|
808
|
+
row[column.name] = value === null ? null : { uri: String(value) };
|
|
789
809
|
} else {
|
|
790
810
|
row[column.name] = value;
|
|
791
811
|
}
|
|
@@ -890,8 +910,20 @@ export const query = (
|
|
|
890
910
|
caller,
|
|
891
911
|
table,
|
|
892
912
|
joins: new Map(),
|
|
893
|
-
params: []
|
|
913
|
+
params: [],
|
|
914
|
+
filtered: new Set()
|
|
894
915
|
};
|
|
916
|
+
// What this query read, noted for index advice once it has its rows.
|
|
917
|
+
const readBefore = runner.usageSoFar?.().rowsRead ?? 0;
|
|
918
|
+
const sorted: string[] = [];
|
|
919
|
+
const noteShape = (returned: number) =>
|
|
920
|
+
runner.noteQuery?.({
|
|
921
|
+
table: table.name,
|
|
922
|
+
filtered: [...(ctx.filtered ?? [])],
|
|
923
|
+
sorted,
|
|
924
|
+
rowsRead: (runner.usageSoFar?.().rowsRead ?? 0) - readBefore,
|
|
925
|
+
returned
|
|
926
|
+
});
|
|
895
927
|
const indexed = new Set<string>();
|
|
896
928
|
const where = compileWhere(ctx, args.where, indexed);
|
|
897
929
|
if (isScoped(caller, table)) {
|
|
@@ -907,7 +939,10 @@ export const query = (
|
|
|
907
939
|
}
|
|
908
940
|
|
|
909
941
|
if (args.aggregate !== undefined && args.aggregate !== null) {
|
|
910
|
-
|
|
942
|
+
const result = aggregate(runner, ctx, args, where, searching, indexed.size > 0);
|
|
943
|
+
const groups = (result as { groups?: unknown }).groups;
|
|
944
|
+
noteShape(Array.isArray(groups) ? groups.length : 1);
|
|
945
|
+
return result;
|
|
911
946
|
}
|
|
912
947
|
|
|
913
948
|
const limit = args.limit === undefined || args.limit === null ? DATA_DEFAULT_PAGE : args.limit;
|
|
@@ -946,6 +981,7 @@ export const query = (
|
|
|
946
981
|
descending,
|
|
947
982
|
...(servedByIndex ? {} : { unindexed: resolved.column.name })
|
|
948
983
|
});
|
|
984
|
+
if (resolved.own && resolved.column.name !== 'id') sorted.push(resolved.column.name);
|
|
949
985
|
if (resolved.own && resolved.column.name === 'id') {
|
|
950
986
|
endsUnique = true;
|
|
951
987
|
break;
|
|
@@ -1043,6 +1079,7 @@ export const query = (
|
|
|
1043
1079
|
}
|
|
1044
1080
|
|
|
1045
1081
|
const { rows: out, last, more } = result;
|
|
1082
|
+
noteShape(out.length);
|
|
1046
1083
|
const includeTruncated =
|
|
1047
1084
|
args.include !== undefined && args.include !== null
|
|
1048
1085
|
? includeRelations(
|
|
@@ -1055,11 +1092,13 @@ export const query = (
|
|
|
1055
1092
|
DATA_MAX_RESULT_BYTES - result.bytes
|
|
1056
1093
|
)
|
|
1057
1094
|
: false;
|
|
1095
|
+
const fileFields = fileFieldPaths(schema, table, args.include);
|
|
1058
1096
|
return {
|
|
1059
1097
|
table: table.name,
|
|
1060
1098
|
...(isScoped(caller, table) ? { scope: SCOPE_NOTE } : {}),
|
|
1061
1099
|
...(result.total !== undefined ? { total: result.total } : {}),
|
|
1062
1100
|
...(matched ? { searchMatched: matched } : {}),
|
|
1101
|
+
...(fileFields.length ? { fileFields } : {}),
|
|
1063
1102
|
rows: out,
|
|
1064
1103
|
...(includeTruncated
|
|
1065
1104
|
? {
|
|
@@ -1079,6 +1118,29 @@ export const query = (
|
|
|
1079
1118
|
};
|
|
1080
1119
|
};
|
|
1081
1120
|
|
|
1121
|
+
// Where a query's rows hold file values, as key paths within each row: the
|
|
1122
|
+
// table's file columns, and those of each included relation. The workers
|
|
1123
|
+
// fill those in from knowledge and drop this before answering; a json
|
|
1124
|
+
// column holding { "uri": … } is never mistaken for one.
|
|
1125
|
+
const fileFieldPaths = (
|
|
1126
|
+
schema: DataSchema,
|
|
1127
|
+
table: DataTable,
|
|
1128
|
+
include: unknown
|
|
1129
|
+
): string[][] => {
|
|
1130
|
+
const own = table.columns
|
|
1131
|
+
.filter(column => column.type === 'file')
|
|
1132
|
+
.map(column => [column.name]);
|
|
1133
|
+
const included = (Array.isArray(include) ? include : []).flatMap(name => {
|
|
1134
|
+
const column = typeof name === 'string' ? columnFor(table, name) : undefined;
|
|
1135
|
+
if (!column || isSystemColumn(column) || column.type !== 'relation') return [];
|
|
1136
|
+
const target = schema.tables.find(t => t.name === column.table);
|
|
1137
|
+
return (target?.columns ?? [])
|
|
1138
|
+
.filter(c => c.type === 'file')
|
|
1139
|
+
.map(c => [name as string, c.name]);
|
|
1140
|
+
});
|
|
1141
|
+
return [...own, ...included];
|
|
1142
|
+
};
|
|
1143
|
+
|
|
1082
1144
|
// Replaces each relation in `rows` named in `include` with the related row,
|
|
1083
1145
|
// read as the caller may see it, within `budget` bytes. Returns whether any
|
|
1084
1146
|
// was left as { id, label } to stay inside it.
|
|
@@ -1840,7 +1902,12 @@ export const write = (
|
|
|
1840
1902
|
const results: WriteResult[] = [];
|
|
1841
1903
|
operations.forEach((op, index) => {
|
|
1842
1904
|
try {
|
|
1905
|
+
const before = runner.usageSoFar?.().rowsWritten ?? 0;
|
|
1843
1906
|
results.push(runOperation(runner, schema, caller, op, index, results));
|
|
1907
|
+
runner.noteWrite?.({
|
|
1908
|
+
table: results[results.length - 1].table,
|
|
1909
|
+
rowsWritten: (runner.usageSoFar?.().rowsWritten ?? 0) - before
|
|
1910
|
+
});
|
|
1844
1911
|
} catch (error) {
|
|
1845
1912
|
if (error instanceof DataError) {
|
|
1846
1913
|
const label = isPlainObject(op) ? `${String(op.op)} ${String(op.table)}` : '?';
|
|
@@ -1979,6 +2046,129 @@ export const deleteImpact = (
|
|
|
1979
2046
|
};
|
|
1980
2047
|
};
|
|
1981
2048
|
|
|
2049
|
+
// File links
|
|
2050
|
+
|
|
2051
|
+
const fileColumns = (schema: DataSchema) =>
|
|
2052
|
+
schema.tables.flatMap(table =>
|
|
2053
|
+
table.columns
|
|
2054
|
+
.filter(column => column.type === 'file')
|
|
2055
|
+
.map(column => ({ table, column }))
|
|
2056
|
+
);
|
|
2057
|
+
|
|
2058
|
+
const fileUriList = (value: unknown): string[] => {
|
|
2059
|
+
if (
|
|
2060
|
+
!Array.isArray(value) ||
|
|
2061
|
+
value.length === 0 ||
|
|
2062
|
+
value.length > DATA_FILE_LINKS_MAX_URIS
|
|
2063
|
+
) {
|
|
2064
|
+
throw new DataError(
|
|
2065
|
+
`"uris" lists 1 to ${DATA_FILE_LINKS_MAX_URIS} knowledge item uris.`
|
|
2066
|
+
);
|
|
2067
|
+
}
|
|
2068
|
+
return [...new Set(value.map(uri => fileUriOf(uri, 'uris')))];
|
|
2069
|
+
};
|
|
2070
|
+
|
|
2071
|
+
// The rows whose file columns point at `uris`, per column: knowledge asks
|
|
2072
|
+
// before it deletes an item. Each file column is indexed.
|
|
2073
|
+
export const fileLinks = (
|
|
2074
|
+
runner: SqlRunner,
|
|
2075
|
+
schema: DataSchema,
|
|
2076
|
+
caller: DataCaller,
|
|
2077
|
+
rawArgs: unknown
|
|
2078
|
+
): DataFileLinks => {
|
|
2079
|
+
assertActor(caller);
|
|
2080
|
+
if (caller.restricted) {
|
|
2081
|
+
throw new DataError('File links are for the owner.', 'forbidden');
|
|
2082
|
+
}
|
|
2083
|
+
const uris = JSON.stringify(fileUriList(tidyArgs(rawArgs).uris));
|
|
2084
|
+
const links: DataFileLinks['links'] = [];
|
|
2085
|
+
for (const { table, column } of fileColumns(schema)) {
|
|
2086
|
+
const rows = Number(
|
|
2087
|
+
first(
|
|
2088
|
+
runner,
|
|
2089
|
+
`SELECT count(*) AS n FROM ${q(table.name)} WHERE ${q(column.name)} IN (SELECT value FROM json_each(?))`,
|
|
2090
|
+
[uris]
|
|
2091
|
+
)?.n ?? 0
|
|
2092
|
+
);
|
|
2093
|
+
if (rows === 0) continue;
|
|
2094
|
+
links.push({
|
|
2095
|
+
table: table.name,
|
|
2096
|
+
tableLabel: table.label,
|
|
2097
|
+
column: column.name,
|
|
2098
|
+
columnLabel: column.label,
|
|
2099
|
+
required: column.required,
|
|
2100
|
+
rows
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
return { links };
|
|
2104
|
+
};
|
|
2105
|
+
|
|
2106
|
+
// Points the links at `from` to `to`, or clears them (`to: null`) once the
|
|
2107
|
+
// item is gone. A required column keeps its link, which then reads as
|
|
2108
|
+
// missing. Recorded in row history; triggers don't see it.
|
|
2109
|
+
export const replaceFileLinks = (
|
|
2110
|
+
runner: SqlRunner,
|
|
2111
|
+
schema: DataSchema,
|
|
2112
|
+
caller: DataCaller,
|
|
2113
|
+
rawArgs: unknown
|
|
2114
|
+
): DataFileLinksReplaced => {
|
|
2115
|
+
assertActor(caller);
|
|
2116
|
+
if (caller.restricted) {
|
|
2117
|
+
throw new DataError('File links are for the owner.', 'forbidden');
|
|
2118
|
+
}
|
|
2119
|
+
const raw = tidyArgs(rawArgs).changes;
|
|
2120
|
+
if (
|
|
2121
|
+
!Array.isArray(raw) ||
|
|
2122
|
+
raw.length === 0 ||
|
|
2123
|
+
raw.length > DATA_FILE_LINKS_MAX_URIS
|
|
2124
|
+
) {
|
|
2125
|
+
throw new DataError(
|
|
2126
|
+
`"changes" lists 1 to ${DATA_FILE_LINKS_MAX_URIS} { from, to } pairs.`
|
|
2127
|
+
);
|
|
2128
|
+
}
|
|
2129
|
+
const changes: DataFileLinkChange[] = raw.map(change => {
|
|
2130
|
+
if (!isPlainObject(change)) {
|
|
2131
|
+
throw new DataError('Each change is { from, to }; to is null to clear.');
|
|
2132
|
+
}
|
|
2133
|
+
return {
|
|
2134
|
+
from: fileUriOf(change.from, 'from'),
|
|
2135
|
+
to: change.to === null ? null : fileUriOf(change.to, 'to')
|
|
2136
|
+
};
|
|
2137
|
+
});
|
|
2138
|
+
const columns = fileColumns(schema);
|
|
2139
|
+
if (columns.length === 0) return { updated: 0, kept: [] };
|
|
2140
|
+
assertWritable(runner, caller, changes.some(change => change.to !== null));
|
|
2141
|
+
return runner.transaction(() => {
|
|
2142
|
+
setContextActor(runner, caller.actor);
|
|
2143
|
+
runner.all('DELETE FROM _ganju_changes');
|
|
2144
|
+
let updated = 0;
|
|
2145
|
+
const kept: DataFileLinksReplaced['kept'] = [];
|
|
2146
|
+
for (const { table, column } of columns) {
|
|
2147
|
+
for (const change of changes) {
|
|
2148
|
+
if (change.to === null && column.required) {
|
|
2149
|
+
const rows = Number(
|
|
2150
|
+
first(
|
|
2151
|
+
runner,
|
|
2152
|
+
`SELECT count(*) AS n FROM ${q(table.name)} WHERE ${q(column.name)} = ?`,
|
|
2153
|
+
[change.from]
|
|
2154
|
+
)?.n ?? 0
|
|
2155
|
+
);
|
|
2156
|
+
if (rows > 0) kept.push({ table: table.name, column: column.name, rows });
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
updated += runner.all(
|
|
2160
|
+
`UPDATE ${q(table.name)} SET ${q(column.name)} = ?, version = version + 1, updated_at = ${DATA_NOW_SQL}, updated_by = ? WHERE ${q(column.name)} = ? RETURNING id`,
|
|
2161
|
+
[change.to, caller.actor, change.from]
|
|
2162
|
+
).length;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
// Tidying links after a knowledge change isn't the owner's edit: it
|
|
2166
|
+
// stays in row history but fires no trigger.
|
|
2167
|
+
runner.all('DELETE FROM _ganju_changes');
|
|
2168
|
+
return { updated, kept };
|
|
2169
|
+
});
|
|
2170
|
+
};
|
|
2171
|
+
|
|
1982
2172
|
// Row history
|
|
1983
2173
|
|
|
1984
2174
|
export const rowHistory = (
|