@terrascale/sdk 0.0.4 → 0.0.6
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/CHANGELOG.md +23 -0
- package/README.md +62 -1
- package/package.json +1 -1
- package/sdk-public-v1-route-manifest.json +10 -1
- package/src/index.js +17 -0
- package/src/v1/catalog.js +30 -0
- package/src/v1/change-webhooks.js +78 -0
- package/src/v1/decode.js +1 -0
- package/src/v1/errors.js +26 -0
- package/src/v1/export.js +68 -0
- package/src/v1/management-routes.js +18 -5
- package/src/v1/shape-reader.js +7 -5
- package/src/v1/sql-execute.js +132 -0
- package/src/v1/terrabase-v1.js +92 -4
- package/src/v1/transport.js +48 -8
- package/types/index.d.ts +17 -1
- package/types/v1/catalog.d.ts +12 -0
- package/types/v1/change-webhooks.d.ts +59 -0
- package/types/v1/decode.d.ts +5 -0
- package/types/v1/errors.d.ts +18 -0
- package/types/v1/export.d.ts +26 -0
- package/types/v1/management-routes.d.ts +1 -1
- package/types/v1/shape-reader.d.ts +1 -1
- package/types/v1/sql-execute.d.ts +61 -0
- package/types/v1/terrabase-v1.d.ts +53 -2
- package/types/v1/transport.d.ts +10 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.6 — relational SQL
|
|
4
|
+
|
|
5
|
+
`db.execute(sql, args)`, `db.query(sql, args)` and `db.batch(statements)` call
|
|
6
|
+
TerraBase's `POST /v1/projects/{project}/databases/{database}/sql/execute`.
|
|
7
|
+
Each request is one transaction. Results are typed
|
|
8
|
+
`{ columns, rows, rowsAffected, lastInsertRowid }`; a refused statement throws
|
|
9
|
+
`SqlError` (a `TerraBaseV1Error`) with `statement` and `sqliteCode`. The
|
|
10
|
+
read-only `db.sql` route is unchanged.
|
|
11
|
+
|
|
12
|
+
## Unreleased — collection TTL and vector near sort
|
|
13
|
+
|
|
14
|
+
`collection.putTtl(path)`, `getTtl()` and `deleteTtl()` call
|
|
15
|
+
`PUT/GET/DELETE .../collections/{collection}/ttl` and return `{ path }`
|
|
16
|
+
(`null` when unset). `query({ sort: [{ near: { path, vector, metric } }] })`
|
|
17
|
+
sorts by vector distance and each item carries `distance`.
|
|
18
|
+
|
|
19
|
+
## Unreleased — database export
|
|
20
|
+
|
|
21
|
+
`db.export({ after, signal })` streams TerraBase's
|
|
22
|
+
`GET /v1/projects/{project}/databases/{database}/export` NDJSON as an async
|
|
23
|
+
iterable of `{ collection, id, document }`. `after: { collection, id }` resumes
|
|
24
|
+
after that document. Error answers raise the usual `TerraBaseV1Error`.
|
|
25
|
+
|
|
3
26
|
## 0.0.4
|
|
4
27
|
|
|
5
28
|
Publishes current main, including `db.sql`, `db.changes`, backups, indexes and
|
package/README.md
CHANGED
|
@@ -108,9 +108,12 @@ OpenAPI and immutable release qualification remain open gates.
|
|
|
108
108
|
| --- | --- |
|
|
109
109
|
| Documents | `collection.get/put/create/patch/delete/list/query/pages/items` |
|
|
110
110
|
| Collections and declared JSON Schema | `db.collections.create/get/list/drop`; `collection.getSchema/putSchema/deleteSchema` |
|
|
111
|
+
| Collection TTL | `collection.putTtl/getTtl/deleteTtl` |
|
|
111
112
|
| Databases | `client.databases.create/list/get/update/delete/branch` |
|
|
112
|
-
| SQL | `db.
|
|
113
|
+
| Relational SQL | `db.execute/query/batch` |
|
|
114
|
+
| Read-only SQL (older) | `db.sql/checkSql/explainSql` |
|
|
113
115
|
| Changes | `db.changes` (SSE async iterator) |
|
|
116
|
+
| Export | `db.export` (NDJSON async iterator) |
|
|
114
117
|
|
|
115
118
|
Project, collection catalog, and database catalog IDs are canonical uppercase
|
|
116
119
|
26-character ULIDs. `generateId()`, `isValidId()` and `requireId()` retain that
|
|
@@ -136,6 +139,21 @@ does not support `lower` paths or query-body `explain`; query number comparisons
|
|
|
136
139
|
64-bit integer/double arithmetic, even when the SDK preserves exact JSON numbers.
|
|
137
140
|
`ExactDecimal` and bigint serialization require Node.js 22+ or a current browser.
|
|
138
141
|
|
|
142
|
+
A `near` sort orders by vector distance, nearest first, and adds `distance` to
|
|
143
|
+
each item. It is the only sort key, `metric` is `cosine` (default) or `l2`,
|
|
144
|
+
`limit` is at most 100 and there is one page (no cursor):
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
await users.putTtl("/expiresAt"); // getTtl() -> { path: "/expiresAt" }; deleteTtl() clears
|
|
148
|
+
const { items } = await docs.query({
|
|
149
|
+
sort: [{ near: { path: "/embedding", vector: [0.1, 0.2], metric: "cosine" } }], limit: 10,
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
A collection TTL deletes documents whose member holds a past RFC 3339 UTC time.
|
|
154
|
+
An hourly home-region sweep does the delete, up to about an hour late; until
|
|
155
|
+
then expired documents stay readable.
|
|
156
|
+
|
|
139
157
|
`db.transaction()` builds one bounded atomic group across collections in that
|
|
140
158
|
database. It supports document operations, `assert`, `assertAbsent` and
|
|
141
159
|
`assertCount`; assertions examine the state before the group's writes.
|
|
@@ -144,6 +162,37 @@ or replay mutations after an uncertain result; this release does not promise
|
|
|
144
162
|
mutation deduplication. Validation, authentication, authorization and
|
|
145
163
|
precondition failures are not automatically retried.
|
|
146
164
|
|
|
165
|
+
Relational SQL runs statements on tables created with `CREATE TABLE` (rows
|
|
166
|
+
are documents). `execute` runs one statement, `query` returns its rows as
|
|
167
|
+
objects keyed by column name, and `batch` sends 1 to 256 statements as one
|
|
168
|
+
transaction: all commit or none do. `BEGIN`, `COMMIT` and `ROLLBACK` are
|
|
169
|
+
refused. Arguments are positional (`?`) or named (`:name`, `@name`, `$name`,
|
|
170
|
+
passed without the prefix). Each is `null`, a number, an int64 `bigint`, a
|
|
171
|
+
boolean (stored as 1 or 0) or a string. Reads need `documents:read`; writes and
|
|
172
|
+
DDL also need `documents:write`.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
await db.batch([
|
|
176
|
+
"CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
|
|
177
|
+
{ sql: "INSERT INTO people (id, name) VALUES (?, ?)", args: [1, "Ada"] },
|
|
178
|
+
{ sql: "INSERT INTO people (id, name) VALUES (:id, :name)", named: { id: 2, name: "Grace" } },
|
|
179
|
+
]);
|
|
180
|
+
const people = await db.query("SELECT id, name FROM people WHERE id > ?", [0]);
|
|
181
|
+
const { columns, rows, rowsAffected, lastInsertRowid } = await db.execute("DELETE FROM people WHERE id = :id", { id: 2 });
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A refused statement throws `SqlError`, a `TerraBaseV1Error` that adds
|
|
185
|
+
`statement` (zero-based index) and `sqliteCode` (such as
|
|
186
|
+
`SQLITE_CONSTRAINT_PRIMARYKEY`). Its `code` is `sql_error` (400),
|
|
187
|
+
`forbidden_scope` (403), `sql_conflict` (409) or `sql_too_large` (413).
|
|
188
|
+
Authentication, quota and availability errors keep their usual error classes.
|
|
189
|
+
|
|
190
|
+
TerraBase sends integers as JSON numbers. A plain JavaScript number cannot hold
|
|
191
|
+
an integer above 2^53 exactly, so the SDK decodes such a value (a row value or
|
|
192
|
+
`lastInsertRowid`) as an `ExactDecimal` that keeps every digit. Convert it with
|
|
193
|
+
`BigInt(String(value))`. Pass large integer arguments as `bigint`.
|
|
194
|
+
|
|
195
|
+
The older `db.sql` route below is read-only.
|
|
147
196
|
SQL is the producer's bounded read-only subset: `{ matched, truncated, rows }`,
|
|
148
197
|
with row `{ id, value }` and no pagination cursor. `checkSql` returns `ok: false`
|
|
149
198
|
for invalid SQL; `explainSql` returns an HTTP error for invalid SQL. All three
|
|
@@ -169,6 +218,18 @@ errors, denials, expired history (410) and overload (429) propagate. The SDK
|
|
|
169
218
|
never silently resets to `now` or broadens the view. Iterator `return()` also
|
|
170
219
|
interrupts an already pending read.
|
|
171
220
|
|
|
221
|
+
`db.export()` streams every live document as `{ collection, id, document }`,
|
|
222
|
+
in collection then id order, from one read snapshot (`documents:read`). Lines
|
|
223
|
+
are parsed as they arrive. A stream that ends inside a line throws; resume with
|
|
224
|
+
the last whole document received, which reads a new snapshot:
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
let last;
|
|
228
|
+
for await (const row of db.export({ after: last, signal: controller.signal })) {
|
|
229
|
+
last = { collection: row.collection, id: row.id };
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
172
233
|
The exported collection `shape()` remains compatible with the historical
|
|
173
234
|
`6cb9745…` release. Its route is absent from the audited current server source;
|
|
174
235
|
do not use it as current producer evidence. SSE changes are a separate database
|
package/package.json
CHANGED
|
@@ -45,6 +45,10 @@
|
|
|
45
45
|
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/shape","operation":"getShape","scopes":["documents:read"],"requestMediaTypes":[],"statuses":[200,307],"responseMediaType":"application/json","source":"src/TerraBase/Features/Shapes/GetShapeEndpoint.cs","request":"none"},
|
|
46
46
|
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/shape/keys","operation":"getShapeKey","scopes":["documents:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Shapes/GetShapeKeyEndpoint.cs","request":"none"},
|
|
47
47
|
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/shape/manifest","operation":"getShapeManifest","scopes":["documents:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Shapes/GetShapeManifestEndpoint.cs","request":"none"},
|
|
48
|
+
{"method":"DELETE","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/ttl","operation":"deleteCollectionTtl","scopes":["documents:write"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Documents/DeleteCollectionTtlEndpoint.cs","request":"none","producerChange":"TerraScale/terrascale#416"},
|
|
49
|
+
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/ttl","operation":"getCollectionTtl","scopes":["documents:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Documents/GetCollectionTtlEndpoint.cs","request":"none","producerChange":"TerraScale/terrascale#416"},
|
|
50
|
+
{"method":"PUT","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/ttl","operation":"putCollectionTtl","scopes":["documents:write"],"requestMediaTypes":["application/json"],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Documents/PutCollectionTtlEndpoint.cs","request":"CollectionTtlBody","producerChange":"TerraScale/terrascale#416"},
|
|
51
|
+
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/export","operation":"exportDatabase","scopes":["documents:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/x-ndjson","source":"src/TerraBase/Features/Documents/ExportDatabaseEndpoint.cs","request":"none","producerChange":"TerraScale/terrascale#412"},
|
|
48
52
|
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/import","operation":"getImport","scopes":["databases:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Import/GetImportEndpoint.cs","request":"none"},
|
|
49
53
|
{"method":"PATCH","path":"/v1/projects/{project}/databases/{database}/import","operation":"updateImport","scopes":["databases:write","documents:write"],"requestMediaTypes":["application/json"],"statuses":[202],"responseMediaType":"application/json","source":"src/TerraBase/Features/Import/UpdateImportEndpoint.cs","request":"UpdateImportRequest"},
|
|
50
54
|
{"method":"POST","path":"/v1/projects/{project}/databases/{database}/import","operation":"createImport","scopes":["databases:write","documents:write"],"requestMediaTypes":["application/json"],"statuses":[200,201],"responseMediaType":"application/json","source":"src/TerraBase/Features/Import/CreateImportEndpoint.cs","request":"CreateImportRequest"},
|
|
@@ -67,6 +71,7 @@
|
|
|
67
71
|
{"method":"PUT","path":"/v1/projects/{project}/databases/{database}/schema/{collection}","operation":"declareCollectionSchema","scopes":["databases:write"],"requestMediaTypes":["application/json"],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Schema/PutCollectionSchemaEndpoint.cs","request":"UniqueSchemaRequest"},
|
|
68
72
|
{"method":"POST","path":"/v1/projects/{project}/databases/{database}/sql","operation":"runSql","scopes":["databases:read"],"requestMediaTypes":["application/json"],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Queries/RunSqlEndpoint.cs","request":"SqlRequest"},
|
|
69
73
|
{"method":"POST","path":"/v1/projects/{project}/databases/{database}/sql/check","operation":"checkSql","scopes":["databases:read"],"requestMediaTypes":["application/json"],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Queries/CheckSqlEndpoint.cs","request":"SqlRequest"},
|
|
74
|
+
{"method":"POST","path":"/v1/projects/{project}/databases/{database}/sql/execute","operation":"executeSql","scopes":["documents:read"],"requestMediaTypes":["application/json"],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Sql/SqlExecuteEndpoint.cs","request":"SqlExecuteRequest","producerChange":"TerraScale/terrascale#420"},
|
|
70
75
|
{"method":"POST","path":"/v1/projects/{project}/databases/{database}/sql/explain","operation":"explainSql","scopes":["databases:read"],"requestMediaTypes":["application/json"],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Queries/ExplainSqlEndpoint.cs","request":"SqlRequest"},
|
|
71
76
|
{"method":"POST","path":"/v1/projects/{project}/databases/{database}/transactions","operation":"runTransaction","scopes":["documents:write"],"requestMediaTypes":["application/json"],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Documents/TransactionEndpoint.cs","request":"TransactionRequest"},
|
|
72
77
|
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/usage","operation":"getDatabaseUsage","scopes":["databases:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Usage/GetDatabaseUsageEndpoint.cs","request":"none"},
|
|
@@ -76,6 +81,10 @@
|
|
|
76
81
|
{"method":"DELETE","path":"/v1/projects/{project}/saved-queries/{query}","operation":"deleteSavedQuery","scopes":["databases:write"],"requestMediaTypes":[],"statuses":[204],"responseMediaType":"","source":"src/TerraBase/Features/Management/Queries/DeleteSavedQueryEndpoint.cs","request":"none"},
|
|
77
82
|
{"method":"GET","path":"/v1/projects/{project}/saved-queries/{query}","operation":"getSavedQuery","scopes":["databases:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Queries/GetSavedQueryEndpoint.cs","request":"none"},
|
|
78
83
|
{"method":"PUT","path":"/v1/projects/{project}/saved-queries/{query}","operation":"updateSavedQuery","scopes":["databases:write"],"requestMediaTypes":["application/json"],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Queries/UpdateSavedQueryEndpoint.cs","request":"SavedQueryRequest"},
|
|
79
|
-
{"method":"GET","path":"/v1/projects/{project}/usage","operation":"getProjectUsage","scopes":["databases:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Usage/GetProjectUsageEndpoint.cs","request":"none"}
|
|
84
|
+
{"method":"GET","path":"/v1/projects/{project}/usage","operation":"getProjectUsage","scopes":["databases:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","source":"src/TerraBase/Features/Management/Usage/GetProjectUsageEndpoint.cs","request":"none"},
|
|
85
|
+
{"method":"POST","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/webhooks","operation":"createChangeWebhook","scopes":["documents:read","documents:write"],"requestMediaTypes":["application/json"],"statuses":[201],"responseMediaType":"application/json","contract":"contracts/change-webhooks.md","request":"CreateWebhookRequest"},
|
|
86
|
+
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/webhooks","operation":"listChangeWebhooks","scopes":["documents:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","contract":"contracts/change-webhooks.md","request":"none"},
|
|
87
|
+
{"method":"DELETE","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/webhooks/{webhook}","operation":"deleteChangeWebhook","scopes":["documents:read","documents:write"],"requestMediaTypes":[],"statuses":[204],"responseMediaType":"","contract":"contracts/change-webhooks.md","request":"none"},
|
|
88
|
+
{"method":"GET","path":"/v1/projects/{project}/databases/{database}/collections/{collection}/webhooks/{webhook}/deliveries","operation":"listChangeWebhookDeliveries","scopes":["documents:read"],"requestMediaTypes":[],"statuses":[200],"responseMediaType":"application/json","contract":"contracts/change-webhooks.md","request":"none"}
|
|
80
89
|
]
|
|
81
90
|
}
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
V1InvalidKeyError,
|
|
16
16
|
V1ForbiddenScopeError,
|
|
17
17
|
V1NotFoundError,
|
|
18
|
+
SqlError,
|
|
18
19
|
V1ErrorCode,
|
|
19
20
|
isTerraBaseV1Error,
|
|
20
21
|
} from "./v1/errors.js";
|
|
@@ -28,6 +29,7 @@ export { generateId, isValidId, requireId } from "./ids.js";
|
|
|
28
29
|
export { ExactDecimal } from "./v1/json.js";
|
|
29
30
|
|
|
30
31
|
/** @typedef {import("./v1/transport.js").TerraBaseV1ClientConfig} TerraBaseV1ClientConfig */
|
|
32
|
+
/** @typedef {import("./v1/transport.js").V1TokenProvider} V1TokenProvider */
|
|
31
33
|
/** @typedef {import("./v1/terrabase-v1.js").V1RequestOptions} V1RequestOptions */
|
|
32
34
|
/** @typedef {import("./v1/terrabase-v1.js").V1ReadOptions} V1ReadOptions */
|
|
33
35
|
/** @typedef {import("./v1/terrabase-v1.js").V1WriteOptions} V1WriteOptions */
|
|
@@ -36,6 +38,7 @@ export { ExactDecimal } from "./v1/json.js";
|
|
|
36
38
|
/** @typedef {import("./v1/terrabase-v1.js").V1PatchOp} V1PatchOp */
|
|
37
39
|
/** @typedef {import("./v1/terrabase-v1.js").V1Patch} V1Patch */
|
|
38
40
|
/** @typedef {import("./v1/terrabase-v1.js").V1SortKey} V1SortKey */
|
|
41
|
+
/** @typedef {import("./v1/terrabase-v1.js").V1NearSortKey} V1NearSortKey */
|
|
39
42
|
/** @typedef {import("./v1/terrabase-v1.js").V1Query} V1Query */
|
|
40
43
|
/** @typedef {import("./v1/terrabase-v1.js").V1Count} V1Count */
|
|
41
44
|
/** @typedef {import("./v1/terrabase-v1.js").V1QueryPage} V1QueryPage */
|
|
@@ -74,9 +77,17 @@ export { V1SavedQueries } from "./v1/saved-queries.js";
|
|
|
74
77
|
/** @typedef {import("./v1/sql.js").V1SqlResult} V1SqlResult */
|
|
75
78
|
/** @typedef {import("./v1/sql.js").V1SqlIndexSuggestion} V1SqlIndexSuggestion */
|
|
76
79
|
/** @typedef {import("./v1/sql.js").V1SqlCheck} V1SqlCheck */
|
|
80
|
+
/** @typedef {import("./v1/sql-execute.js").V1SqlArgument} V1SqlArgument */
|
|
81
|
+
/** @typedef {import("./v1/sql-execute.js").V1SqlArguments} V1SqlArguments */
|
|
82
|
+
/** @typedef {import("./v1/sql-execute.js").V1SqlStatement} V1SqlStatement */
|
|
83
|
+
/** @typedef {import("./v1/sql-execute.js").V1SqlValue} V1SqlValue */
|
|
84
|
+
/** @typedef {import("./v1/sql-execute.js").V1SqlColumn} V1SqlColumn */
|
|
85
|
+
/** @typedef {import("./v1/sql-execute.js").V1SqlExecuteResult} V1SqlExecuteResult */
|
|
77
86
|
/** @typedef {import("./v1/changes.js").V1ChangeStreamRequest} V1ChangeStreamRequest */
|
|
78
87
|
/** @typedef {import("./v1/changes.js").V1DatabaseChange} V1DatabaseChange */
|
|
79
88
|
/** @typedef {import("./v1/changes.js").V1DatabaseChangeCursor} V1DatabaseChangeCursor */
|
|
89
|
+
/** @typedef {import("./v1/export.js").V1ExportOptions} V1ExportOptions */
|
|
90
|
+
/** @typedef {import("./v1/export.js").V1ExportedDocument} V1ExportedDocument */
|
|
80
91
|
/** @typedef {import("./v1/catalog.js").V1ManagedDatabase} V1ManagedDatabase */
|
|
81
92
|
/** @typedef {import("./v1/catalog.js").V1DatabaseCreateRequest} V1DatabaseCreateRequest */
|
|
82
93
|
/** @typedef {import("./v1/catalog.js").V1DatabaseUpdateRequest} V1DatabaseUpdateRequest */
|
|
@@ -84,6 +95,7 @@ export { V1SavedQueries } from "./v1/saved-queries.js";
|
|
|
84
95
|
/** @typedef {import("./v1/catalog.js").V1DatabaseListRequest} V1DatabaseListRequest */
|
|
85
96
|
/** @typedef {import("./v1/catalog.js").V1DatabasePage} V1DatabasePage */
|
|
86
97
|
/** @typedef {import("./v1/catalog.js").V1CollectionSchema} V1CollectionSchema */
|
|
98
|
+
/** @typedef {import("./v1/catalog.js").V1CollectionTtl} V1CollectionTtl */
|
|
87
99
|
|
|
88
100
|
export { V1DatabaseSchemaClient } from "./v1/database-schema.js";
|
|
89
101
|
/** @typedef {import("./v1/database-schema.js").V1InferredSchema} V1InferredSchema */
|
|
@@ -109,6 +121,11 @@ export { V1ProjectUsageClient, V1DatabaseUsageClient } from "./v1/usage.js";
|
|
|
109
121
|
/** @typedef {import("./v1/usage.js").V1AnalyticsRequest} V1AnalyticsRequest */
|
|
110
122
|
|
|
111
123
|
export { V1Backups, V1BackupDestinationClient } from "./v1/backups.js";
|
|
124
|
+
export { V1ChangeWebhooks } from "./v1/change-webhooks.js";
|
|
125
|
+
/** @typedef {import("./v1/change-webhooks.js").V1ChangeWebhook} V1ChangeWebhook */
|
|
126
|
+
/** @typedef {import("./v1/change-webhooks.js").V1CreatedChangeWebhook} V1CreatedChangeWebhook */
|
|
127
|
+
/** @typedef {import("./v1/change-webhooks.js").V1ChangeWebhookDelivery} V1ChangeWebhookDelivery */
|
|
128
|
+
/** @typedef {import("./v1/change-webhooks.js").V1ChangeWebhookListRequest} V1ChangeWebhookListRequest */
|
|
112
129
|
/** @typedef {import("./v1/backups.js").V1Backup} V1Backup */
|
|
113
130
|
/** @typedef {import("./v1/backups.js").V1BackupPage} V1BackupPage */
|
|
114
131
|
/** @typedef {import("./v1/backups.js").V1BackupPolicy} V1BackupPolicy */
|
package/src/v1/catalog.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { requireId } from "../ids.js";
|
|
2
2
|
import { array, malformed, object, string } from "./decode.js";
|
|
3
3
|
import { requireName } from "./ids.js";
|
|
4
|
+
import { isJsonPointer } from "./filter.js";
|
|
4
5
|
import { isJsonObject, stringifyExactJson } from "./json.js";
|
|
5
6
|
/**
|
|
6
7
|
* @import { JsonInputObject, JsonObject, JsonValue } from "./json.js"
|
|
@@ -26,6 +27,7 @@ import { isJsonObject, stringifyExactJson } from "./json.js";
|
|
|
26
27
|
/** @typedef {{ readonly after?: string | undefined; readonly limit?: number | undefined }} V1DatabaseListRequest */
|
|
27
28
|
/** @typedef {{ readonly items: readonly V1ManagedDatabase[]; readonly next: string | null }} V1DatabasePage */
|
|
28
29
|
/** @typedef {{ readonly schema: JsonObject | boolean | null; readonly dialect?: string | null | undefined }} V1CollectionSchema */
|
|
30
|
+
/** The expiry member, or null when documents never expire. @typedef {{ readonly path: string | null }} V1CollectionTtl */
|
|
29
31
|
|
|
30
32
|
/** @param {unknown} value @param {readonly string[]} allowed @returns {void} */
|
|
31
33
|
function members(value, allowed) {
|
|
@@ -208,3 +210,31 @@ export async function deleteCollectionSchema(transport, params, options = {}) {
|
|
|
208
210
|
mutationOptions(options);
|
|
209
211
|
return collectionSchema((await transport.call("deleteCollectionSchema", params, { signal: options.signal })).value);
|
|
210
212
|
}
|
|
213
|
+
|
|
214
|
+
/** @param {JsonValue | undefined} value @returns {V1CollectionTtl} */
|
|
215
|
+
function collectionTtl(value) {
|
|
216
|
+
const path = object(value, "collection TTL").path;
|
|
217
|
+
if (path !== null && typeof path !== "string") malformed("TTL path");
|
|
218
|
+
return Object.freeze({ path });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {V1RequestOptions} [options] @returns {Promise<V1CollectionTtl>} */
|
|
222
|
+
export async function getCollectionTtl(transport, params, options = {}) {
|
|
223
|
+
return collectionTtl((await transport.call("getCollectionTtl", params, { signal: options.signal })).value);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {string} path @param {V1RequestOptions} [options] @returns {Promise<V1CollectionTtl>} */
|
|
227
|
+
export async function putCollectionTtl(transport, params, path, options = {}) {
|
|
228
|
+
mutationOptions(options);
|
|
229
|
+
if (!isJsonPointer(path))
|
|
230
|
+
throw new TypeError("A TTL path is a JSON Pointer of 1 to 16 object members.");
|
|
231
|
+
return collectionTtl((await transport.call("putCollectionTtl", params, {
|
|
232
|
+
body: stringifyExactJson({ path }, "collection TTL"), signal: options.signal,
|
|
233
|
+
})).value);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {V1RequestOptions} [options] @returns {Promise<V1CollectionTtl>} */
|
|
237
|
+
export async function deleteCollectionTtl(transport, params, options = {}) {
|
|
238
|
+
mutationOptions(options);
|
|
239
|
+
return collectionTtl((await transport.call("deleteCollectionTtl", params, { signal: options.signal })).value);
|
|
240
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { requireId } from "../ids.js";
|
|
2
|
+
import { requireName } from "./ids.js";
|
|
3
|
+
import { object, string } from "./decode.js";
|
|
4
|
+
import { list, nullable, numeric, record, text } from "./management-decode.js";
|
|
5
|
+
import { members, mutationOptions, pageQuery } from "./management-validation.js";
|
|
6
|
+
import { stringifyExactJson } from "./json.js";
|
|
7
|
+
/** @import { V1Transport } from "./transport.js" */
|
|
8
|
+
/** @import { V1RequestOptions } from "./terrabase-v1.js" */
|
|
9
|
+
const webhook = record({ id: requireId, url: text, createdAt: text, status: text });
|
|
10
|
+
const webhooks = list(webhook);
|
|
11
|
+
const deliveries = list(record({ id: requireId, createdAt: text, status: text, attempts: numeric,
|
|
12
|
+
lastAttemptAt: nullable(text), nextAttemptAt: nullable(text), httpStatus: nullable(numeric), errorCode: nullable(text) }));
|
|
13
|
+
/** @typedef {ReturnType<typeof webhook>} V1ChangeWebhook */
|
|
14
|
+
/** @typedef {V1ChangeWebhook & { readonly secret: string }} V1CreatedChangeWebhook */
|
|
15
|
+
/** @typedef {ReturnType<typeof deliveries>[number]} V1ChangeWebhookDelivery */
|
|
16
|
+
/** @typedef {{ readonly after?: string; readonly limit?: number }} V1ChangeWebhookListRequest */
|
|
17
|
+
|
|
18
|
+
/** @param {V1ChangeWebhookListRequest} request */
|
|
19
|
+
function query(request) {
|
|
20
|
+
if (request.after !== undefined) requireId(request.after, "after");
|
|
21
|
+
return pageQuery(request);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Collection change webhook registration and sanitized delivery history. Requests are never replayed. */
|
|
25
|
+
export class V1ChangeWebhooks {
|
|
26
|
+
/** @type {V1Transport} */
|
|
27
|
+
#transport;
|
|
28
|
+
/** @type {{database: string; collection: string}} */
|
|
29
|
+
#params;
|
|
30
|
+
/** @param {V1Transport} transport @param {string} database @param {string} collection */
|
|
31
|
+
constructor(transport, database, collection) {
|
|
32
|
+
this.#transport = transport;
|
|
33
|
+
this.#params = { database: requireName("database", database), collection: requireName("collection", collection) };
|
|
34
|
+
Object.freeze(this);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Registers from the current feed head. Requires documents:read and documents:write.
|
|
38
|
+
* Store secret immediately; it is non-enumerable to keep it out of JSON and ordinary diagnostics.
|
|
39
|
+
* @param {{ readonly url: string }} request @param {V1RequestOptions} [options] @returns {Promise<V1CreatedChangeWebhook>}
|
|
40
|
+
*/
|
|
41
|
+
async create(request, options = {}) {
|
|
42
|
+
members(request, ["url"]);
|
|
43
|
+
if (typeof request.url !== "string" || request.url.length > 2048) throw new TypeError("Webhook URL must be an HTTPS URL of at most 2048 characters.");
|
|
44
|
+
let url;
|
|
45
|
+
try { url = new URL(request.url); } catch { throw new TypeError("Webhook URL must be absolute HTTPS."); }
|
|
46
|
+
if (url.protocol !== "https:" || url.port !== "" || url.username !== "" || url.password !== "" || url.hash !== "")
|
|
47
|
+
throw new TypeError("Webhook URL must be HTTPS on port 443, without credentials or a fragment.");
|
|
48
|
+
mutationOptions(options);
|
|
49
|
+
const raw = object((await this.#transport.call("createChangeWebhook", this.#params, {
|
|
50
|
+
body: stringifyExactJson(request, "change webhook"), signal: options.signal,
|
|
51
|
+
})).value, "created webhook");
|
|
52
|
+
const secret = string(raw.secret, "webhook secret");
|
|
53
|
+
return Object.freeze(Object.defineProperty({ ...webhook(raw), secret }, "secret", { enumerable: false }));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Requires documents:read. Ascending IDs; continue with the final ID until an empty array. No secrets.
|
|
57
|
+
* @param {V1ChangeWebhookListRequest} [request] @param {V1RequestOptions} [options] @returns {Promise<readonly V1ChangeWebhook[]>}
|
|
58
|
+
*/
|
|
59
|
+
async list(request = {}, options = {}) {
|
|
60
|
+
return webhooks((await this.#transport.call("listChangeWebhooks", this.#params, { query: query(request), signal: options.signal })).value);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Requires documents:read and documents:write. An in-flight delivery may still complete. Unknown ID returns 404.
|
|
64
|
+
* @param {string} id @param {V1RequestOptions} [options] @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
async delete(id, options = {}) {
|
|
67
|
+
mutationOptions(options);
|
|
68
|
+
await this.#transport.call("deleteChangeWebhook", { ...this.#params, webhook: requireId(id) }, { signal: options.signal });
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Requires documents:read. Latest 100 retained records; paginate with the final ID until an empty array.
|
|
72
|
+
* @param {string} id @param {V1ChangeWebhookListRequest} [request] @param {V1RequestOptions} [options] @returns {Promise<readonly V1ChangeWebhookDelivery[]>}
|
|
73
|
+
*/
|
|
74
|
+
async deliveries(id, request = {}, options = {}) {
|
|
75
|
+
return deliveries((await this.#transport.call("listChangeWebhookDeliveries", { ...this.#params, webhook: requireId(id) },
|
|
76
|
+
{ query: query(request), signal: options.signal })).value);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/v1/decode.js
CHANGED
|
@@ -13,6 +13,7 @@ import { isJsonObject } from "./json.js";
|
|
|
13
13
|
* @property {"value" | "conflict" | (string & {})} state
|
|
14
14
|
* @property {JsonObject} document
|
|
15
15
|
* @property {readonly JsonValue[]} [siblings] Live versions and tombstones on a causal conflict.
|
|
16
|
+
* @property {number} [distance] Distance to the query vector; present only on `near` query results.
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
/**
|
package/src/v1/errors.js
CHANGED
|
@@ -76,6 +76,7 @@ export const V1ErrorCode = Object.freeze({
|
|
|
76
76
|
mustRefetch: "must_refetch", preconditionFailed: "precondition_failed", payloadTooLarge: "payload_too_large",
|
|
77
77
|
unsupportedMediaType: "unsupported_media_type", uriTooLong: "uri_too_long", shapeBusy: "shape_busy",
|
|
78
78
|
internalError: "internal_error", capacity: "capacity", notModified: "not_modified",
|
|
79
|
+
sqlError: "sql_error", sqlConflict: "sql_conflict", sqlTooLarge: "sql_too_large",
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
/** `412 precondition_failed`: an If-Match, If-None-Match, assert or assert_count failed. */
|
|
@@ -114,11 +115,36 @@ export class V1NotFoundError extends TerraBaseV1Error {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* A refused relational SQL statement: `sql_error` (400), `forbidden_scope` (403, writes and DDL need
|
|
120
|
+
* documents:write), `sql_conflict` (409) or `sql_too_large` (413). The whole request rolled back.
|
|
121
|
+
*/
|
|
122
|
+
export class SqlError extends TerraBaseV1Error {
|
|
123
|
+
/** @type {number} Zero-based index of the failing statement. */
|
|
124
|
+
statement;
|
|
125
|
+
/** @type {string | undefined} The SQLite error code, such as `SQLITE_CONSTRAINT_PRIMARYKEY`. */
|
|
126
|
+
sqliteCode;
|
|
127
|
+
|
|
128
|
+
/** @param {TerraBaseV1ErrorInit & { statement: number; sqliteCode?: string | undefined }} init */
|
|
129
|
+
constructor(init) {
|
|
130
|
+
super(init);
|
|
131
|
+
this.name = "SqlError";
|
|
132
|
+
this.statement = init.statement;
|
|
133
|
+
this.sqliteCode = init.sqliteCode;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
/**
|
|
118
138
|
* @param {TerraBaseV1ErrorInit} init
|
|
119
139
|
* @returns {TerraBaseV1Error}
|
|
120
140
|
*/
|
|
121
141
|
function typedError(init) {
|
|
142
|
+
// Only the SQL execute route reports a statement index.
|
|
143
|
+
const statement = init.details?.statement;
|
|
144
|
+
if (typeof statement === "number" && Number.isInteger(statement) && statement >= 0) {
|
|
145
|
+
const sqliteCode = init.details?.sqlite_code;
|
|
146
|
+
return new SqlError({ ...init, statement, sqliteCode: typeof sqliteCode === "string" ? sqliteCode : undefined });
|
|
147
|
+
}
|
|
122
148
|
switch (init.code) {
|
|
123
149
|
case "precondition_failed": return new V1PreconditionFailedError(init);
|
|
124
150
|
case "invalid_key": return new V1InvalidKeyError(init);
|
package/src/v1/export.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { documentObject, malformed, object } from "./decode.js";
|
|
2
|
+
import { requireDocumentId, requireName } from "./ids.js";
|
|
3
|
+
import { parseExactJson } from "./json.js";
|
|
4
|
+
import { BodyTransportError } from "./transport.js";
|
|
5
|
+
/** @import { JsonObject } from "./json.js" */
|
|
6
|
+
/** @import { V1Transport } from "./transport.js" */
|
|
7
|
+
|
|
8
|
+
/** @typedef {{ readonly collection: string; readonly id: string; readonly document: JsonObject }} V1ExportedDocument */
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} V1ExportOptions
|
|
11
|
+
* @property {{ readonly collection: string; readonly id: string } | undefined} [after] Resume after this document; the resumed part reads a new snapshot.
|
|
12
|
+
* @property {AbortSignal | undefined} [signal]
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// A 1 MiB document may require six characters per character when JSON escapes it.
|
|
16
|
+
const maximumLineLength = 8 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/** @param {string} line @returns {V1ExportedDocument} */
|
|
19
|
+
function decodeLine(line) {
|
|
20
|
+
/** @type {import("./json.js").JsonValue} */
|
|
21
|
+
let value;
|
|
22
|
+
try { value = parseExactJson(line); }
|
|
23
|
+
catch { malformed("export line JSON"); }
|
|
24
|
+
const raw = object(value, "export line");
|
|
25
|
+
return Object.freeze({ collection: requireName("collection", raw.collection), id: requireDocumentId(raw.id), document: documentObject(raw.document) });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Streams every live document of a database as NDJSON, in collection then id order, from one read snapshot.
|
|
30
|
+
* A stream that ends inside a line is incomplete and throws; resume with `after` set to the last document received.
|
|
31
|
+
* @param {V1Transport} transport
|
|
32
|
+
* @param {string} database
|
|
33
|
+
* @param {V1ExportOptions} [options]
|
|
34
|
+
* @returns {AsyncGenerator<V1ExportedDocument, void, undefined>}
|
|
35
|
+
*/
|
|
36
|
+
export async function* exportDocuments(transport, database, options = {}) {
|
|
37
|
+
const { after, signal } = options;
|
|
38
|
+
const query = new URLSearchParams();
|
|
39
|
+
if (after !== undefined) {
|
|
40
|
+
if (typeof after !== "object" || after === null) throw new TypeError("export after is { collection, id }.");
|
|
41
|
+
query.set("after_collection", requireName("collection", after.collection));
|
|
42
|
+
query.set("after_id", requireDocumentId(after.id));
|
|
43
|
+
}
|
|
44
|
+
const response = await transport.openStream("exportDatabase", { database }, { query, signal });
|
|
45
|
+
const body = /** @type {ReadableStream<Uint8Array>} */ (response.body);
|
|
46
|
+
const decoder = /** @type {ReadableWritablePair<string, Uint8Array>} */ (new TextDecoderStream("utf-8", { fatal: true, ignoreBOM: true }));
|
|
47
|
+
const reader = body.pipeThrough(decoder).getReader();
|
|
48
|
+
let pending = "";
|
|
49
|
+
try {
|
|
50
|
+
while (true) {
|
|
51
|
+
/** @type {ReadableStreamReadResult<string>} */
|
|
52
|
+
let part;
|
|
53
|
+
try { part = await reader.read(); }
|
|
54
|
+
catch (error) { signal?.throwIfAborted(); throw new BodyTransportError(error); }
|
|
55
|
+
if (part.done) break;
|
|
56
|
+
// Split only the new text so a long line arriving in many chunks is scanned once.
|
|
57
|
+
const lines = part.value.split("\n");
|
|
58
|
+
lines[0] = pending + lines[0];
|
|
59
|
+
pending = /** @type {string} */ (lines.pop());
|
|
60
|
+
if (pending.length > maximumLineLength) malformed("export line exceeding 8 MiB");
|
|
61
|
+
for (const line of lines) yield decodeLine(line);
|
|
62
|
+
}
|
|
63
|
+
if (pending !== "") malformed("incomplete export line");
|
|
64
|
+
} finally {
|
|
65
|
+
await reader.cancel().catch(() => undefined);
|
|
66
|
+
reader.releaseLock();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -1,8 +1,20 @@
|
|
|
1
|
-
/** @typedef {"getShapeManifest" | "healthz" | "readyz" | "backupz" | "replicationHealth" | "listPlatformDatabases" | "listPlatformCollections" | "listPlatformDocuments" | "getPlatformDocument" | "createImport" | "getImport" | "uploadImportSource" | "updateImport" | "startImport" | "cancelImport" | "resumeImport" | "createBackup" | "listBackups" | "restoreBackup" | "getBackupPolicy" | "setBackupPolicy" | "getRestoreWindow" | "planPointInTime" | "restorePointInTime" | "getBackupDestination" | "setBackupDestination" | "deleteBackupDestination" | "getProjectUsage" | "getProjectAnalytics" | "getProjectReplication" | "getDatabaseUsage" | "getDatabaseAnalytics" | "getDatabaseReplication" | "createIndex" | "listIndexes" | "dropIndex" | "getAutoIndexPolicy" | "setAutoIndexPolicy" | "listIndexRecommendations" | "applyIndexRecommendation" | "getDatabaseSchema" | "inferCollectionSchema" | "declareCollectionSchema" | "listQueryShapes" | "createSavedQuery" | "listSavedQueries" | "getSavedQuery" | "updateSavedQuery" | "deleteSavedQuery"} ManagementOperation */
|
|
1
|
+
/** @typedef {"getShapeManifest" | "exportDatabase" | "executeSql" | "putCollectionTtl" | "getCollectionTtl" | "deleteCollectionTtl" | "healthz" | "readyz" | "backupz" | "replicationHealth" | "listPlatformDatabases" | "listPlatformCollections" | "listPlatformDocuments" | "getPlatformDocument" | "createImport" | "getImport" | "uploadImportSource" | "updateImport" | "startImport" | "cancelImport" | "resumeImport" | "createBackup" | "listBackups" | "restoreBackup" | "getBackupPolicy" | "setBackupPolicy" | "getRestoreWindow" | "planPointInTime" | "restorePointInTime" | "getBackupDestination" | "setBackupDestination" | "deleteBackupDestination" | "getProjectUsage" | "getProjectAnalytics" | "getProjectReplication" | "getDatabaseUsage" | "getDatabaseAnalytics" | "getDatabaseReplication" | "createIndex" | "listIndexes" | "dropIndex" | "getAutoIndexPolicy" | "setAutoIndexPolicy" | "listIndexRecommendations" | "applyIndexRecommendation" | "getDatabaseSchema" | "inferCollectionSchema" | "declareCollectionSchema" | "listQueryShapes" | "createSavedQuery" | "listSavedQueries" | "getSavedQuery" | "updateSavedQuery" | "deleteSavedQuery" | "createChangeWebhook" | "listChangeWebhooks" | "deleteChangeWebhook" | "listChangeWebhookDeliveries"} ManagementOperation */
|
|
2
|
+
const webhooks = "/v1/projects/{project}/databases/{database}/collections/{collection}/webhooks";
|
|
2
3
|
const saved = "/v1/projects/{project}/saved-queries";
|
|
4
|
+
const ttl = "/v1/projects/{project}/databases/{database}/collections/{collection}/ttl";
|
|
3
5
|
/** @type {readonly import("./routes.js").V1Route[]} */
|
|
4
6
|
export const managementV1Routes = Object.freeze([
|
|
7
|
+
route("createChangeWebhook", "POST", webhooks, 201),
|
|
8
|
+
route("listChangeWebhooks", "GET", webhooks),
|
|
9
|
+
route("deleteChangeWebhook", "DELETE", `${webhooks}/{webhook}`, 204),
|
|
10
|
+
route("listChangeWebhookDeliveries", "GET", `${webhooks}/{webhook}/deliveries`),
|
|
5
11
|
route("getShapeManifest", "GET", "/v1/projects/{project}/databases/{database}/collections/{collection}/shape/manifest"),
|
|
12
|
+
route("exportDatabase", "GET", "/v1/projects/{project}/databases/{database}/export"),
|
|
13
|
+
// Reads need documents:read; writes and DDL also need documents:write, checked per statement.
|
|
14
|
+
route("executeSql", "POST", "/v1/projects/{project}/databases/{database}/sql/execute"),
|
|
15
|
+
route("putCollectionTtl", "PUT", ttl),
|
|
16
|
+
route("getCollectionTtl", "GET", ttl),
|
|
17
|
+
route("deleteCollectionTtl", "DELETE", ttl),
|
|
6
18
|
route("healthz", "GET", "/healthz"),
|
|
7
19
|
route("readyz", "GET", "/readyz"),
|
|
8
20
|
route("backupz", "GET", "/backupz"),
|
|
@@ -56,12 +68,13 @@ export const managementV1Routes = Object.freeze([
|
|
|
56
68
|
/** @param {ManagementOperation} operation @param {import("./routes.js").V1Route["method"]} method @param {string} pathTemplate @param {number} [status] @returns {import("./routes.js").V1Route} */
|
|
57
69
|
function route(operation, method, pathTemplate, status = 200) {
|
|
58
70
|
const write = method !== "GET" && operation !== "planPointInTime";
|
|
71
|
+
const webhook = pathTemplate.startsWith(webhooks);
|
|
59
72
|
const noBody = ["createBackup", "applyIndexRecommendation", "cancelImport", "resumeImport"].includes(operation);
|
|
60
73
|
return Object.freeze({ operation, method, pathTemplate,
|
|
61
|
-
permission: operation === "getShapeManifest" ? "documents:read" : pathTemplate.startsWith("/v1/platform/") ? "platform-admin" : !pathTemplate.startsWith("/v1/") ? "anonymous" : write ? "databases:write" : "databases:read",
|
|
62
|
-
additionalPermissions: Object.freeze(["createImport", "uploadImportSource", "updateImport", "startImport", "resumeImport"].includes(operation) ? ["documents:write"] : []),
|
|
74
|
+
permission: webhook ? write ? "documents:write" : "documents:read" : operation === "getShapeManifest" || operation === "exportDatabase" || operation === "executeSql" || operation === "getCollectionTtl" ? "documents:read" : pathTemplate === ttl ? "documents:write" : pathTemplate.startsWith("/v1/platform/") ? "platform-admin" : !pathTemplate.startsWith("/v1/") ? "anonymous" : write ? "databases:write" : "databases:read",
|
|
75
|
+
additionalPermissions: Object.freeze(webhook && write ? ["documents:read"] : ["createImport", "uploadImportSource", "updateImport", "startImport", "resumeImport"].includes(operation) ? ["documents:write"] : []),
|
|
63
76
|
requestMediaTypes: Object.freeze(noBody ? [] : operation === "uploadImportSource" ? ["application/octet-stream"] : method === "POST" || method === "PUT" || method === "PATCH" ? ["application/json"] : []),
|
|
64
|
-
responseMediaType: status === 204 ? "" : "application/json", successStatuses: Object.freeze(operation === "readyz" || operation === "backupz" ? [200, 503] : operation === "applyIndexRecommendation" || operation === "createImport" ? [200, 201] : [status]),
|
|
65
|
-
idempotencyKey: "none", replaySafe: !write, contract: "docs/terrabase/api.md", contractText: Object.freeze([]),
|
|
77
|
+
responseMediaType: status === 204 ? "" : operation === "exportDatabase" ? "application/x-ndjson" : "application/json", successStatuses: Object.freeze(operation === "readyz" || operation === "backupz" ? [200, 503] : operation === "applyIndexRecommendation" || operation === "createImport" ? [200, 201] : [status]),
|
|
78
|
+
idempotencyKey: "none", replaySafe: !write, contract: webhook ? "docs/terrabase/change-webhooks.md" : "docs/terrabase/api.md", contractText: Object.freeze([]),
|
|
66
79
|
});
|
|
67
80
|
}
|
package/src/v1/shape-reader.js
CHANGED
|
@@ -9,7 +9,7 @@ import { TerraBaseV1Error } from "./errors.js";
|
|
|
9
9
|
/** @import { V1ShapeRequest, V1ShapePage } from "./shapes.js" */
|
|
10
10
|
/** @typedef {{handle: string; offset: string; epoch: string; keyId: string}} V1SealedShapePosition */
|
|
11
11
|
/** @typedef {{ latestEpoch: bigint; keys: Map<string, { epoch: bigint; key: Uint8Array }> }} ShapeKeys */
|
|
12
|
-
/** @type {WeakMap<V1Transport, Map<string, ShapeKeys>>}
|
|
12
|
+
/** @type {WeakMap<V1Transport, Map<string, ShapeKeys>>} Only immutable credentials retain keys across decrypts. */
|
|
13
13
|
const shapeKeys = new WeakMap();
|
|
14
14
|
/** @param {Readonly<Record<string, string>>} params */
|
|
15
15
|
const keyScope = params => JSON.stringify([params.database, params.collection]);
|
|
@@ -26,8 +26,10 @@ async function shapeCall(transport, params, operation, options) {
|
|
|
26
26
|
throw error;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
/** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @returns {ShapeKeys} */
|
|
29
|
+
/** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @returns {ShapeKeys | undefined} */
|
|
30
30
|
function currentKeys(transport, params) {
|
|
31
|
+
// Callback tokens can change users; authorize every decrypt without retaining their keys.
|
|
32
|
+
if (transport.hasTokenProvider) return undefined;
|
|
31
33
|
let scopes = shapeKeys.get(transport);
|
|
32
34
|
if (scopes === undefined) { scopes = new Map(); shapeKeys.set(transport, scopes); }
|
|
33
35
|
const scope = keyScope(params);
|
|
@@ -152,7 +154,7 @@ export async function readShapePage(transport, params, request, signal, ifNoneMa
|
|
|
152
154
|
return page;
|
|
153
155
|
}
|
|
154
156
|
/**
|
|
155
|
-
* Reuse
|
|
157
|
+
* Reuse keys only for static credentials; callback tokens authorize every decrypt with no-store.
|
|
156
158
|
* @param {V1Transport} transport @param {Readonly<Record<string,string>>} params
|
|
157
159
|
* @param {Uint8Array} bytes @param {V1SealedShapePosition} position @param {AbortSignal | undefined} signal
|
|
158
160
|
* @returns {Promise<V1ShapePage>}
|
|
@@ -167,7 +169,7 @@ export async function decryptSealedShapePage(transport, params, bytes, position,
|
|
|
167
169
|
signal?.throwIfAborted();
|
|
168
170
|
const cache = currentKeys(transport, params);
|
|
169
171
|
const cacheId = JSON.stringify([handle, epoch, keyId]);
|
|
170
|
-
let raw = cache
|
|
172
|
+
let raw = cache?.keys.get(cacheId)?.key;
|
|
171
173
|
if (raw === undefined) {
|
|
172
174
|
const query = new URLSearchParams({ handle, epoch, key_id: keyId });
|
|
173
175
|
const response = await shapeCall(transport, params, "getShapeKey", { query, cache: "no-store", signal });
|
|
@@ -179,7 +181,7 @@ export async function decryptSealedShapePage(transport, params, bytes, position,
|
|
|
179
181
|
if (btoa(String.fromCharCode(...raw)).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "") !== encoded) malformed("shape epoch key encoding");
|
|
180
182
|
const servedEpoch = BigInt(epoch);
|
|
181
183
|
const active = currentKeys(transport, params);
|
|
182
|
-
if (active === cache && servedEpoch >= cache.latestEpoch) {
|
|
184
|
+
if (cache !== undefined && active === cache && servedEpoch >= cache.latestEpoch) {
|
|
183
185
|
cache.latestEpoch = servedEpoch;
|
|
184
186
|
for (const [id, entry] of cache.keys) if (entry.epoch < servedEpoch) cache.keys.delete(id);
|
|
185
187
|
cache.keys.set(cacheId, { epoch: servedEpoch, key: raw });
|