@terrascale/sdk 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +193 -56
  3. package/package.json +14 -5
  4. package/sdk-public-v1-route-manifest.json +81 -0
  5. package/sdk-v1-route-manifest.json +8996 -1697
  6. package/src/database-codec.js +21 -26
  7. package/src/database-types.js +11 -11
  8. package/src/database-view.js +6 -6
  9. package/src/http.js +13 -48
  10. package/src/ids.js +22 -0
  11. package/src/index.js +77 -2
  12. package/src/local/better-auth.js +17 -6
  13. package/src/local/environment.js +0 -1
  14. package/src/local/index.js +2 -2
  15. package/src/local/legacy-client.js +5 -5
  16. package/src/local/router.js +12 -12
  17. package/src/local/store.js +29 -28
  18. package/src/local/test-environment.js +3 -2
  19. package/src/postgres.js +5 -4
  20. package/src/react/core.js +2 -2
  21. package/src/ts-auth-ids.js +122 -0
  22. package/src/ts-auth.js +75 -77
  23. package/src/v1/backups.js +111 -0
  24. package/src/v1/catalog.js +210 -0
  25. package/src/v1/changes.js +225 -0
  26. package/src/v1/database-schema.js +45 -0
  27. package/src/v1/decode.js +20 -8
  28. package/src/v1/errors.js +23 -3
  29. package/src/v1/ids.js +9 -59
  30. package/src/v1/imports.js +72 -0
  31. package/src/v1/indexes.js +76 -0
  32. package/src/v1/json.js +4 -3
  33. package/src/v1/management-decode.js +24 -0
  34. package/src/v1/management-routes.js +67 -0
  35. package/src/v1/management-validation.js +25 -0
  36. package/src/v1/routes.js +36 -11
  37. package/src/v1/saved-queries.js +58 -0
  38. package/src/v1/shape-crypto.js +125 -0
  39. package/src/v1/shape-manifest.js +31 -0
  40. package/src/v1/shape-reader.js +212 -0
  41. package/src/v1/shape-subscription.js +208 -0
  42. package/src/v1/shapes.js +15 -10
  43. package/src/v1/sql.js +98 -0
  44. package/src/v1/system.js +80 -0
  45. package/src/v1/terrabase-v1.js +192 -33
  46. package/src/v1/transport.js +80 -34
  47. package/src/v1/usage.js +101 -0
  48. package/types/database-codec.d.ts +1 -6
  49. package/types/database-types.d.ts +11 -11
  50. package/types/http.d.ts +2 -8
  51. package/types/ids.d.ts +10 -0
  52. package/types/index.d.ts +70 -1
  53. package/types/local/environment.d.ts +1 -1
  54. package/types/local/store.d.ts +10 -0
  55. package/types/local/test-environment.d.ts +2 -2
  56. package/types/postgres.d.ts +4 -4
  57. package/types/ts-auth-ids.d.ts +14 -0
  58. package/types/ts-auth.d.ts +99 -101
  59. package/types/v1/backups.d.ts +128 -0
  60. package/types/v1/catalog.d.ts +92 -0
  61. package/types/v1/changes.d.ts +53 -0
  62. package/types/v1/database-schema.d.ts +82 -0
  63. package/types/v1/decode.d.ts +17 -4
  64. package/types/v1/errors.d.ts +16 -1
  65. package/types/v1/ids.d.ts +4 -16
  66. package/types/v1/imports.d.ts +132 -0
  67. package/types/v1/indexes.d.ts +75 -0
  68. package/types/v1/management-decode.d.ts +15 -0
  69. package/types/v1/management-routes.d.ts +3 -0
  70. package/types/v1/management-validation.d.ts +8 -0
  71. package/types/v1/routes.d.ts +5 -2
  72. package/types/v1/saved-queries.d.ts +33 -0
  73. package/types/v1/shape-crypto.d.ts +39 -0
  74. package/types/v1/shape-manifest.d.ts +28 -0
  75. package/types/v1/shape-reader.d.ts +45 -0
  76. package/types/v1/shape-subscription.d.ts +35 -0
  77. package/types/v1/shapes.d.ts +14 -0
  78. package/types/v1/sql.d.ts +47 -0
  79. package/types/v1/system.d.ts +85 -0
  80. package/types/v1/terrabase-v1.d.ts +116 -7
  81. package/types/v1/transport.d.ts +27 -10
  82. package/types/v1/usage.d.ts +171 -0
  83. package/src/ts-auth-discovery.js +0 -207
  84. package/types/ts-auth-discovery.d.ts +0 -19
package/src/v1/ids.js CHANGED
@@ -1,4 +1,5 @@
1
- // Identifiers for the TerraBase v1 Documents API (api-v1.md).
1
+ // Identifiers for the TerraBase v1 Documents API.
2
+ import { requireId } from "../ids.js";
2
3
 
3
4
  const namePattern = /^[a-z][a-z0-9_-]{0,62}$/u;
4
5
  // Version tokens are opaque (tbv1-<32 hex>, tbv2-...): only RFC 9110 opaque-tag characters, safe to quote in If-Match.
@@ -6,12 +7,6 @@ const versionPattern = /^[\x21\x23-\x7e]{1,256}$/u;
6
7
  const offsetPattern = /^(?:0|[1-9]\d{0,19})$/u;
7
8
  const maximumU64 = (1n << 64n) - 1n;
8
9
 
9
- /** RFC 9562 NAMESPACE_URL, 6ba7b811-9dad-11d1-80b4-00c04fd430c8. */
10
- const namespaceUrl = Uint8Array.of(
11
- 0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1,
12
- 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
13
- );
14
-
15
10
  /**
16
11
  * @param {"database" | "collection"} kind
17
12
  * @param {unknown} name
@@ -24,23 +19,19 @@ export function requireName(kind, name) {
24
19
  }
25
20
 
26
21
  /**
27
- * The producer resolves this opaque catalog project id (normally a UUID), not its display name.
22
+ * The catalog project ID is a canonical ULID; database and collection routes use names.
28
23
  * @param {unknown} project
29
- * @returns {string}
24
+ * @returns {import("../ids.js").EntityId}
30
25
  */
31
26
  export function requireProject(project) {
32
- if (typeof project !== "string" || project === "" || project === "." || project === ".." || /[\p{Cc}\uD800-\uDFFF]/u.test(project))
33
- throw new TypeError("project must be a non-empty catalog project ID without control characters or dot path segments.");
34
- return project;
27
+ return requireId(project);
35
28
  }
36
29
 
37
- /**
38
- * @param {unknown} id
39
- * @returns {string}
40
- */
30
+ /** A caller-supplied key is not a generated entity ID. @param {unknown} id @returns {string} */
41
31
  export function requireDocumentId(id) {
42
- if (typeof id !== "string" || id.length < 1 || id.length > 256 || /[\p{Cc}]/u.test(id) || /[\uD800-\uDFFF]/u.test(id) || id === "." || id === "..")
43
- throw new TypeError("Document IDs must be 1-256 characters without control characters or dot path segments; new IDs must be lowercase UUIDs.");
32
+ if (typeof id !== "string" || id.length < 1 || id.length > 256 ||
33
+ /[\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(id))
34
+ throw new TypeError("TerraBase document keys must be 1-256 characters of valid UTF-8 without control characters.");
44
35
  return id;
45
36
  }
46
37
 
@@ -76,44 +67,3 @@ export function requireU64(value, what) {
76
67
  if (parsed === undefined) throw new TypeError(`${what} must be a u64: a canonical decimal string or bigint.`);
77
68
  return parsed;
78
69
  }
79
-
80
- /**
81
- * @param {Uint8Array} namespace
82
- * @param {string} name
83
- * @returns {Promise<Uint8Array>}
84
- */
85
- async function uuidV5(namespace, name) {
86
- const encoded = new TextEncoder().encode(name);
87
- const input = new Uint8Array(namespace.length + encoded.length);
88
- input.set(namespace);
89
- input.set(encoded, namespace.length);
90
- const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", input));
91
- const uuid = digest.slice(0, 16);
92
- uuid[6] = ((uuid[6] ?? 0) & 0x0f) | 0x50;
93
- uuid[8] = ((uuid[8] ?? 0) & 0x3f) | 0x80;
94
- return uuid;
95
- }
96
-
97
- /**
98
- * @param {Uint8Array} bytes
99
- * @returns {string}
100
- */
101
- function formatUuid(bytes) {
102
- const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
103
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
104
- }
105
-
106
- /**
107
- * The deterministic document ID for a natural key in a collection:
108
- * `uuidv5(uuidv5(NAMESPACE_URL, "terrabase:collection:" + collection), key)`.
109
- * Uses WebCrypto, so it works in Node.js, browsers and workers.
110
- * @param {string} collection
111
- * @param {string} key
112
- * @returns {Promise<string>}
113
- */
114
- export async function documentIdFor(collection, key) {
115
- requireName("collection", collection);
116
- if (typeof key !== "string") throw new TypeError("The natural key must be a string.");
117
- const namespace = await uuidV5(namespaceUrl, `terrabase:collection:${collection}`);
118
- return formatUuid(await uuidV5(namespace, key));
119
- }
@@ -0,0 +1,72 @@
1
+ import { requireId } from "../ids.js";
2
+ import { requireName } from "./ids.js";
3
+ import { flag, list, nullable, numeric, record, text } from "./management-decode.js";
4
+ import { members, mutationOptions } from "./management-validation.js";
5
+ import { stringifyExactJson } from "./json.js";
6
+ /** @import { V1Transport } from "./transport.js" */
7
+ /** @import { V1RequestOptions } from "./terrabase-v1.js" */
8
+ const importOptions = record({ missingPrimaryKey: nullable(text), exclude: nullable(list(text)) });
9
+ const column = record({ name: text, sourceType: text, mapping: text, nullable: flag, excluded: flag });
10
+ const constraint = record({ kind: text, name: nullable(text), columns: list(text), references: nullable(text), enforced: flag });
11
+ const issue = record({ severity: text, code: text, column: nullable(text), row: nullable(numeric), message: text });
12
+ const table = record({ source: text, collection: text, excluded: flag, rows: numeric, idPolicy: text, primaryKey: list(text),
13
+ columns: list(column), constraints: list(constraint), issues: list(issue) });
14
+ const preview = record({ id: requireId, tables: list(table), skipped: list(record({ kind: text, name: text })), skippedTruncated: flag, blocking: numeric, warnings: numeric });
15
+ const progress = record({ tablesDone: numeric, tablesTotal: numeric, rowsWritten: numeric, rowsTotal: numeric, currentTable: nullable(text) });
16
+ const validation = record({ passed: flag, tables: list(record({ source: text, collection: text, sourceRows: numeric, documents: numeric,
17
+ sourceChecksum: text, documentChecksum: text, match: flag })) });
18
+ const error = record({ code: text, message: text, retryable: flag, table: nullable(text), row: nullable(numeric), column: nullable(text) });
19
+ const job = record({ id: requireId, project: requireId, database: text, region: text, state: text, cancelRequested: flag, options: importOptions,
20
+ source: record({ format: text, bytes: numeric, sha256: text, receivedBytes: numeric, staged: flag, retainedUntil: nullable(text) }),
21
+ preview: nullable(preview), confirmedPreviewId: nullable(requireId), progress: nullable(progress), validation: nullable(validation), error: nullable(error),
22
+ createdAt: text, updatedAt: text, startedAt: nullable(text), finishedAt: nullable(text) });
23
+ /** @typedef {ReturnType<typeof job>} V1ImportJob */
24
+ /** @typedef {{ readonly missingPrimaryKey?: "reject" | "generate" | null; readonly exclude?: readonly string[] | null }} V1ImportOptions */
25
+ /** @typedef {{ readonly format: "postgres_plain" | "sqlite"; readonly sourceBytes: number; readonly sourceSha256: string; readonly options?: V1ImportOptions | null }} V1CreateImportRequest */
26
+
27
+ /** One import job belongs to its destination database; preview confirmation is explicit. */
28
+ export class V1Imports {
29
+ /** @type {V1Transport} */
30
+ #transport;
31
+ /** @type {{database: string}} */
32
+ #params;
33
+ /** @param {V1Transport} transport @param {string} database */
34
+ constructor(transport, database) { this.#transport = transport; this.#params = { database: requireName("database", database) }; Object.freeze(this); }
35
+ /** @param {V1CreateImportRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
36
+ async create(request, options = {}) {
37
+ members(request, ["format", "sourceBytes", "sourceSha256", "options"]);
38
+ if (!["postgres_plain", "sqlite"].includes(request.format) || !Number.isSafeInteger(request.sourceBytes) || request.sourceBytes < 1 || request.sourceBytes > 1073741824 ||
39
+ typeof request.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(request.sourceSha256)) throw new TypeError("Invalid import format, size or SHA-256.");
40
+ mutationOptions(options);
41
+ return job((await this.#transport.call("createImport", this.#params, { body: stringifyExactJson(request, "import create"), signal: options.signal })).value);
42
+ }
43
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
44
+ async get(options = {}) { return job((await this.#transport.call("getImport", this.#params, { signal: options.signal })).value); }
45
+ /** @param {number | bigint} offset @param {Uint8Array} bytes @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
46
+ async uploadSource(offset, bytes, options = {}) {
47
+ if (!(typeof offset === "bigint" || typeof offset === "number" && Number.isSafeInteger(offset)) || offset < 0 || BigInt(offset) > 9223372036854775807n)
48
+ throw new TypeError("offset must be a non-negative signed 64-bit integer.");
49
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > 4194304) throw new TypeError("A source part must contain 1-4194304 bytes.");
50
+ mutationOptions(options);
51
+ return job((await this.#transport.call("uploadImportSource", this.#params, {
52
+ body: bytes.slice(), contentType: "application/octet-stream", query: new URLSearchParams({ offset: String(offset) }), signal: options.signal,
53
+ })).value);
54
+ }
55
+ /** @param {{readonly options?: V1ImportOptions | null}} request @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
56
+ async update(request, options = {}) {
57
+ members(request, ["options"]);
58
+ mutationOptions(options);
59
+ return job((await this.#transport.call("updateImport", this.#params, { body: stringifyExactJson(request, "import update"), signal: options.signal })).value);
60
+ }
61
+ /** @param {{readonly previewId: string}} request @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
62
+ async start(request, options = {}) {
63
+ members(request, ["previewId"]);
64
+ requireId(request.previewId, "Preview ID");
65
+ mutationOptions(options);
66
+ return job((await this.#transport.call("startImport", this.#params, { body: stringifyExactJson(request, "import start"), signal: options.signal })).value);
67
+ }
68
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
69
+ async cancel(options = {}) { mutationOptions(options); return job((await this.#transport.call("cancelImport", this.#params, { signal: options.signal })).value); }
70
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
71
+ async resume(options = {}) { mutationOptions(options); return job((await this.#transport.call("resumeImport", this.#params, { signal: options.signal })).value); }
72
+ }
@@ -0,0 +1,76 @@
1
+ import { requireName } from "./ids.js";
2
+ import { flag, list, numeric, record, text } from "./management-decode.js";
3
+ import { members, mutationOptions } from "./management-validation.js";
4
+ import { stringifyExactJson } from "./json.js";
5
+ /** @import { V1Transport } from "./transport.js" */
6
+ /** @import { V1RequestOptions } from "./terrabase-v1.js" */
7
+ const index = record({ name: text, collection: text, fields: list(text), state: text });
8
+ const indexes = record({ items: list(index) });
9
+ const policy = record({ collection: text, enabled: flag });
10
+ const recommendations = record({ items: list(record({ id: text, collection: text, fields: list(text), reason: text, queries: numeric })) });
11
+ const applied = record({ status: text, index });
12
+ /** @typedef {ReturnType<typeof index>} V1Index */
13
+ /** @typedef {ReturnType<typeof indexes>} V1IndexList */
14
+ /** @typedef {ReturnType<typeof policy>} V1AutoIndexPolicy */
15
+ /** @typedef {ReturnType<typeof recommendations>} V1IndexRecommendations */
16
+ /** @typedef {ReturnType<typeof applied>} V1AppliedIndexRecommendation */
17
+ /** @typedef {{ readonly collection: string; readonly fields: readonly string[]; readonly name?: string | null }} V1CreateIndexRequest */
18
+ /** @param {unknown} value @returns {string} */
19
+ function indexName(value) {
20
+ if (typeof value !== "string" || !/^[a-z][a-z0-9_]{0,62}$/u.test(value)) throw new TypeError("Invalid index name.");
21
+ return value;
22
+ }
23
+
24
+ /** Secondary indexes, automatic policies and workload recommendations for one database. */
25
+ export class V1Indexes {
26
+ /** @type {V1Transport} */
27
+ #transport;
28
+ /** @type {{database: string}} */
29
+ #params;
30
+ /** @param {V1Transport} transport @param {string} database */
31
+ constructor(transport, database) { this.#transport = transport; this.#params = { database: requireName("database", database) }; Object.freeze(this); }
32
+ /** @param {V1CreateIndexRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1Index>} */
33
+ async create(request, options = {}) {
34
+ members(request, ["collection", "fields", "name"]);
35
+ requireName("collection", request.collection);
36
+ if (!Array.isArray(request.fields) || request.fields.length < 1 || request.fields.length > 4 ||
37
+ new Set(request.fields).size !== request.fields.length || request.fields.some(f => typeof f !== "string" || f === "_id" || f === "_rev" || !/^[A-Za-z_][A-Za-z0-9_]{0,63}(?:\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,7}$/u.test(f)))
38
+ throw new TypeError("fields must be 1-4 distinct dotted paths, excluding _id and _rev.");
39
+ if (request.name !== undefined && request.name !== null) indexName(request.name);
40
+ mutationOptions(options);
41
+ return index((await this.#transport.call("createIndex", this.#params, { body: stringifyExactJson(request, "index"), signal: options.signal })).value);
42
+ }
43
+ /** @param {{ readonly collection?: string }} [request] @param {V1RequestOptions} [options] @returns {Promise<V1IndexList>} */
44
+ async list(request = {}, options = {}) {
45
+ members(request, ["collection"]);
46
+ const query = new URLSearchParams();
47
+ if (request.collection !== undefined) query.set("collection", requireName("collection", request.collection));
48
+ return indexes((await this.#transport.call("listIndexes", this.#params, { query, signal: options.signal })).value);
49
+ }
50
+ /** @param {string} name @param {V1RequestOptions} [options] @returns {Promise<void>} */
51
+ async drop(name, options = {}) {
52
+ mutationOptions(options);
53
+ await this.#transport.call("dropIndex", { ...this.#params, index: indexName(name) }, { signal: options.signal });
54
+ }
55
+ /** @param {string} collection @param {V1RequestOptions} [options] @returns {Promise<V1AutoIndexPolicy>} */
56
+ async getAutoPolicy(collection, options = {}) {
57
+ return policy((await this.#transport.call("getAutoIndexPolicy", { ...this.#params, collection: requireName("collection", collection) }, { signal: options.signal })).value);
58
+ }
59
+ /** @param {string} collection @param {{ readonly enabled: boolean }} request @param {V1RequestOptions} [options] @returns {Promise<V1AutoIndexPolicy>} */
60
+ async setAutoPolicy(collection, request, options = {}) {
61
+ members(request, ["enabled"]);
62
+ if (typeof request.enabled !== "boolean") throw new TypeError("enabled must be boolean.");
63
+ mutationOptions(options);
64
+ return policy((await this.#transport.call("setAutoIndexPolicy", { ...this.#params, collection: requireName("collection", collection) }, {
65
+ body: stringifyExactJson(request, "auto-index policy"), signal: options.signal,
66
+ })).value);
67
+ }
68
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1IndexRecommendations>} */
69
+ async recommendations(options = {}) { return recommendations((await this.#transport.call("listIndexRecommendations", this.#params, { signal: options.signal })).value); }
70
+ /** @param {string} id Opaque producer recommendation identifier. @param {V1RequestOptions} [options] @returns {Promise<V1AppliedIndexRecommendation>} */
71
+ async applyRecommendation(id, options = {}) {
72
+ if (typeof id !== "string" || id === "") throw new TypeError("A recommendation identifier is required.");
73
+ mutationOptions(options);
74
+ return applied((await this.#transport.call("applyIndexRecommendation", { ...this.#params, recommendation: id }, { signal: options.signal })).value);
75
+ }
76
+ }
package/src/v1/json.js CHANGED
@@ -28,9 +28,10 @@ function canonicalDecimal(text) {
28
28
  let digits = `${match[2]}${fraction}`.replace(/^0+/u, "");
29
29
  let exponent = BigInt(match[4] ?? "0") - BigInt(fraction.length);
30
30
  if (digits === "") return { negative: false, digits: "", exponent: 0n };
31
- const trimmed = digits.replace(/0+$/u, "");
32
- exponent += BigInt(digits.length - trimmed.length);
33
- digits = trimmed;
31
+ let end = digits.length;
32
+ while (digits[end - 1] === "0") end--;
33
+ exponent += BigInt(digits.length - end);
34
+ digits = digits.slice(0, end);
34
35
  return { negative: match[1] === "-", digits, exponent };
35
36
  }
36
37
 
@@ -0,0 +1,24 @@
1
+ import { array, malformed, object, string, boolean } from "./decode.js";
2
+ import { ExactDecimal } from "./json.js";
3
+ /** @import { JsonValue } from "./json.js" */
4
+ /** @template T @typedef {(value: JsonValue | undefined) => T} Decoder */
5
+ export const text = /** @type {Decoder<string>} */ (value => string(value, "string"));
6
+ export const flag = /** @type {Decoder<boolean>} */ (value => boolean(value, "boolean"));
7
+ /** @type {Decoder<number | ExactDecimal>} */
8
+ export const numeric = value => typeof value === "number" && Number.isFinite(value) || value instanceof ExactDecimal ? value : malformed("number");
9
+ /** @template T @param {Decoder<T>} decode @returns {Decoder<T | null>} */
10
+ export const nullable = decode => value => value === null ? null : decode(value);
11
+ /** @template T @param {Decoder<T>} decode @returns {Decoder<T | undefined>} */
12
+ export const optional = decode => value => value === undefined ? undefined : decode(value);
13
+ /** @template T @param {Decoder<T>} decode @returns {Decoder<readonly T[]>} */
14
+ export const list = decode => value => Object.freeze(array(value, "array").map(decode));
15
+ /** @template T @param {{ [K in keyof T]: Decoder<T[K]> }} fields @returns {Decoder<Readonly<T>>} */
16
+ export function record(fields) {
17
+ return value => {
18
+ const raw = object(value, "management response");
19
+ return Object.freeze(/** @type {T} */ (Object.fromEntries(Object.entries(fields).map(([key, decode]) =>
20
+ [key, /** @type {Decoder<unknown>} */ (decode)(raw[key])]))));
21
+ };
22
+ }
23
+ /** @template T @param {Decoder<T>} decode @returns {Decoder<Readonly<Record<string, T>>>} */
24
+ export const dictionary = decode => value => Object.freeze(Object.fromEntries(Object.entries(object(value, "dictionary")).map(([key, item]) => [key, decode(item)])));
@@ -0,0 +1,67 @@
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 */
2
+ const saved = "/v1/projects/{project}/saved-queries";
3
+ /** @type {readonly import("./routes.js").V1Route[]} */
4
+ export const managementV1Routes = Object.freeze([
5
+ route("getShapeManifest", "GET", "/v1/projects/{project}/databases/{database}/collections/{collection}/shape/manifest"),
6
+ route("healthz", "GET", "/healthz"),
7
+ route("readyz", "GET", "/readyz"),
8
+ route("backupz", "GET", "/backupz"),
9
+ route("replicationHealth", "GET", "/health/replication"),
10
+ route("listPlatformDatabases", "GET", "/v1/platform/databases"),
11
+ route("listPlatformCollections", "GET", "/v1/platform/databases/{database}/collections"),
12
+ route("listPlatformDocuments", "GET", "/v1/platform/databases/{database}/collections/{collection}/documents"),
13
+ route("getPlatformDocument", "GET", "/v1/platform/databases/{database}/collections/{collection}/documents/by-key"),
14
+ route("createImport", "POST", "/v1/projects/{project}/databases/{database}/import", 201),
15
+ route("getImport", "GET", "/v1/projects/{project}/databases/{database}/import", 200),
16
+ route("uploadImportSource", "PUT", "/v1/projects/{project}/databases/{database}/import/source", 200),
17
+ route("updateImport", "PATCH", "/v1/projects/{project}/databases/{database}/import", 202),
18
+ route("startImport", "POST", "/v1/projects/{project}/databases/{database}/import/start", 202),
19
+ route("cancelImport", "POST", "/v1/projects/{project}/databases/{database}/import/cancel", 202),
20
+ route("resumeImport", "POST", "/v1/projects/{project}/databases/{database}/import/resume", 202),
21
+ route("createBackup", "POST", "/v1/projects/{project}/databases/{database}/backups", 201),
22
+ route("listBackups", "GET", "/v1/projects/{project}/databases/{database}/backups", 200),
23
+ route("restoreBackup", "POST", "/v1/projects/{project}/databases/{database}/backups/{backup}/restore", 201),
24
+ route("getBackupPolicy", "GET", "/v1/projects/{project}/databases/{database}/backup-policy", 200),
25
+ route("setBackupPolicy", "PUT", "/v1/projects/{project}/databases/{database}/backup-policy", 200),
26
+ route("getRestoreWindow", "GET", "/v1/projects/{project}/databases/{database}/point-in-time", 200),
27
+ route("planPointInTime", "POST", "/v1/projects/{project}/databases/{database}/point-in-time/plan", 200),
28
+ route("restorePointInTime", "POST", "/v1/projects/{project}/databases/{database}/point-in-time/restore", 201),
29
+ route("getBackupDestination", "GET", "/v1/projects/{project}/backup-destination", 200),
30
+ route("setBackupDestination", "PUT", "/v1/projects/{project}/backup-destination", 200),
31
+ route("deleteBackupDestination", "DELETE", "/v1/projects/{project}/backup-destination", 204),
32
+ route("getProjectUsage", "GET", "/v1/projects/{project}/usage"),
33
+ route("getProjectAnalytics", "GET", "/v1/projects/{project}/analytics"),
34
+ route("getProjectReplication", "GET", "/v1/projects/{project}/replication"),
35
+ route("getDatabaseUsage", "GET", "/v1/projects/{project}/databases/{database}/usage"),
36
+ route("getDatabaseAnalytics", "GET", "/v1/projects/{project}/databases/{database}/analytics"),
37
+ route("getDatabaseReplication", "GET", "/v1/projects/{project}/databases/{database}/replication"),
38
+ route("createIndex", "POST", "/v1/projects/{project}/databases/{database}/indexes", 201),
39
+ route("listIndexes", "GET", "/v1/projects/{project}/databases/{database}/indexes", 200),
40
+ route("dropIndex", "DELETE", "/v1/projects/{project}/databases/{database}/indexes/{index}", 204),
41
+ route("getAutoIndexPolicy", "GET", "/v1/projects/{project}/databases/{database}/auto-index/{collection}", 200),
42
+ route("setAutoIndexPolicy", "PUT", "/v1/projects/{project}/databases/{database}/auto-index/{collection}", 200),
43
+ route("listIndexRecommendations", "GET", "/v1/projects/{project}/databases/{database}/index-recommendations", 200),
44
+ route("applyIndexRecommendation", "POST", "/v1/projects/{project}/databases/{database}/index-recommendations/{recommendation}/apply", 201),
45
+ route("getDatabaseSchema", "GET", "/v1/projects/{project}/databases/{database}/schema"),
46
+ route("inferCollectionSchema", "GET", "/v1/projects/{project}/databases/{database}/schema/{collection}"),
47
+ route("declareCollectionSchema", "PUT", "/v1/projects/{project}/databases/{database}/schema/{collection}"),
48
+ route("listQueryShapes", "GET", "/v1/projects/{project}/databases/{database}/query-shapes"),
49
+ route("createSavedQuery", "POST", saved, 201),
50
+ route("listSavedQueries", "GET", saved),
51
+ route("getSavedQuery", "GET", `${saved}/{query}`),
52
+ route("updateSavedQuery", "PUT", `${saved}/{query}`),
53
+ route("deleteSavedQuery", "DELETE", `${saved}/{query}`, 204),
54
+ ]);
55
+
56
+ /** @param {ManagementOperation} operation @param {import("./routes.js").V1Route["method"]} method @param {string} pathTemplate @param {number} [status] @returns {import("./routes.js").V1Route} */
57
+ function route(operation, method, pathTemplate, status = 200) {
58
+ const write = method !== "GET" && operation !== "planPointInTime";
59
+ const noBody = ["createBackup", "applyIndexRecommendation", "cancelImport", "resumeImport"].includes(operation);
60
+ 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"] : []),
63
+ 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([]),
66
+ });
67
+ }
@@ -0,0 +1,25 @@
1
+ /** @import { V1DatabaseListRequest } from "./catalog.js" */
2
+ /** @param {unknown} request @param {readonly string[]} allowed */
3
+ export function members(request, allowed) {
4
+ if (request === null || typeof request !== "object" || Array.isArray(request) ||
5
+ ![Object.prototype, null].includes(Object.getPrototypeOf(request))) throw new TypeError("Expected a plain request object.");
6
+ for (const key of Object.keys(request)) if (!allowed.includes(key)) throw new TypeError(`Unknown request member ${key}.`);
7
+ }
8
+ /** @param {object} options */
9
+ export function mutationOptions(options) {
10
+ if ("idempotencyKey" in options) throw new TypeError("The producer does not support Idempotency-Key; mutations are never replayed.");
11
+ }
12
+ /** @param {V1DatabaseListRequest} request */
13
+ export function pageQuery(request) {
14
+ members(request, ["after", "limit"]);
15
+ const query = new URLSearchParams();
16
+ if (request.after !== undefined) {
17
+ if (typeof request.after !== "string") throw new TypeError("after must be an opaque string.");
18
+ query.set("after", request.after);
19
+ }
20
+ if (request.limit !== undefined) {
21
+ if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 100) throw new TypeError("limit must be 1-100.");
22
+ query.set("limit", String(request.limit));
23
+ }
24
+ return query;
25
+ }
package/src/v1/routes.js CHANGED
@@ -1,11 +1,13 @@
1
- // Routes owned by the C# Documents API at 0248ad3 (decision 0004 and shape-reads.md).
2
- /** @typedef {"getDocument" | "putDocument" | "createDocument" | "patchDocument" | "deleteDocument" | "commitTransaction" | "queryDocuments" | "createCollection" | "getCollection" | "listCollections" | "dropCollection" | "readShape"} V1Operation */
1
+ import { managementV1Routes } from "./management-routes.js";
2
+ // Current TerraBase routes at e30ffdc; readShape is retained from release 6cb9745 only.
3
+ /** @typedef {import("./management-routes.js").ManagementOperation | "readFreeShape" | "getShapeKey" | "getDocument" | "putDocument" | "createDocument" | "patchDocument" | "deleteDocument" | "commitTransaction" | "queryDocuments" | "createCollection" | "getCollection" | "listCollections" | "dropCollection" | "readShape" | "runSql" | "checkSql" | "explainSql" | "streamDatabaseChanges" | "listDocuments" | "getCollectionSchema" | "putCollectionSchema" | "deleteCollectionSchema" | "createDatabase" | "listDatabases" | "getDatabase" | "updateDatabase" | "deleteDatabase" | "branchDatabase"} V1Operation */
3
4
  /**
4
5
  * @typedef {object} V1Route
5
6
  * @property {V1Operation} operation
6
7
  * @property {"GET" | "PUT" | "POST" | "PATCH" | "DELETE"} method
7
8
  * @property {string} pathTemplate
8
- * @property {"documents:read" | "documents:write" | "shapes:read"} permission
9
+ * @property {"documents:read" | "documents:write" | "shapes:read" | "databases:read" | "databases:write" | "anonymous" | "platform-admin"} permission
10
+ * @property {readonly string[]} [additionalPermissions]
9
11
  * @property {readonly string[]} requestMediaTypes
10
12
  * @property {string} responseMediaType
11
13
  * @property {readonly number[]} successStatuses
@@ -18,13 +20,14 @@ const database = "/v1/projects/{project}/databases/{database}";
18
20
  const collections = `${database}/collections`;
19
21
  const collection = `${collections}/{collection}`;
20
22
  const documents = `${collection}/documents`;
23
+ const databases = "/v1/projects/{project}/databases";
21
24
  /** @type {readonly V1Route[]} */
22
25
  export const v1Routes = Object.freeze(/** @type {V1Route[]} */ ([
23
- { operation: "getDocument", method: "GET", pathTemplate: `${documents}/{id}`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
24
- { operation: "putDocument", method: "PUT", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200, 201] },
26
+ { operation: "getDocument", method: "GET", pathTemplate: `${documents}/by-key`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
27
+ { operation: "putDocument", method: "PUT", pathTemplate: `${documents}/by-key`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200, 201] },
25
28
  { operation: "createDocument", method: "POST", pathTemplate: documents, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
26
- { operation: "patchDocument", method: "PATCH", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: ["application/merge-patch+json", "application/vnd.terrabase.ops+json"], successStatuses: [200] },
27
- { operation: "deleteDocument", method: "DELETE", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
29
+ { operation: "patchDocument", method: "PATCH", pathTemplate: `${documents}/by-key`, permission: "documents:write", requestMediaTypes: ["application/merge-patch+json", "application/vnd.terrabase.ops+json"], successStatuses: [200] },
30
+ { operation: "deleteDocument", method: "DELETE", pathTemplate: `${documents}/by-key`, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
28
31
  { operation: "commitTransaction", method: "POST", pathTemplate: `${database}/transactions`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200] },
29
32
  { operation: "queryDocuments", method: "POST", pathTemplate: `${collection}/query`, permission: "documents:read", requestMediaTypes: ["application/json"], successStatuses: [200] },
30
33
  { operation: "createCollection", method: "POST", pathTemplate: collections, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
@@ -32,10 +35,25 @@ export const v1Routes = Object.freeze(/** @type {V1Route[]} */ ([
32
35
  { operation: "listCollections", method: "GET", pathTemplate: collections, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
33
36
  { operation: "dropCollection", method: "DELETE", pathTemplate: collection, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
34
37
  { operation: "readShape", method: "GET", pathTemplate: `${collection}/shape`, permission: "shapes:read", requestMediaTypes: [], successStatuses: [200, 307] },
38
+ { operation: "runSql", method: "POST", pathTemplate: `${database}/sql`, permission: "databases:read", requestMediaTypes: ["application/json"], successStatuses: [200] },
39
+ { operation: "checkSql", method: "POST", pathTemplate: `${database}/sql/check`, permission: "databases:read", requestMediaTypes: ["application/json"], successStatuses: [200] },
40
+ { operation: "explainSql", method: "POST", pathTemplate: `${database}/sql/explain`, permission: "databases:read", requestMediaTypes: ["application/json"], successStatuses: [200] },
41
+ { operation: "streamDatabaseChanges", method: "GET", pathTemplate: `${database}/changes`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
42
+ { operation: "listDocuments", method: "GET", pathTemplate: documents, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
43
+ { operation: "getCollectionSchema", method: "GET", pathTemplate: `${collection}/schema`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
44
+ { operation: "putCollectionSchema", method: "PUT", pathTemplate: `${collection}/schema`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200] },
45
+ { operation: "deleteCollectionSchema", method: "DELETE", pathTemplate: `${collection}/schema`, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
46
+ { operation: "createDatabase", method: "POST", pathTemplate: databases, permission: "databases:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
47
+ { operation: "listDatabases", method: "GET", pathTemplate: databases, permission: "databases:read", requestMediaTypes: [], successStatuses: [200] },
48
+ { operation: "getDatabase", method: "GET", pathTemplate: database, permission: "databases:read", requestMediaTypes: [], successStatuses: [200] },
49
+ { operation: "updateDatabase", method: "PATCH", pathTemplate: database, permission: "databases:write", requestMediaTypes: ["application/json"], successStatuses: [200] },
50
+ { operation: "deleteDatabase", method: "DELETE", pathTemplate: database, permission: "databases:write", requestMediaTypes: [], successStatuses: [204] },
51
+ { operation: "branchDatabase", method: "POST", pathTemplate: `${database}/branches`, permission: "databases:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
35
52
  ].map(route => ({
36
- ...route, responseMediaType: "application/json", idempotencyKey: "none",
37
- replaySafe: route.permission !== "documents:write",
38
- contract: route.operation === "readShape" ? "docs/shape-reads.md" : "docs/decisions/0004-document-api-v1.md",
53
+ ...route, responseMediaType: route.operation === "deleteDatabase" ? "" : route.operation === "streamDatabaseChanges" ? "text/event-stream" : "application/json", idempotencyKey: "none",
54
+ replaySafe: !route.permission.endsWith(":write"),
55
+ contract: route.operation === "readShape" ? "docs/shape-reads.md" :
56
+ ["runSql", "checkSql", "explainSql", "streamDatabaseChanges"].includes(route.operation) ? "docs/terrabase/api.md" : "docs/decisions/0004-document-api-v1.md",
39
57
  contractText: [],
40
58
  }))).map(route => Object.freeze({
41
59
  ...route, requestMediaTypes: Object.freeze(route.requestMediaTypes),
@@ -43,7 +61,7 @@ export const v1Routes = Object.freeze(/** @type {V1Route[]} */ ([
43
61
  })));
44
62
  /** @param {V1Operation} operation @returns {V1Route} */
45
63
  export function v1Route(operation) {
46
- const route = v1Routes.find(candidate => candidate.operation === operation);
64
+ const route = [...v1Routes, ...shapeV1Routes, ...managementV1Routes].find(candidate => candidate.operation === operation);
47
65
  if (route === undefined) throw new TypeError(`Unknown v1 operation ${operation}.`);
48
66
  return route;
49
67
  }
@@ -55,3 +73,10 @@ export function v1RoutePath(route, params) {
55
73
  return encodeURIComponent(value);
56
74
  });
57
75
  }
76
+
77
+ // Proposed free-read protocol, separately inventoried from historical/runtime route evidence.
78
+ /** @type {readonly V1Route[]} */
79
+ export const shapeV1Routes = Object.freeze([
80
+ Object.freeze({ operation: /** @type {const} */ ("readFreeShape"), method: /** @type {const} */ ("GET"), pathTemplate: `${collection}/shape`, permission: /** @type {const} */ ("documents:read"), requestMediaTypes: Object.freeze([]), responseMediaType: "application/json", successStatuses: Object.freeze([200, 307]), idempotencyKey: /** @type {const} */ ("none"), replaySafe: true, contract: "docs/terrabase/shape-reads.md", contractText: Object.freeze([]) }),
81
+ Object.freeze({ operation: /** @type {const} */ ("getShapeKey"), method: /** @type {const} */ ("GET"), pathTemplate: `${collection}/shape/keys`, permission: /** @type {const} */ ("documents:read"), requestMediaTypes: Object.freeze([]), responseMediaType: "application/json", successStatuses: Object.freeze([200]), idempotencyKey: /** @type {const} */ ("none"), replaySafe: true, contract: "docs/terrabase/shape-reads.md", contractText: Object.freeze([]) }),
82
+ ]);
@@ -0,0 +1,58 @@
1
+ import { requireId } from "../ids.js";
2
+ import { array, malformed, object, string } from "./decode.js";
3
+ import { stringifyExactJson } from "./json.js";
4
+ import { members, mutationOptions, pageQuery } from "./management-validation.js";
5
+ /** @import { V1Transport } from "./transport.js" */
6
+ /** @import { V1RequestOptions } from "./terrabase-v1.js" */
7
+ /** @typedef {{ readonly id: string; readonly name: string; readonly sql: string; readonly createdAt: string; readonly updatedAt: string | null }} V1SavedQuery */
8
+ /** @typedef {{ readonly name: string; readonly sql: string }} V1SavedQueryRequest */
9
+ /** @typedef {{ readonly items: readonly V1SavedQuery[]; readonly next: string | null }} V1SavedQueryPage */
10
+
11
+ /** @param {import("./json.js").JsonValue | undefined} value @returns {V1SavedQuery} */
12
+ function savedQuery(value) {
13
+ const raw = object(value, "saved query");
14
+ return Object.freeze({ id: requireId(raw.id), name: string(raw.name, "saved query name"), sql: string(raw.sql, "saved SQL"),
15
+ createdAt: string(raw.createdAt, "saved query createdAt"), updatedAt: raw.updatedAt === null ? null : string(raw.updatedAt, "saved query updatedAt") });
16
+ }
17
+ /** @param {V1SavedQueryRequest} request */
18
+ function body(request) {
19
+ members(request, ["name", "sql"]);
20
+ if (typeof request.name !== "string" || request.name.trim().length < 1 || request.name.trim().length > 128 ||
21
+ /[\p{Cc}]/u.test(request.name) || !/[\p{L}\p{N}]/u.test(request.name)) throw new TypeError("name must contain a letter or digit and be 1-128 characters without controls.");
22
+ if (typeof request.sql !== "string" || request.sql.trim() === "" || request.sql.length > 16_384)
23
+ throw new TypeError("sql must be a non-empty statement of at most 16384 UTF-16 code units.");
24
+ return stringifyExactJson({ name: request.name, sql: request.sql }, "saved query");
25
+ }
26
+
27
+ /** Saved queries belong to the authorized project, independently of database handles. */
28
+ export class V1SavedQueries {
29
+ /** @type {V1Transport} */
30
+ #transport;
31
+ /** @param {V1Transport} transport */
32
+ constructor(transport) { this.#transport = transport; Object.freeze(this); }
33
+ /** @param {V1SavedQueryRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1SavedQuery>} */
34
+ async create(request, options = {}) {
35
+ mutationOptions(options);
36
+ return savedQuery((await this.#transport.call("createSavedQuery", {}, { body: body(request), signal: options.signal })).value);
37
+ }
38
+ /** @param {import("./catalog.js").V1DatabaseListRequest} [request] @param {V1RequestOptions} [options] @returns {Promise<V1SavedQueryPage>} */
39
+ async list(request = {}, options = {}) {
40
+ const raw = object((await this.#transport.call("listSavedQueries", {}, { query: pageQuery(request), signal: options.signal })).value, "saved query page");
41
+ if (raw.next !== null && (typeof raw.next !== "string" || raw.next === "")) malformed("saved query next");
42
+ return Object.freeze({ items: Object.freeze(array(raw.items, "saved queries").map(savedQuery)), next: raw.next });
43
+ }
44
+ /** @param {string} id @param {V1RequestOptions} [options] @returns {Promise<V1SavedQuery>} */
45
+ async get(id, options = {}) {
46
+ return savedQuery((await this.#transport.call("getSavedQuery", { query: requireId(id) }, { signal: options.signal })).value);
47
+ }
48
+ /** @param {string} id @param {V1SavedQueryRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1SavedQuery>} */
49
+ async update(id, request, options = {}) {
50
+ mutationOptions(options);
51
+ return savedQuery((await this.#transport.call("updateSavedQuery", { query: requireId(id) }, { body: body(request), signal: options.signal })).value);
52
+ }
53
+ /** @param {string} id @param {V1RequestOptions} [options] @returns {Promise<void>} */
54
+ async delete(id, options = {}) {
55
+ mutationOptions(options);
56
+ await this.#transport.call("deleteSavedQuery", { query: requireId(id) }, { signal: options.signal });
57
+ }
58
+ }