@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 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.sql/checkSql/explainSql` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrascale/sdk",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "JavaScript SDK, typed with JSDoc, for project-scoped C# TerraBase v1 and hosted TerraScale identity.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -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);
@@ -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
  }
@@ -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>>} Keys stay scoped to a credential, database and collection. */
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 process-memory keys for this handle/epoch/KID; fetch misses without HTTP cache 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.keys.get(cacheId)?.key;
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 });