@ganju/utils 0.0.15 → 0.0.17

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 (77) hide show
  1. package/dist/constants.d.ts +26 -0
  2. package/dist/constants.d.ts.map +1 -1
  3. package/dist/constants.js +107 -9
  4. package/dist/customCodeToken.d.ts +11 -0
  5. package/dist/customCodeToken.d.ts.map +1 -1
  6. package/dist/customCodeToken.js +41 -1
  7. package/dist/data.d.ts +13 -0
  8. package/dist/data.d.ts.map +1 -0
  9. package/dist/data.js +103 -0
  10. package/dist/dataApi.d.ts +578 -0
  11. package/dist/dataApi.d.ts.map +1 -0
  12. package/dist/dataApi.js +399 -0
  13. package/dist/dataCompiler.d.ts +68 -0
  14. package/dist/dataCompiler.d.ts.map +1 -0
  15. package/dist/dataCompiler.js +730 -0
  16. package/dist/dataCsv.d.ts +20 -0
  17. package/dist/dataCsv.d.ts.map +1 -0
  18. package/dist/dataCsv.js +102 -0
  19. package/dist/dataDdl.d.ts +29 -0
  20. package/dist/dataDdl.d.ts.map +1 -0
  21. package/dist/dataDdl.js +354 -0
  22. package/dist/dataEngine.d.ts +143 -0
  23. package/dist/dataEngine.d.ts.map +1 -0
  24. package/dist/dataEngine.js +2042 -0
  25. package/dist/dataIdentifiers.d.ts +8 -0
  26. package/dist/dataIdentifiers.d.ts.map +1 -0
  27. package/dist/dataIdentifiers.js +55 -0
  28. package/dist/dataImport.d.ts +79 -0
  29. package/dist/dataImport.d.ts.map +1 -0
  30. package/dist/dataImport.js +453 -0
  31. package/dist/dataQueries.d.ts +73 -0
  32. package/dist/dataQueries.d.ts.map +1 -0
  33. package/dist/dataQueries.js +513 -0
  34. package/dist/dataRpc.d.ts +150 -0
  35. package/dist/dataRpc.d.ts.map +1 -0
  36. package/dist/dataRpc.js +2 -0
  37. package/dist/dataTools.d.ts +40 -0
  38. package/dist/dataTools.d.ts.map +1 -0
  39. package/dist/dataTools.js +280 -0
  40. package/dist/dataTypes.d.ts +159 -0
  41. package/dist/dataTypes.d.ts.map +1 -0
  42. package/dist/dataTypes.js +160 -0
  43. package/dist/index.d.ts +394 -8
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +4 -0
  46. package/dist/oauth.d.ts +14 -0
  47. package/dist/oauth.d.ts.map +1 -1
  48. package/dist/oauth.js +42 -2
  49. package/dist/resourceRerank.d.ts +3 -2
  50. package/dist/resourceRerank.d.ts.map +1 -1
  51. package/dist/resourceRerank.js +5 -2
  52. package/dist/schema.d.ts +376 -4
  53. package/dist/schema.d.ts.map +1 -1
  54. package/dist/schema.js +268 -1
  55. package/dist/sdkConstants.d.ts +1 -0
  56. package/dist/sdkConstants.d.ts.map +1 -1
  57. package/dist/sdkConstants.js +2 -1
  58. package/package.json +17 -2
  59. package/src/constants.ts +121 -9
  60. package/src/customCodeToken.ts +74 -0
  61. package/src/data.ts +108 -0
  62. package/src/dataApi.ts +479 -0
  63. package/src/dataCompiler.ts +952 -0
  64. package/src/dataCsv.ts +99 -0
  65. package/src/dataDdl.ts +419 -0
  66. package/src/dataEngine.ts +2773 -0
  67. package/src/dataIdentifiers.ts +63 -0
  68. package/src/dataImport.ts +557 -0
  69. package/src/dataQueries.ts +730 -0
  70. package/src/dataRpc.ts +132 -0
  71. package/src/dataTools.ts +338 -0
  72. package/src/dataTypes.ts +318 -0
  73. package/src/index.ts +13 -1
  74. package/src/oauth.ts +42 -1
  75. package/src/resourceRerank.ts +5 -2
  76. package/src/schema.ts +315 -1
  77. package/src/sdkConstants.ts +1 -0
@@ -178,6 +178,80 @@ export const verifyCustomCodeToken = async (
178
178
  };
179
179
  };
180
180
 
181
+ // Names the function one dispatch runs.
182
+ //
183
+ // The tool token names the artifact and version but not the function: one
184
+ // script serves every function, and it would otherwise say itself which one
185
+ // is calling. What a function may do with the database (its manifest's
186
+ // `data`) needs a name the script can't choose, so the dispatcher signs one
187
+ // per call and passes it in with the input; the script only hands it back.
188
+ // Signed with a key derived from the same secret, so a call token is never
189
+ // a tool token or the other way round.
190
+ export interface CustomCodeCallPayload {
191
+ v: string;
192
+ artifactId: string;
193
+ versionId: string;
194
+ tool: string;
195
+ exp: number;
196
+ }
197
+
198
+ const callSecret = (secret: string) => `${secret}:call`;
199
+
200
+ export const mintCustomCodeCallToken = async (
201
+ payload: Omit<CustomCodeCallPayload, 'v' | 'exp'> & { ttlMs: number },
202
+ secret: string,
203
+ issuedAt: number = Date.now()
204
+ ): Promise<string> => {
205
+ const body: CustomCodeCallPayload = {
206
+ v: constants.CUSTOM_CODE_TOKEN_VERSION,
207
+ artifactId: payload.artifactId,
208
+ versionId: payload.versionId,
209
+ tool: payload.tool,
210
+ exp: Math.ceil((issuedAt + payload.ttlMs) / 1000)
211
+ };
212
+ const encodedPayload = toBase64Url(
213
+ bytesToBase64(encoder.encode(JSON.stringify(body)))
214
+ );
215
+ const signature = await signPayload(encodedPayload, callSecret(secret));
216
+ return `${encodedPayload}.${signature}`;
217
+ };
218
+
219
+ export const verifyCustomCodeCallToken = async (
220
+ token: string,
221
+ secret: string,
222
+ now: number = Date.now()
223
+ ): Promise<CustomCodeCallPayload | null> => {
224
+ const separator = token.lastIndexOf('.');
225
+ if (separator <= 0) return null;
226
+ const encodedPayload = token.slice(0, separator);
227
+ try {
228
+ const key = await importKey(callSecret(secret));
229
+ const valid = await crypto.subtle.verify(
230
+ 'HMAC',
231
+ key,
232
+ new Uint8Array(base64ToBytes(fromBase64Url(token.slice(separator + 1)))),
233
+ encoder.encode(encodedPayload)
234
+ );
235
+ if (!valid) return null;
236
+ const candidate = JSON.parse(
237
+ new TextDecoder().decode(base64ToBytes(fromBase64Url(encodedPayload)))
238
+ ) as Partial<CustomCodeCallPayload>;
239
+ if (
240
+ candidate.v !== constants.CUSTOM_CODE_TOKEN_VERSION ||
241
+ typeof candidate.artifactId !== 'string' ||
242
+ typeof candidate.versionId !== 'string' ||
243
+ typeof candidate.tool !== 'string' ||
244
+ typeof candidate.exp !== 'number' ||
245
+ candidate.exp * 1000 <= now
246
+ ) {
247
+ return null;
248
+ }
249
+ return candidate as CustomCodeCallPayload;
250
+ } catch {
251
+ return null;
252
+ }
253
+ };
254
+
181
255
  /**
182
256
  * The legacy dispatch-namespace script name for an artifact: `artifact_<id>`.
183
257
  *
package/src/data.ts ADDED
@@ -0,0 +1,108 @@
1
+ // Entry point for `@ganju/utils/data`: everything a project's database and
2
+ // its callers share, without pulling in the rest of the package.
3
+ export * from './dataTypes';
4
+ export {
5
+ DATA_IDENTIFIER_PATTERN,
6
+ DATA_SYSTEM_COLUMNS,
7
+ DATA_HIDDEN_COLUMNS,
8
+ identifierIssue,
9
+ isValidIdentifier
10
+ } from './dataIdentifiers';
11
+ export {
12
+ createTableSql,
13
+ columnDefinitionSql,
14
+ desiredIndexes,
15
+ desiredTriggers,
16
+ ftsCreateSql,
17
+ ftsTableName,
18
+ searchableColumns,
19
+ sqlLiteral
20
+ } from './dataDdl';
21
+ export {
22
+ chunkRows,
23
+ rowsPerInsert,
24
+ ftsMatch,
25
+ keysetCondition,
26
+ encodeCursor,
27
+ decodeCursor,
28
+ isSensitiveWrite,
29
+ tidyArgs,
30
+ zoned
31
+ } from './dataCompiler';
32
+ export type { SqlValue, KeysetCursor, SortKey } from './dataCompiler';
33
+ export {
34
+ INTERNAL_MIGRATIONS,
35
+ INTERNAL_SCHEMA_VERSION,
36
+ hasInternalTables,
37
+ getMeta,
38
+ setMeta,
39
+ migrateInternal,
40
+ loadSchema,
41
+ toTableInput,
42
+ normaliseColumn,
43
+ translateSqliteError,
44
+ describe,
45
+ query,
46
+ write,
47
+ rowHistory,
48
+ pruneHistory,
49
+ deleteImpact,
50
+ createTable,
51
+ updateTable,
52
+ dropTable,
53
+ addColumn,
54
+ updateColumn,
55
+ dropColumn,
56
+ columnChangeNeedsRebuild,
57
+ dropColumnNeedsRebuild,
58
+ readTableCopy,
59
+ clearFailedTableCopy,
60
+ copyTableStep,
61
+ writeReachesCopy,
62
+ assertActor,
63
+ assertWritable,
64
+ exportRows
65
+ } from './dataEngine';
66
+ export type { SqlRow, SqlRunner } from './dataEngine';
67
+ export {
68
+ DATA_QUERY_PARAM_TYPES,
69
+ DATA_MAX_QUERIES,
70
+ DATA_MAX_QUERY_SQL,
71
+ DATA_MAX_QUERY_PARAMS,
72
+ DATA_DEFAULT_QUERY_ROWS,
73
+ compileQuerySql,
74
+ checkQueryPlan,
75
+ listQueries,
76
+ queriesFor,
77
+ saveQuery,
78
+ dropQuery,
79
+ runQuery
80
+ } from './dataQueries';
81
+ export type {
82
+ DataQuery,
83
+ DataQueryInput,
84
+ DataQueryParam,
85
+ DataQueryParamType
86
+ } from './dataQueries';
87
+ export type {
88
+ ProjectDatabaseRpc,
89
+ DatabaseInfo,
90
+ ExportFile,
91
+ ExportFormat,
92
+ ExportState,
93
+ RestoreResult
94
+ } from './dataRpc';
95
+ export {
96
+ DATA_TOOL_DESCRIBE,
97
+ DATA_TOOL_QUERY,
98
+ DATA_TOOL_WRITE,
99
+ DATA_TOOL_ADMIN,
100
+ DATA_TOOL_NAMES,
101
+ DATA_TOOL_TITLES,
102
+ DATA_TOOL_MAX_LIMIT,
103
+ DATA_ADMIN_ACTIONS,
104
+ hasDataToolTables,
105
+ dataToolDefinitions,
106
+ dataAdminToolDefinition
107
+ } from './dataTools';
108
+ export type { DataToolDefinition, DataAdminAction } from './dataTools';
package/src/dataApi.ts ADDED
@@ -0,0 +1,479 @@
1
+ // The Data API's routes, as one list: apps/api registers its data routes from
2
+ // it, and the OpenAPI file is generated from it, so the documented API and the
3
+ // served one can't drift apart. Entry point `@ganju/utils/dataApi`.
4
+ import { z } from 'zod';
5
+
6
+ import { Schema } from './schema';
7
+
8
+ export const DATA_API_BASE =
9
+ '/organization/:organizationId/project/:projectId/artifact/data';
10
+
11
+ export type DataApiMethod = 'get' | 'post' | 'patch' | 'delete';
12
+
13
+ export interface DataApiRoute {
14
+ method: DataApiMethod;
15
+ // Relative to DATA_API_BASE, in Hono's `:param` form.
16
+ path: string;
17
+ summary: string;
18
+ description?: string;
19
+ tag: 'Database' | 'Tables' | 'Rows' | 'Queries' | 'History and export';
20
+ body?: z.ZodType;
21
+ query?: z.ZodObject;
22
+ // What a 200/201 answers with, in a sentence.
23
+ returns: string;
24
+ status?: 200 | 201;
25
+ // Answers with a file instead of JSON.
26
+ file?: boolean;
27
+ }
28
+
29
+ const {
30
+ DATA_DATABASE_CREATE,
31
+ DATA_DATABASE_DELETE,
32
+ DATA_DATABASE_PATCH,
33
+ DATA_TABLE_INPUT,
34
+ DATA_TABLE_PATCH,
35
+ DATA_COLUMN_INPUT,
36
+ DATA_COLUMN_PATCH,
37
+ DATA_TABLE_DROP_BODY,
38
+ DATA_ROW_LIST_QUERY,
39
+ DATA_ROW_GET_QUERY,
40
+ DATA_ROW_CREATE_BODY,
41
+ DATA_ROW_UPDATE_BODY,
42
+ DATA_ROW_DELETE_BODY,
43
+ DATA_DELETE_PREVIEW_BODY,
44
+ DATA_WRITE_BODY,
45
+ DATA_ROW_HISTORY_QUERY,
46
+ DATA_RESTORE_POINT_BODY,
47
+ DATA_EXPORT_BODY,
48
+ DATA_EXPORT_QUERY,
49
+ DATA_EXPORT_FILE_QUERY,
50
+ DATA_CHANGE_LIST_QUERY,
51
+ DATA_QUERY_INPUT,
52
+ DATA_QUERY_RUN_BODY
53
+ } = Schema;
54
+
55
+ export const DATA_API_ROUTES = {
56
+ getDatabase: {
57
+ method: 'get',
58
+ path: '',
59
+ tag: 'Database',
60
+ summary: "The project's database",
61
+ description:
62
+ 'Its status and size, and its tables when it is ready. `database` is null when the project has none.',
63
+ returns: '{ database }'
64
+ },
65
+ create: {
66
+ method: 'post',
67
+ path: '',
68
+ tag: 'Database',
69
+ summary: 'Create the database',
70
+ description:
71
+ "Pro and Enterprise. Safe to repeat: a second call returns the same database. `name` defaults to the project's slug. While a deleted database is in its grace period this is refused unless `replaceDeleted` is true: the deleted one can then no longer be restored or exported, and is emptied when its grace period ends.",
72
+ body: DATA_DATABASE_CREATE.pick({ name: true, replaceDeleted: true }),
73
+ returns: '{ database }',
74
+ status: 201
75
+ },
76
+ updateDatabase: {
77
+ method: 'patch',
78
+ path: '',
79
+ tag: 'Database',
80
+ summary: "Change the database's settings",
81
+ description:
82
+ '`timeZone`, an IANA zone such as `America/Bogota`, or null for UTC. Datetimes come back in it for callers that send no `timeZone` of their own: the assistant, functions and scripts. Datetimes without an offset are read in it too.',
83
+ body: DATA_DATABASE_PATCH,
84
+ returns: '{ database }'
85
+ },
86
+ remove: {
87
+ method: 'delete',
88
+ path: '',
89
+ tag: 'Database',
90
+ summary: 'Delete the database',
91
+ description:
92
+ 'Needs `confirmName`, the database name typed out. It can be restored for 7 days.',
93
+ body: DATA_DATABASE_DELETE.pick({ confirmName: true }),
94
+ returns: '{ database }'
95
+ },
96
+ restore: {
97
+ method: 'post',
98
+ path: '/restore',
99
+ tag: 'Database',
100
+ summary: 'Undo a delete',
101
+ description: 'Within the 7-day grace period.',
102
+ returns: '{ database }'
103
+ },
104
+ restorePoint: {
105
+ method: 'post',
106
+ path: '/restore-point',
107
+ tag: 'Database',
108
+ summary: 'Restore to a point in time',
109
+ description:
110
+ 'Replaces everything with the database as it was at `timestamp` (within 30 days), or at the `bookmark` a checkpoint, a dropped table or column, a type change or an earlier restore answered with. The answer carries `undoBookmark`, which undoes the restore. Calls in flight while it restarts fail.',
111
+ body: DATA_RESTORE_POINT_BODY,
112
+ returns: '{ bookmark, undoBookmark }'
113
+ },
114
+ checkpoint: {
115
+ method: 'post',
116
+ path: '/checkpoint',
117
+ tag: 'Database',
118
+ summary: 'Take a restore point',
119
+ description:
120
+ "Answers with the `bookmark` of the database as it is right now, which `POST /restore-point` accepts. Take one before a large change, such as replacing a table's rows, to undo exactly that change: a time is not that precise.",
121
+ returns: '{ bookmark }'
122
+ },
123
+ dismissCopy: {
124
+ method: 'delete',
125
+ path: '/copy',
126
+ tag: 'Tables',
127
+ summary: 'Dismiss a failed table copy',
128
+ description:
129
+ '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.',
130
+ returns: '{ copy: null }'
131
+ },
132
+ listTables: {
133
+ method: 'get',
134
+ path: '/table',
135
+ tag: 'Tables',
136
+ summary: 'Tables and their columns',
137
+ returns: '{ tables }'
138
+ },
139
+ createTable: {
140
+ method: 'post',
141
+ path: '/table',
142
+ tag: 'Tables',
143
+ summary: 'Create a table',
144
+ body: DATA_TABLE_INPUT,
145
+ returns: '{ table }',
146
+ status: 201
147
+ },
148
+ updateTable: {
149
+ method: 'patch',
150
+ path: '/table/:table',
151
+ tag: 'Tables',
152
+ summary: 'Rename a table or change its settings',
153
+ body: DATA_TABLE_PATCH,
154
+ returns: '{ table }'
155
+ },
156
+ dropTable: {
157
+ method: 'delete',
158
+ path: '/table/:table',
159
+ tag: 'Tables',
160
+ summary: 'Drop a table',
161
+ description:
162
+ 'Needs `confirmName`, the table name typed out. The answer carries a `bookmark`: restoring to it brings the table back.',
163
+ body: DATA_TABLE_DROP_BODY,
164
+ returns: '{ dropped, bookmark }'
165
+ },
166
+ addColumn: {
167
+ method: 'post',
168
+ path: '/table/:table/column',
169
+ tag: 'Tables',
170
+ summary: 'Add a column',
171
+ body: DATA_COLUMN_INPUT,
172
+ returns: '{ table }',
173
+ status: 201
174
+ },
175
+ updateColumn: {
176
+ method: 'patch',
177
+ path: '/table/:table/column/:column',
178
+ tag: 'Tables',
179
+ summary: 'Rename a column or change it',
180
+ description:
181
+ 'A type change copies the table and lists the first rows that would not fit. When it copies, the answer carries a `bookmark` from just before. Over 100,000 rows the copy runs in the background: the answer carries `copy`, the table keeps its old columns until it finishes, and meanwhile table changes, and writes that would change its rows, are refused.',
182
+ body: DATA_COLUMN_PATCH,
183
+ returns: '{ table, bookmark, copy }'
184
+ },
185
+ dropColumn: {
186
+ method: 'delete',
187
+ path: '/table/:table/column/:column',
188
+ tag: 'Tables',
189
+ summary: 'Drop a column',
190
+ description:
191
+ 'The answer carries a `bookmark`: restoring to it brings the column back. Dropping a relation column copies the table, in the background over 100,000 rows, as for a type change.',
192
+ returns: '{ table, bookmark, copy }'
193
+ },
194
+ listRows: {
195
+ method: 'get',
196
+ path: '/table/:table/row',
197
+ tag: 'Rows',
198
+ summary: 'List, filter and search rows',
199
+ description:
200
+ '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.',
201
+ query: DATA_ROW_LIST_QUERY,
202
+ returns: '{ table, rows, total?, nextCursor? }'
203
+ },
204
+ createRows: {
205
+ method: 'post',
206
+ path: '/table/:table/row',
207
+ tag: 'Rows',
208
+ summary: 'Insert one row, or up to 500',
209
+ body: DATA_ROW_CREATE_BODY,
210
+ returns: '{ ids }',
211
+ status: 201
212
+ },
213
+ getRow: {
214
+ method: 'get',
215
+ path: '/table/:table/row/:id',
216
+ tag: 'Rows',
217
+ summary: 'One row',
218
+ query: DATA_ROW_GET_QUERY,
219
+ returns: '{ row }'
220
+ },
221
+ updateRow: {
222
+ method: 'patch',
223
+ path: '/table/:table/row/:id',
224
+ tag: 'Rows',
225
+ summary: 'Change a row',
226
+ description:
227
+ 'Send the `version` you read: if the row changed since, the answer is 409 with the current row and who changed it.',
228
+ body: DATA_ROW_UPDATE_BODY,
229
+ returns: '{ row }'
230
+ },
231
+ deleteRow: {
232
+ method: 'delete',
233
+ path: '/table/:table/row/:id',
234
+ tag: 'Rows',
235
+ summary: 'Delete a row',
236
+ description: 'Send the `version` you read, as for a change.',
237
+ body: DATA_ROW_DELETE_BODY,
238
+ returns: '{ deleted }'
239
+ },
240
+ deletePreview: {
241
+ method: 'post',
242
+ path: '/table/:table/delete-preview',
243
+ tag: 'Rows',
244
+ summary: 'What deleting rows would do',
245
+ description:
246
+ 'For `ids`, or every row without them: each relation column pointing at a row the delete removes, with how many rows it would delete (cascade), clear (set null) or that refuse the delete (restrict). Cascades are followed all the way down. `blocked` says a restrict would refuse it. Past 200,000 affected rows `truncated` is true and the counts are lower bounds. Nothing is deleted.',
247
+ body: DATA_DELETE_PREVIEW_BODY,
248
+ returns: '{ table, rows, effects, blocked, truncated }'
249
+ },
250
+ rowHistory: {
251
+ method: 'get',
252
+ path: '/table/:table/row/:id/history',
253
+ tag: 'History and export',
254
+ summary: "A row's history",
255
+ description: 'Newest first, kept 90 days, with who made each change.',
256
+ query: DATA_ROW_HISTORY_QUERY,
257
+ returns: '{ entries, nextCursor? }'
258
+ },
259
+ write: {
260
+ method: 'post',
261
+ path: '/write',
262
+ tag: 'Rows',
263
+ summary: 'Several writes, all or nothing',
264
+ description:
265
+ 'Inserts, updates and deletes across tables in one transaction. A guard (`where` plus `expect`) that fails rolls everything back. A later step uses an earlier single-row insert\'s id as `{ "$ref": <step> }`, in its values or as the `id` it updates or deletes.',
266
+ body: DATA_WRITE_BODY,
267
+ returns: '{ results }'
268
+ },
269
+ listQueries: {
270
+ method: 'get',
271
+ path: '/query',
272
+ tag: 'Queries',
273
+ summary: 'Named queries',
274
+ description:
275
+ 'Each with its SQL, parameters, the tables it reads and its plan (`SCAN orders` means it reads the whole table).',
276
+ returns: '{ queries }'
277
+ },
278
+ saveQuery: {
279
+ method: 'post',
280
+ path: '/query',
281
+ tag: 'Queries',
282
+ summary: 'Save a named query',
283
+ description:
284
+ "One `SELECT` (or `WITH … SELECT`) with named parameters such as `:from`, each declared in `params` with its type. It is compiled, never run, and refused unless it only reads this database's tables. Saving under an existing name replaces it. `assistant: true` lets channel bots run it from data-query.",
285
+ body: DATA_QUERY_INPUT,
286
+ returns: '{ query }',
287
+ status: 201
288
+ },
289
+ dropQuery: {
290
+ method: 'delete',
291
+ path: '/query/:name',
292
+ tag: 'Queries',
293
+ summary: 'Delete a named query',
294
+ returns: '{ dropped }'
295
+ },
296
+ runQuery: {
297
+ method: 'post',
298
+ path: '/query/:name/run',
299
+ tag: 'Queries',
300
+ summary: 'Run a named query',
301
+ description:
302
+ 'With its `params`, up to `limit` rows (100 by default, 500 at most) and 1 MB; `truncated` says it stopped early. A query that would scan a table over 200,000 rows is refused.',
303
+ body: DATA_QUERY_RUN_BODY,
304
+ returns: '{ query, rows, truncated? }'
305
+ },
306
+ startExport: {
307
+ method: 'post',
308
+ path: '/export',
309
+ tag: 'History and export',
310
+ summary: 'Start an export',
311
+ description:
312
+ 'Writes a copy in the background: schema.json and one JSONL or CSV file per table. Poll `GET /export` until `status` is `done`. Works during the 7-day grace period after a delete too.',
313
+ body: DATA_EXPORT_BODY,
314
+ returns: '{ exportId, status }',
315
+ status: 201
316
+ },
317
+ exportStatus: {
318
+ method: 'get',
319
+ path: '/export',
320
+ tag: 'History and export',
321
+ summary: 'The latest export',
322
+ query: DATA_EXPORT_QUERY,
323
+ returns: '{ export }'
324
+ },
325
+ exportFile: {
326
+ method: 'get',
327
+ path: '/export/file',
328
+ tag: 'History and export',
329
+ summary: "Download one of an export's files",
330
+ description: "A table's file, or schema.json without `table`.",
331
+ query: DATA_EXPORT_FILE_QUERY,
332
+ returns: 'The file',
333
+ file: true
334
+ },
335
+ listChanges: {
336
+ method: 'get',
337
+ path: '/change',
338
+ tag: 'History and export',
339
+ summary: 'The schema change log',
340
+ description:
341
+ 'Newest first: tables and columns changed, restores, exports, deletes.',
342
+ query: DATA_CHANGE_LIST_QUERY,
343
+ returns: '{ changes, nextCursor? }'
344
+ }
345
+ } as const satisfies Record<string, DataApiRoute>;
346
+
347
+ export type DataApiRouteId = keyof typeof DATA_API_ROUTES;
348
+
349
+ // `/table/:table` → `/table/{table}`
350
+ const openApiPath = (path: string) => path.replace(/:([A-Za-z]+)/g, '{$1}');
351
+
352
+ const jsonSchema = (schema: z.ZodType) =>
353
+ z.toJSONSchema(schema, {
354
+ io: 'input',
355
+ unrepresentable: 'any',
356
+ target: 'draft-2020-12'
357
+ });
358
+
359
+ const errorResponse = (description: string) => ({
360
+ description,
361
+ content: {
362
+ 'application/json': { schema: { $ref: '#/components/schemas/Error' } }
363
+ }
364
+ });
365
+
366
+ /**
367
+ * The OpenAPI 3.1 document for the Data API, served by apps/api at
368
+ * /openapi/data.json.
369
+ */
370
+ export const dataOpenApi = (serverUrl: string) => {
371
+ const paths: Record<string, Record<string, unknown>> = {};
372
+ for (const [id, route] of Object.entries(DATA_API_ROUTES) as [
373
+ DataApiRouteId,
374
+ DataApiRoute
375
+ ][]) {
376
+ const fullPath = openApiPath(`${DATA_API_BASE}${route.path}`);
377
+ const pathParams = [...fullPath.matchAll(/\{([A-Za-z]+)\}/g)].map(m => ({
378
+ name: m[1],
379
+ in: 'path',
380
+ required: true,
381
+ schema:
382
+ m[1] === 'id' ? { type: 'integer', minimum: 1 } : { type: 'string' }
383
+ }));
384
+ const queryParams = route.query
385
+ ? Object.entries(route.query.shape).map(([name, field]) => ({
386
+ name,
387
+ in: 'query',
388
+ required: !(field as z.ZodType).safeParse(undefined).success,
389
+ schema: jsonSchema(field as z.ZodType)
390
+ }))
391
+ : [];
392
+ const status = String(route.status ?? 200);
393
+ paths[fullPath] ??= {};
394
+ paths[fullPath][route.method] = {
395
+ operationId: id,
396
+ tags: [route.tag],
397
+ summary: route.summary,
398
+ ...(route.description ? { description: route.description } : {}),
399
+ parameters: [...pathParams, ...queryParams],
400
+ ...(route.body
401
+ ? {
402
+ requestBody: {
403
+ required: true,
404
+ content: {
405
+ 'application/json': { schema: jsonSchema(route.body) }
406
+ }
407
+ }
408
+ }
409
+ : {}),
410
+ responses: {
411
+ [status]: route.file
412
+ ? {
413
+ description: route.returns,
414
+ content: { 'application/octet-stream': {} }
415
+ }
416
+ : {
417
+ description: route.returns,
418
+ content: { 'application/json': { schema: { type: 'object' } } }
419
+ },
420
+ '400': errorResponse(
421
+ 'The request is not valid; `errors` lists each field.'
422
+ ),
423
+ '401': errorResponse('No session or token.'),
424
+ '402': errorResponse('The plan does not include Data.'),
425
+ '403': errorResponse(
426
+ 'Not an admin of the project, or the token is for another project.'
427
+ ),
428
+ '404': errorResponse('No database, table or row by that name.'),
429
+ '409': errorResponse(
430
+ 'A conflict: a stale `version` (with `details.current`), a guard that failed, a constraint, or a full database.'
431
+ ),
432
+ '429': errorResponse('Too many requests for this token; wait a minute.')
433
+ }
434
+ };
435
+ }
436
+ return {
437
+ openapi: '3.1.0',
438
+ info: {
439
+ title: 'Ganju Data API',
440
+ version: '1',
441
+ description:
442
+ "Tables, rows and search in a project's database. Authenticate with a personal access token (`ganju token create`) as a bearer token; a token reaches only its own project."
443
+ },
444
+ servers: [{ url: serverUrl }],
445
+ security: [{ bearer: [] }],
446
+ tags: [
447
+ { name: 'Database' },
448
+ { name: 'Tables' },
449
+ { name: 'Rows' },
450
+ { name: 'Queries' },
451
+ { name: 'History and export' }
452
+ ],
453
+ paths,
454
+ components: {
455
+ securitySchemes: { bearer: { type: 'http', scheme: 'bearer' } },
456
+ schemas: {
457
+ Error: {
458
+ type: 'object',
459
+ properties: {
460
+ error: { type: 'string' },
461
+ code: { type: 'string' },
462
+ details: { type: 'object' },
463
+ // A request that didn't parse: one entry per field.
464
+ errors: {
465
+ type: 'array',
466
+ items: {
467
+ type: 'object',
468
+ properties: {
469
+ path: { type: 'string' },
470
+ message: { type: 'string' }
471
+ }
472
+ }
473
+ }
474
+ }
475
+ }
476
+ }
477
+ }
478
+ };
479
+ };