@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,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,6 +4,9 @@
|
|
|
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,
|
|
@@ -56,7 +59,8 @@ export interface DatabaseInfo {
|
|
|
56
59
|
timeZone: string | null;
|
|
57
60
|
schemaVersion: number;
|
|
58
61
|
size: number;
|
|
59
|
-
|
|
62
|
+
// `rows` is about how many: the highest id, read from the index.
|
|
63
|
+
tables: { name: string; label: string | null; columns: number; rows: number }[];
|
|
60
64
|
// A column change copying a large table in the background: table changes,
|
|
61
65
|
// and writes that would change its rows, wait while it runs.
|
|
62
66
|
copy: DataTableCopy | null;
|
|
@@ -106,6 +110,12 @@ export interface ProjectDatabaseRpc {
|
|
|
106
110
|
dismissCopy(caller: DataCaller): R<{ copy: null }>;
|
|
107
111
|
describe(caller: DataCaller, args?: unknown): R<unknown>;
|
|
108
112
|
query(caller: DataCaller, args: unknown): R<unknown>;
|
|
113
|
+
// A row as a trigger event carries it (`id`, or the newest): for a test
|
|
114
|
+
// send and for a run whose event was too large to carry its row.
|
|
115
|
+
eventRow(
|
|
116
|
+
caller: DataCaller,
|
|
117
|
+
args: { table: string; id?: number }
|
|
118
|
+
): R<Record<string, unknown> | null>;
|
|
109
119
|
write(
|
|
110
120
|
caller: DataCaller,
|
|
111
121
|
args: unknown
|
|
@@ -114,6 +124,12 @@ export interface ProjectDatabaseRpc {
|
|
|
114
124
|
caller: DataCaller,
|
|
115
125
|
args: { table: string; ids?: number[] }
|
|
116
126
|
): R<DataDeleteImpact>;
|
|
127
|
+
// The rows that link knowledge items, and moving or clearing those links.
|
|
128
|
+
fileLinks(caller: DataCaller, args: { uris: string[] }): R<DataFileLinks>;
|
|
129
|
+
replaceFileLinks(
|
|
130
|
+
caller: DataCaller,
|
|
131
|
+
args: { changes: DataFileLinkChange[] }
|
|
132
|
+
): R<DataFileLinksReplaced>;
|
|
117
133
|
history(
|
|
118
134
|
caller: DataCaller,
|
|
119
135
|
args: { table: string; id: number; limit?: number; cursor?: number }
|
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).',
|
package/src/dataTriggers.ts
CHANGED
|
@@ -146,6 +146,9 @@ export interface DataTriggerEventMessage {
|
|
|
146
146
|
depth: number;
|
|
147
147
|
at: string;
|
|
148
148
|
truncated?: true;
|
|
149
|
+
// The row's customer on a customer-scoped table, kept when the row is
|
|
150
|
+
// left out for its size.
|
|
151
|
+
participantId?: string;
|
|
149
152
|
// Not run: the chain went too deep. Recorded as a failed run.
|
|
150
153
|
error?: string;
|
|
151
154
|
}
|
|
@@ -321,6 +324,29 @@ export const renderTriggerText = (
|
|
|
321
324
|
return typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
322
325
|
});
|
|
323
326
|
|
|
327
|
+
export interface DataTriggerCustomerMessage {
|
|
328
|
+
text: string;
|
|
329
|
+
template: { name: string; language: string; params: string[] } | null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// What a "customer" action sends for a row: its text, and the template with
|
|
333
|
+
// each value filled in. A test send builds it the same way as a real run.
|
|
334
|
+
export const renderCustomerMessage = (
|
|
335
|
+
action: { text: string; template?: DataTriggerTemplate },
|
|
336
|
+
row: Record<string, unknown> | null
|
|
337
|
+
): DataTriggerCustomerMessage => ({
|
|
338
|
+
text: renderTriggerText(action.text, row),
|
|
339
|
+
template: action.template
|
|
340
|
+
? {
|
|
341
|
+
name: action.template.name,
|
|
342
|
+
language: action.template.language,
|
|
343
|
+
params: (action.template.params ?? []).map(p =>
|
|
344
|
+
renderTriggerText(p, row)
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
: null
|
|
348
|
+
});
|
|
349
|
+
|
|
324
350
|
const templateFields = (text: string) =>
|
|
325
351
|
[...text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)].map(m => m[1]);
|
|
326
352
|
|
|
@@ -465,6 +491,23 @@ const normaliseAction = (
|
|
|
465
491
|
}
|
|
466
492
|
};
|
|
467
493
|
|
|
494
|
+
// A "customer" action as the dialog has it, saved or not, checked against
|
|
495
|
+
// its table the way saving it would be.
|
|
496
|
+
export const normaliseCustomerAction = (
|
|
497
|
+
schema: DataSchema,
|
|
498
|
+
tableName: string,
|
|
499
|
+
value: unknown
|
|
500
|
+
): Extract<DataTriggerAction, { type: 'customer' }> => {
|
|
501
|
+
const table = schema.tables.find(t => t.name === tableName);
|
|
502
|
+
if (!table) {
|
|
503
|
+
throw new DataError(`There is no table named ${tableName}.`, 'not_found');
|
|
504
|
+
}
|
|
505
|
+
return normaliseAction(table, { customer: value }) as Extract<
|
|
506
|
+
DataTriggerAction,
|
|
507
|
+
{ type: 'customer' }
|
|
508
|
+
>;
|
|
509
|
+
};
|
|
510
|
+
|
|
468
511
|
const defaultName = (table: string, on: string, action: DataTriggerAction) => {
|
|
469
512
|
const target =
|
|
470
513
|
action.type === 'function'
|
|
@@ -898,6 +941,29 @@ const shape = (
|
|
|
898
941
|
return out;
|
|
899
942
|
};
|
|
900
943
|
|
|
944
|
+
/**
|
|
945
|
+
* A row as a trigger event carries it, read from the table: `id`, or the
|
|
946
|
+
* newest row. Null when there is none. A test send and a run whose event
|
|
947
|
+
* was too large to carry its row build their message from it, so it reads
|
|
948
|
+
* as it does for the event, system fields and the customer included.
|
|
949
|
+
*/
|
|
950
|
+
export const readEventRow = (
|
|
951
|
+
runner: SqlRunner,
|
|
952
|
+
schema: DataSchema,
|
|
953
|
+
tableName: string,
|
|
954
|
+
id?: number
|
|
955
|
+
): Record<string, unknown> | null => {
|
|
956
|
+
const table = schema.tables.find(t => t.name === tableName);
|
|
957
|
+
if (!table) {
|
|
958
|
+
throw new DataError(`There is no table named ${tableName}.`, 'not_found');
|
|
959
|
+
}
|
|
960
|
+
const [stored] =
|
|
961
|
+
id === undefined
|
|
962
|
+
? runner.all(`SELECT * FROM "${table.name}" ORDER BY id DESC LIMIT 1`)
|
|
963
|
+
: runner.all(`SELECT * FROM "${table.name}" WHERE id = ?`, [id]);
|
|
964
|
+
return stored ? shape(table, JSON.stringify(stored)) : null;
|
|
965
|
+
};
|
|
966
|
+
|
|
901
967
|
const DEPTH_PATTERN = /^trigger:([^\s]+)$/;
|
|
902
968
|
|
|
903
969
|
/**
|
|
@@ -933,6 +999,7 @@ export const collectTriggerEvents = (
|
|
|
933
999
|
if (listening.length === 0) continue;
|
|
934
1000
|
const row = shape(table, change.new);
|
|
935
1001
|
const previous = shape(table, change.old);
|
|
1002
|
+
const customer = row?.participant_id ?? previous?.participant_id;
|
|
936
1003
|
for (const trigger of listening) {
|
|
937
1004
|
if (trigger.id === ownTrigger) continue;
|
|
938
1005
|
if (!conditionMatches(trigger.when, row, previous)) continue;
|
|
@@ -948,7 +1015,10 @@ export const collectTriggerEvents = (
|
|
|
948
1015
|
previous,
|
|
949
1016
|
actor: (change.actor as string | null) ?? null,
|
|
950
1017
|
depth,
|
|
951
|
-
at: new Date().toISOString()
|
|
1018
|
+
at: new Date().toISOString(),
|
|
1019
|
+
...(typeof customer === 'string' && customer
|
|
1020
|
+
? { participantId: customer }
|
|
1021
|
+
: {})
|
|
952
1022
|
};
|
|
953
1023
|
if (depth > DATA_TRIGGER_MAX_DEPTH) {
|
|
954
1024
|
message.row = null;
|
package/src/dataTypes.ts
CHANGED
|
@@ -138,11 +138,33 @@ export interface DataCallerLimits {
|
|
|
138
138
|
readOnly?: boolean;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
// What one query on a table did: the columns it filtered and sorted on, and
|
|
142
|
+
// how many rows it read to return how many. The API sums them per column for
|
|
143
|
+
// a week to suggest indexes.
|
|
144
|
+
export interface DataQueryShape {
|
|
145
|
+
table: string;
|
|
146
|
+
filtered: string[];
|
|
147
|
+
sorted: string[];
|
|
148
|
+
rowsRead: number;
|
|
149
|
+
returned: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Rows one write operation wrote to a table, which tells a write-heavy table.
|
|
153
|
+
export interface DataWriteShape {
|
|
154
|
+
table: string;
|
|
155
|
+
rowsWritten: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
141
158
|
export interface DataUsage {
|
|
142
159
|
rowsRead: number;
|
|
143
160
|
rowsWritten: number;
|
|
161
|
+
queries?: DataQueryShape[];
|
|
162
|
+
writes?: DataWriteShape[];
|
|
144
163
|
}
|
|
145
164
|
|
|
165
|
+
// At most this many shapes travel back with one call.
|
|
166
|
+
export const DATA_MAX_USAGE_SHAPES = 20;
|
|
167
|
+
|
|
146
168
|
export const DATA_ERROR_CODES = [
|
|
147
169
|
'invalid',
|
|
148
170
|
'not_found',
|
|
@@ -278,6 +300,46 @@ export interface DataDeleteImpact {
|
|
|
278
300
|
truncated: boolean;
|
|
279
301
|
}
|
|
280
302
|
|
|
303
|
+
// A file column links a knowledge item by its uri. Reads return it as
|
|
304
|
+
// { uri }, which the workers fill with the item's title and type.
|
|
305
|
+
export const DATA_FILE_URI_MAX = 2048;
|
|
306
|
+
export const DATA_FILE_LINKS_MAX_URIS = 500;
|
|
307
|
+
|
|
308
|
+
export interface DataFileValue {
|
|
309
|
+
uri: string;
|
|
310
|
+
// The item's id and size: only in the dashboard's reads, for previews.
|
|
311
|
+
id?: string;
|
|
312
|
+
size?: number;
|
|
313
|
+
title?: string;
|
|
314
|
+
mimeType?: string;
|
|
315
|
+
// No knowledge item has this uri any more.
|
|
316
|
+
missing?: true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// The rows whose file columns point at some uris, per column.
|
|
320
|
+
export interface DataFileLinks {
|
|
321
|
+
links: {
|
|
322
|
+
table: string;
|
|
323
|
+
tableLabel: string | null;
|
|
324
|
+
column: string;
|
|
325
|
+
columnLabel: string | null;
|
|
326
|
+
required: boolean;
|
|
327
|
+
rows: number;
|
|
328
|
+
}[];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export interface DataFileLinkChange {
|
|
332
|
+
from: string;
|
|
333
|
+
// null clears the link.
|
|
334
|
+
to: string | null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface DataFileLinksReplaced {
|
|
338
|
+
updated: number;
|
|
339
|
+
// Required columns can't be emptied: their links stay, and show as missing.
|
|
340
|
+
kept: { table: string; column: string; rows: number }[];
|
|
341
|
+
}
|
|
342
|
+
|
|
281
343
|
// A table copy running in the background, as callers see it.
|
|
282
344
|
export interface DataTableCopy {
|
|
283
345
|
table: string;
|
package/src/index.ts
CHANGED
|
@@ -129,6 +129,8 @@ import { oauthProviders } from './oauthProviders';
|
|
|
129
129
|
import type { OAuthProviderConfig } from './oauthProviders';
|
|
130
130
|
import { resolveAttachment } from './attachment';
|
|
131
131
|
import { isExposedResource } from './exposedResource';
|
|
132
|
+
import { dataAttachmentOf, skipsIndexing } from './dataAttachment';
|
|
133
|
+
import type { DataAttachment } from './dataAttachment';
|
|
132
134
|
import type { ExposableResource } from './exposedResource';
|
|
133
135
|
import {
|
|
134
136
|
isSyncableResource,
|
|
@@ -391,6 +393,10 @@ import {
|
|
|
391
393
|
cleanWhatsappTemplateParam,
|
|
392
394
|
whatsappSendError
|
|
393
395
|
} from './whatsappTemplates';
|
|
396
|
+
import {
|
|
397
|
+
whatsappMessageChange,
|
|
398
|
+
whatsappAccountChanges
|
|
399
|
+
} from './whatsappWebhook';
|
|
394
400
|
import type {
|
|
395
401
|
WhatsappTemplateCategory,
|
|
396
402
|
WhatsappTemplateDraft,
|
|
@@ -428,6 +434,8 @@ export const utils = {
|
|
|
428
434
|
whatsappTemplateSendable,
|
|
429
435
|
cleanWhatsappTemplateParam,
|
|
430
436
|
whatsappSendError,
|
|
437
|
+
whatsappMessageChange,
|
|
438
|
+
whatsappAccountChanges,
|
|
431
439
|
getEnv,
|
|
432
440
|
fetcher,
|
|
433
441
|
constants,
|
|
@@ -485,6 +493,8 @@ export const utils = {
|
|
|
485
493
|
oauthProviders,
|
|
486
494
|
resolveAttachment,
|
|
487
495
|
isExposedResource,
|
|
496
|
+
dataAttachmentOf,
|
|
497
|
+
skipsIndexing,
|
|
488
498
|
isSyncableResource,
|
|
489
499
|
isResourceSyncInProgress,
|
|
490
500
|
resourceSyncProvider,
|
|
@@ -730,6 +740,7 @@ export type {
|
|
|
730
740
|
AttachmentResource,
|
|
731
741
|
ResolvedAttachment,
|
|
732
742
|
ResolveAttachmentResult,
|
|
743
|
+
DataAttachment,
|
|
733
744
|
ExposableResource,
|
|
734
745
|
SyncableResource,
|
|
735
746
|
ResourceSyncProvider,
|
package/src/localizeDataError.ts
CHANGED
|
@@ -39,6 +39,21 @@ const guardSuffix = (suffix: string): string => {
|
|
|
39
39
|
return suffix;
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
// Meta's other template statuses, as the refusal writes them.
|
|
43
|
+
const TEMPLATE_STATUS_ES: Record<string, string> = {
|
|
44
|
+
paused: 'pausada',
|
|
45
|
+
disabled: 'desactivada',
|
|
46
|
+
flagged: 'marcada',
|
|
47
|
+
'in appeal': 'en apelación',
|
|
48
|
+
'pending deletion': 'pendiente de eliminación',
|
|
49
|
+
deleted: 'eliminada',
|
|
50
|
+
'limit exceeded': 'por encima del límite'
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// "The WhatsApp template x (es)", as a channel's refusal names it.
|
|
54
|
+
const templateLabel = (label: string): string =>
|
|
55
|
+
label.replace(/^The WhatsApp template /, 'La plantilla de WhatsApp ');
|
|
56
|
+
|
|
42
57
|
export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
|
|
43
58
|
'Unknown column "{0}" in {1}. Columns: {2}.':
|
|
44
59
|
'Columna desconocida "{0}" en {1}. Columnas: {2}.',
|
|
@@ -124,8 +139,22 @@ export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
|
|
|
124
139
|
'{0}: "min" is a number.': '{0}: "min" es un número.',
|
|
125
140
|
"{0}: a {1} column can't be unique.":
|
|
126
141
|
'{0}: una columna {1} no puede ser única.',
|
|
127
|
-
'{0}: only text columns can be searchable (text, long_text, email, url, phone, enum).':
|
|
128
|
-
'{0}: solo las columnas de texto pueden ser buscables (text, long_text, email, url, phone, enum).',
|
|
142
|
+
'{0}: only text and file columns can be searchable (text, long_text, email, url, phone, enum, file).':
|
|
143
|
+
'{0}: solo las columnas de texto y de archivo pueden ser buscables (text, long_text, email, url, phone, enum, file).',
|
|
144
|
+
"{0} takes a knowledge item's uri, e.g. resource://menu-photo.":
|
|
145
|
+
'{0} lleva el uri de un elemento del conocimiento, p. ej. resource://menu-photo.',
|
|
146
|
+
'{0}: a uri is at most {1} characters.':
|
|
147
|
+
'{0}: un uri tiene como máximo {1} caracteres.',
|
|
148
|
+
'"uris" lists 1 to {0} knowledge item uris.':
|
|
149
|
+
'"uris" lista de 1 a {0} uris de elementos del conocimiento.',
|
|
150
|
+
'No resource in this project has the uri {0}; link an existing resource by its uri.':
|
|
151
|
+
'Ningún recurso de este proyecto tiene el uri {0}; enlaza un recurso que exista por su uri.',
|
|
152
|
+
'File links are for the owner.':
|
|
153
|
+
'Los enlaces a archivos son para el dueño.',
|
|
154
|
+
'"changes" lists 1 to {0} { from, to } pairs.':
|
|
155
|
+
'"changes" lista de 1 a {0} pares { from, to }.',
|
|
156
|
+
'Each change is { from, to }; to is null to clear.':
|
|
157
|
+
'Cada cambio es { from, to }; to es null para quitar el enlace.',
|
|
129
158
|
'{0}: a column named "rank" can\'t be searchable; rename it (e.g. "ranking").':
|
|
130
159
|
'{0}: una columna llamada "rank" no puede ser buscable; cámbiale el nombre (p. ej. "ranking").',
|
|
131
160
|
"{0}: a {1} column can't be filterable; mark it searchable instead.":
|
|
@@ -469,6 +498,9 @@ export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
|
|
|
469
498
|
'Type the table\'s name, "{0}", to drop it.':
|
|
470
499
|
'Escribe el nombre de la tabla, "{0}", para eliminarla.',
|
|
471
500
|
'There is no such run.': 'No existe esa ejecución.',
|
|
501
|
+
'There is no such index suggestion.': 'No existe esa sugerencia de índice.',
|
|
502
|
+
'An unused index is only flagged; remove it from the column if you want to, or dismiss the suggestion.':
|
|
503
|
+
'Un índice sin uso solo se señala; quítalo de la columna si quieres, o descarta la sugerencia.',
|
|
472
504
|
'Only a failed or skipped run can be tried again.':
|
|
473
505
|
'Solo se puede reintentar una ejecución fallida u omitida.',
|
|
474
506
|
'This run kept no event to send again.':
|
|
@@ -516,7 +548,73 @@ export const DATA_ERROR_MESSAGES_ES: Record<string, string | Render> = {
|
|
|
516
548
|
'"{0}" is reserved by SQLite; pick another column name (e.g. "{1}_ref").':
|
|
517
549
|
'"{0}" está reservado por SQLite; elige otro nombre de columna (p. ej. "{1}_ref").',
|
|
518
550
|
'"{0}" is a column every table already has.':
|
|
519
|
-
'"{0}" es una columna que ya tienen todas las tablas.'
|
|
551
|
+
'"{0}" es una columna que ya tienen todas las tablas.',
|
|
552
|
+
// A test send of a trigger's customer message.
|
|
553
|
+
"You haven't linked a chat with this project's bots: write to the bot from your own phone and send /link.":
|
|
554
|
+
'No has vinculado un chat con los bots de este proyecto: escríbele al bot desde tu propio teléfono y envía /link.',
|
|
555
|
+
"{0} isn't one of your linked chats with this project's bots.":
|
|
556
|
+
'{0} no es uno de tus chats vinculados con los bots de este proyecto.',
|
|
557
|
+
'Several of your chats are linked to this project\'s bots; pick one with "participantId".':
|
|
558
|
+
'Varios de tus chats están vinculados con los bots de este proyecto; elige uno con "participantId".',
|
|
559
|
+
'{0} has no rows yet; add one to build the test from.':
|
|
560
|
+
'{0} aún no tiene filas; agrega una para armar la prueba.',
|
|
561
|
+
'Reading a row as its trigger event is for the project owner.':
|
|
562
|
+
'Leer una fila como su evento de disparador es solo para el dueño del proyecto.',
|
|
563
|
+
// The channel's refusals, which a test send answers with.
|
|
564
|
+
"This channel's credentials can't be read; connect it again.":
|
|
565
|
+
'No se pueden leer las credenciales de este canal; conéctalo de nuevo.',
|
|
566
|
+
'A message needs "text".': 'Un mensaje necesita "text".',
|
|
567
|
+
'A message is at most {0} characters.':
|
|
568
|
+
'Un mensaje tiene como máximo {0} caracteres.',
|
|
569
|
+
'{0} refused the message: {1}': '{0} rechazó el mensaje: {1}',
|
|
570
|
+
'{0} refused the message.': '{0} rechazó el mensaje.',
|
|
571
|
+
"Ganju can't send messages on {0} channels.":
|
|
572
|
+
'Ganju no puede enviar mensajes en canales de {0}.',
|
|
573
|
+
'WhatsApp: {0}': 'WhatsApp: {0}',
|
|
574
|
+
'The {0} channel is paused.': 'El canal de {0} está en pausa.',
|
|
575
|
+
"{0} isn't on this channel's WhatsApp Business account. Create it in the channel's Message templates, or check its name and language.":
|
|
576
|
+
a =>
|
|
577
|
+
`${templateLabel(a[0])} no está en la cuenta de WhatsApp Business de este canal. Créala en Plantillas de mensajes del canal, o revisa su nombre e idioma.`,
|
|
578
|
+
'{0} {1}; only approved templates can be sent.': a => {
|
|
579
|
+
const whole = `${a[0]} ${a[1]}`;
|
|
580
|
+
const m =
|
|
581
|
+
/^(The WhatsApp template [\s\S]+? \([^)]*\)) (is still in Meta's review|was rejected by Meta|is ([\s\S]+?) by Meta)( \([\s\S]*\))?$/.exec(
|
|
582
|
+
whole
|
|
583
|
+
);
|
|
584
|
+
if (!m) return `${whole}; solo se pueden enviar plantillas aprobadas.`;
|
|
585
|
+
const why = m[2].startsWith('is still')
|
|
586
|
+
? 'sigue en revisión de Meta'
|
|
587
|
+
: m[2].startsWith('was rejected')
|
|
588
|
+
? 'fue rechazada por Meta'
|
|
589
|
+
: `está ${TEMPLATE_STATUS_ES[m[3]] ?? `en el estado ${m[3]}`} por Meta`;
|
|
590
|
+
return `${templateLabel(m[1])} ${why}${m[4] ?? ''}; solo se pueden enviar plantillas aprobadas.`;
|
|
591
|
+
},
|
|
592
|
+
"{0} has a media header, a header variable or a button that takes a value; Ganju only fills the message's variables. Make a version without them.":
|
|
593
|
+
a =>
|
|
594
|
+
`${templateLabel(a[0])} tiene un encabezado con archivo, una variable en el encabezado o un botón que recibe un valor; Ganju solo llena las variables del mensaje. Haz una versión sin ellos.`,
|
|
595
|
+
"There is no customer {0} on this project's channels.":
|
|
596
|
+
'No hay ningún cliente {0} en los canales de este proyecto.',
|
|
597
|
+
'A message needs "text", a "template", or both.':
|
|
598
|
+
'Un mensaje necesita "text", una "template", o ambos.',
|
|
599
|
+
'This customer has never written to the bot privately, so there is no chat to message them in.':
|
|
600
|
+
'Este cliente nunca le ha escrito al bot en privado, así que no hay un chat donde escribirle.',
|
|
601
|
+
"This customer already got the channel's {0} messages for the last 24 hours.":
|
|
602
|
+
'Este cliente ya recibió los {0} mensajes del canal de las últimas 24 horas.',
|
|
603
|
+
"Outside WhatsApp's 24-hour window (this customer last wrote more than 24 hours ago); use an approved template.":
|
|
604
|
+
'Fuera de la ventana de 24 horas de WhatsApp (este cliente escribió por última vez hace más de 24 horas); usa una plantilla aprobada.',
|
|
605
|
+
'Templates are for WhatsApp; {0} takes "text".':
|
|
606
|
+
'Las plantillas son para WhatsApp; {0} recibe "text".',
|
|
607
|
+
'The WhatsApp template {0} has {1} variable; {2} value was given.':
|
|
608
|
+
'La plantilla de WhatsApp {0} tiene {1} variable; se dio {2} valor.',
|
|
609
|
+
'The WhatsApp template {0} has {1} variable; {2} values were given.':
|
|
610
|
+
'La plantilla de WhatsApp {0} tiene {1} variable; se dieron {2} valores.',
|
|
611
|
+
'The WhatsApp template {0} has {1} variables; {2} value was given.':
|
|
612
|
+
'La plantilla de WhatsApp {0} tiene {1} variables; se dio {2} valor.',
|
|
613
|
+
'The WhatsApp template {0} has {1} variables; {2} values were given.':
|
|
614
|
+
'La plantilla de WhatsApp {0} tiene {1} variables; se dieron {2} valores.',
|
|
615
|
+
'A post needs "chatId".': 'Una publicación necesita "chatId".',
|
|
616
|
+
'WhatsApp has no group chats for bots; message a customer with send().':
|
|
617
|
+
'WhatsApp no tiene chats de grupo para bots; escríbele a un cliente con send().'
|
|
520
618
|
};
|
|
521
619
|
|
|
522
620
|
interface Compiled {
|
package/src/schema.ts
CHANGED
|
@@ -418,6 +418,14 @@ const ARTIFACT_SYNC_ONE_DRIVE = z.object({
|
|
|
418
418
|
organizationId: z.uuid()
|
|
419
419
|
});
|
|
420
420
|
|
|
421
|
+
// A knowledge item uploaded from a Data row: which file column it was for,
|
|
422
|
+
// and whether it is indexed for search too (the column's searchable switch).
|
|
423
|
+
const DATA_ATTACHMENT_METADATA = z.object({
|
|
424
|
+
table: z.string().min(1).max(64),
|
|
425
|
+
column: z.string().min(1).max(64),
|
|
426
|
+
index: z.boolean()
|
|
427
|
+
});
|
|
428
|
+
|
|
421
429
|
const ARTIFACT_CREATE_RESOURCE = z.object({
|
|
422
430
|
title: z.string().min(3).max(200),
|
|
423
431
|
uri: z.string(),
|
|
@@ -529,6 +537,15 @@ const ARTIFACT_GET_RESOURCE_BY_ID = z.object({
|
|
|
529
537
|
organizationId: z.uuid()
|
|
530
538
|
});
|
|
531
539
|
|
|
540
|
+
// The knowledge items a Data file column can link, newest first.
|
|
541
|
+
const ARTIFACT_PICK_RESOURCE = z.object({
|
|
542
|
+
projectId: z.uuid(),
|
|
543
|
+
userId: z.uuid(),
|
|
544
|
+
organizationId: z.uuid(),
|
|
545
|
+
q: z.string().trim().max(200).optional(),
|
|
546
|
+
limit: z.coerce.number().int().min(1).max(50).default(20)
|
|
547
|
+
});
|
|
548
|
+
|
|
532
549
|
const ARTIFACT_REMOVE_RESOURCE = z.object({
|
|
533
550
|
resourceId: z.uuid(),
|
|
534
551
|
projectId: z.uuid(),
|
|
@@ -834,6 +851,17 @@ const DATA_TRIGGER_PATCH = z.strictObject({ enabled: z.boolean() });
|
|
|
834
851
|
const DATA_TRIGGER_DEPLOY_BODY = z.strictObject({
|
|
835
852
|
triggers: z.array(DATA_TRIGGER_INPUT).max(50)
|
|
836
853
|
});
|
|
854
|
+
// A test of a "customer" action as the dialog has it, sent to the signed-in
|
|
855
|
+
// owner's own chat with the bot, built from one row (the newest by default).
|
|
856
|
+
const DATA_TRIGGER_TEST_SEND_BODY = z.strictObject({
|
|
857
|
+
table: DATA_IDENTIFIER,
|
|
858
|
+
customer: DATA_TRIGGER_INPUT.shape.customer.unwrap(),
|
|
859
|
+
rowId: z.number().int().min(1).optional(),
|
|
860
|
+
// Which of the owner's linked chats, when there is more than one.
|
|
861
|
+
participantId: z.string().min(1).max(64).optional(),
|
|
862
|
+
// Send the template even inside WhatsApp's 24-hour window.
|
|
863
|
+
asTemplate: z.boolean().optional()
|
|
864
|
+
});
|
|
837
865
|
const DATA_TRIGGER_RUN_LIST_QUERY = z.object({
|
|
838
866
|
limit: z.coerce.number().int().min(1).max(50).optional()
|
|
839
867
|
});
|
|
@@ -848,6 +876,8 @@ const DATA_TRIGGER_PATH_PARAMS = DATA_DATABASE_GET.extend({ name: DATA_TRIGGER_N
|
|
|
848
876
|
const DATA_TRIGGER_RUN_PARAMS = DATA_TRIGGER_PATH_PARAMS.extend({
|
|
849
877
|
runId: z.string().min(1).max(64)
|
|
850
878
|
});
|
|
879
|
+
// An index suggestion, by its id.
|
|
880
|
+
const DATA_INDEX_ADVICE_PARAMS = DATA_DATABASE_GET.extend({ adviceId: z.uuid() });
|
|
851
881
|
|
|
852
882
|
const ARTIFACT_UPLOAD_RESOURCE_FILE = z.object({
|
|
853
883
|
resourceId: z.uuid(),
|
|
@@ -2150,6 +2180,8 @@ export const Schema = {
|
|
|
2150
2180
|
ARTIFACT_GET_PROMPT,
|
|
2151
2181
|
ARTIFACT_REMOVE_PROMPT,
|
|
2152
2182
|
ARTIFACT_CREATE_RESOURCE,
|
|
2183
|
+
DATA_ATTACHMENT_METADATA,
|
|
2184
|
+
ARTIFACT_PICK_RESOURCE,
|
|
2153
2185
|
ARTIFACT_CREATE_RESOURCE_VIEW,
|
|
2154
2186
|
ARTIFACT_CREATE_WEBSITE,
|
|
2155
2187
|
ARTIFACT_CREATE_WEBSITE_VIEW,
|
|
@@ -2196,9 +2228,11 @@ export const Schema = {
|
|
|
2196
2228
|
DATA_TRIGGER_INPUT,
|
|
2197
2229
|
DATA_TRIGGER_PATCH,
|
|
2198
2230
|
DATA_TRIGGER_DEPLOY_BODY,
|
|
2231
|
+
DATA_TRIGGER_TEST_SEND_BODY,
|
|
2199
2232
|
DATA_TRIGGER_RUN_LIST_QUERY,
|
|
2200
2233
|
DATA_TRIGGER_PATH_PARAMS,
|
|
2201
2234
|
DATA_TRIGGER_RUN_PARAMS,
|
|
2235
|
+
DATA_INDEX_ADVICE_PARAMS,
|
|
2202
2236
|
DATA_COLUMN_PARAMS,
|
|
2203
2237
|
DATA_ROW_PARAMS,
|
|
2204
2238
|
ARTIFACT_REMOVE_RESOURCE,
|