@terrascale/sdk 0.0.3 → 0.0.5

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 (66) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +206 -67
  3. package/package.json +10 -4
  4. package/sdk-public-v1-route-manifest.json +85 -0
  5. package/sdk-v1-route-manifest.json +896 -15
  6. package/src/http.js +3 -48
  7. package/src/index.js +80 -1
  8. package/src/local/environment.js +0 -1
  9. package/src/ts-auth.js +1 -5
  10. package/src/v1/backups.js +111 -0
  11. package/src/v1/catalog.js +240 -0
  12. package/src/v1/changes.js +225 -0
  13. package/src/v1/database-schema.js +45 -0
  14. package/src/v1/decode.js +6 -5
  15. package/src/v1/errors.js +23 -3
  16. package/src/v1/export.js +68 -0
  17. package/src/v1/ids.js +5 -2
  18. package/src/v1/imports.js +72 -0
  19. package/src/v1/indexes.js +76 -0
  20. package/src/v1/json.js +4 -3
  21. package/src/v1/management-decode.js +24 -0
  22. package/src/v1/management-routes.js +72 -0
  23. package/src/v1/management-validation.js +25 -0
  24. package/src/v1/routes.js +32 -7
  25. package/src/v1/saved-queries.js +58 -0
  26. package/src/v1/shape-crypto.js +125 -0
  27. package/src/v1/shape-manifest.js +31 -0
  28. package/src/v1/shape-reader.js +214 -0
  29. package/src/v1/shape-subscription.js +208 -0
  30. package/src/v1/shapes.js +11 -5
  31. package/src/v1/sql.js +98 -0
  32. package/src/v1/system.js +80 -0
  33. package/src/v1/terrabase-v1.js +231 -30
  34. package/src/v1/transport.js +117 -33
  35. package/src/v1/usage.js +101 -0
  36. package/types/http.d.ts +2 -8
  37. package/types/index.d.ts +73 -0
  38. package/types/local/environment.d.ts +1 -1
  39. package/types/ts-auth.d.ts +0 -2
  40. package/types/v1/backups.d.ts +128 -0
  41. package/types/v1/catalog.d.ts +104 -0
  42. package/types/v1/changes.d.ts +53 -0
  43. package/types/v1/database-schema.d.ts +82 -0
  44. package/types/v1/decode.d.ts +20 -4
  45. package/types/v1/errors.d.ts +16 -1
  46. package/types/v1/export.d.ts +26 -0
  47. package/types/v1/ids.d.ts +2 -2
  48. package/types/v1/imports.d.ts +132 -0
  49. package/types/v1/indexes.d.ts +75 -0
  50. package/types/v1/management-decode.d.ts +15 -0
  51. package/types/v1/management-routes.d.ts +3 -0
  52. package/types/v1/management-validation.d.ts +8 -0
  53. package/types/v1/routes.d.ts +5 -2
  54. package/types/v1/saved-queries.d.ts +33 -0
  55. package/types/v1/shape-crypto.d.ts +39 -0
  56. package/types/v1/shape-manifest.d.ts +28 -0
  57. package/types/v1/shape-reader.d.ts +45 -0
  58. package/types/v1/shape-subscription.d.ts +35 -0
  59. package/types/v1/shapes.d.ts +16 -2
  60. package/types/v1/sql.d.ts +47 -0
  61. package/types/v1/system.d.ts +85 -0
  62. package/types/v1/terrabase-v1.d.ts +140 -6
  63. package/types/v1/transport.d.ts +33 -10
  64. package/types/v1/usage.d.ts +171 -0
  65. package/src/ts-auth-discovery.js +0 -207
  66. package/types/ts-auth-discovery.d.ts +0 -19
@@ -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,72 @@
1
+ /** @typedef {"getShapeManifest" | "exportDatabase" | "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"} ManagementOperation */
2
+ const saved = "/v1/projects/{project}/saved-queries";
3
+ const ttl = "/v1/projects/{project}/databases/{database}/collections/{collection}/ttl";
4
+ /** @type {readonly import("./routes.js").V1Route[]} */
5
+ export const managementV1Routes = Object.freeze([
6
+ route("getShapeManifest", "GET", "/v1/projects/{project}/databases/{database}/collections/{collection}/shape/manifest"),
7
+ route("exportDatabase", "GET", "/v1/projects/{project}/databases/{database}/export"),
8
+ route("putCollectionTtl", "PUT", ttl),
9
+ route("getCollectionTtl", "GET", ttl),
10
+ route("deleteCollectionTtl", "DELETE", ttl),
11
+ route("healthz", "GET", "/healthz"),
12
+ route("readyz", "GET", "/readyz"),
13
+ route("backupz", "GET", "/backupz"),
14
+ route("replicationHealth", "GET", "/health/replication"),
15
+ route("listPlatformDatabases", "GET", "/v1/platform/databases"),
16
+ route("listPlatformCollections", "GET", "/v1/platform/databases/{database}/collections"),
17
+ route("listPlatformDocuments", "GET", "/v1/platform/databases/{database}/collections/{collection}/documents"),
18
+ route("getPlatformDocument", "GET", "/v1/platform/databases/{database}/collections/{collection}/documents/by-key"),
19
+ route("createImport", "POST", "/v1/projects/{project}/databases/{database}/import", 201),
20
+ route("getImport", "GET", "/v1/projects/{project}/databases/{database}/import", 200),
21
+ route("uploadImportSource", "PUT", "/v1/projects/{project}/databases/{database}/import/source", 200),
22
+ route("updateImport", "PATCH", "/v1/projects/{project}/databases/{database}/import", 202),
23
+ route("startImport", "POST", "/v1/projects/{project}/databases/{database}/import/start", 202),
24
+ route("cancelImport", "POST", "/v1/projects/{project}/databases/{database}/import/cancel", 202),
25
+ route("resumeImport", "POST", "/v1/projects/{project}/databases/{database}/import/resume", 202),
26
+ route("createBackup", "POST", "/v1/projects/{project}/databases/{database}/backups", 201),
27
+ route("listBackups", "GET", "/v1/projects/{project}/databases/{database}/backups", 200),
28
+ route("restoreBackup", "POST", "/v1/projects/{project}/databases/{database}/backups/{backup}/restore", 201),
29
+ route("getBackupPolicy", "GET", "/v1/projects/{project}/databases/{database}/backup-policy", 200),
30
+ route("setBackupPolicy", "PUT", "/v1/projects/{project}/databases/{database}/backup-policy", 200),
31
+ route("getRestoreWindow", "GET", "/v1/projects/{project}/databases/{database}/point-in-time", 200),
32
+ route("planPointInTime", "POST", "/v1/projects/{project}/databases/{database}/point-in-time/plan", 200),
33
+ route("restorePointInTime", "POST", "/v1/projects/{project}/databases/{database}/point-in-time/restore", 201),
34
+ route("getBackupDestination", "GET", "/v1/projects/{project}/backup-destination", 200),
35
+ route("setBackupDestination", "PUT", "/v1/projects/{project}/backup-destination", 200),
36
+ route("deleteBackupDestination", "DELETE", "/v1/projects/{project}/backup-destination", 204),
37
+ route("getProjectUsage", "GET", "/v1/projects/{project}/usage"),
38
+ route("getProjectAnalytics", "GET", "/v1/projects/{project}/analytics"),
39
+ route("getProjectReplication", "GET", "/v1/projects/{project}/replication"),
40
+ route("getDatabaseUsage", "GET", "/v1/projects/{project}/databases/{database}/usage"),
41
+ route("getDatabaseAnalytics", "GET", "/v1/projects/{project}/databases/{database}/analytics"),
42
+ route("getDatabaseReplication", "GET", "/v1/projects/{project}/databases/{database}/replication"),
43
+ route("createIndex", "POST", "/v1/projects/{project}/databases/{database}/indexes", 201),
44
+ route("listIndexes", "GET", "/v1/projects/{project}/databases/{database}/indexes", 200),
45
+ route("dropIndex", "DELETE", "/v1/projects/{project}/databases/{database}/indexes/{index}", 204),
46
+ route("getAutoIndexPolicy", "GET", "/v1/projects/{project}/databases/{database}/auto-index/{collection}", 200),
47
+ route("setAutoIndexPolicy", "PUT", "/v1/projects/{project}/databases/{database}/auto-index/{collection}", 200),
48
+ route("listIndexRecommendations", "GET", "/v1/projects/{project}/databases/{database}/index-recommendations", 200),
49
+ route("applyIndexRecommendation", "POST", "/v1/projects/{project}/databases/{database}/index-recommendations/{recommendation}/apply", 201),
50
+ route("getDatabaseSchema", "GET", "/v1/projects/{project}/databases/{database}/schema"),
51
+ route("inferCollectionSchema", "GET", "/v1/projects/{project}/databases/{database}/schema/{collection}"),
52
+ route("declareCollectionSchema", "PUT", "/v1/projects/{project}/databases/{database}/schema/{collection}"),
53
+ route("listQueryShapes", "GET", "/v1/projects/{project}/databases/{database}/query-shapes"),
54
+ route("createSavedQuery", "POST", saved, 201),
55
+ route("listSavedQueries", "GET", saved),
56
+ route("getSavedQuery", "GET", `${saved}/{query}`),
57
+ route("updateSavedQuery", "PUT", `${saved}/{query}`),
58
+ route("deleteSavedQuery", "DELETE", `${saved}/{query}`, 204),
59
+ ]);
60
+
61
+ /** @param {ManagementOperation} operation @param {import("./routes.js").V1Route["method"]} method @param {string} pathTemplate @param {number} [status] @returns {import("./routes.js").V1Route} */
62
+ function route(operation, method, pathTemplate, status = 200) {
63
+ const write = method !== "GET" && operation !== "planPointInTime";
64
+ const noBody = ["createBackup", "applyIndexRecommendation", "cancelImport", "resumeImport"].includes(operation);
65
+ return Object.freeze({ operation, method, pathTemplate,
66
+ permission: operation === "getShapeManifest" || operation === "exportDatabase" || operation === "getCollectionTtl" ? "documents:read" : pathTemplate === ttl ? "documents:write" : pathTemplate.startsWith("/v1/platform/") ? "platform-admin" : !pathTemplate.startsWith("/v1/") ? "anonymous" : write ? "databases:write" : "databases:read",
67
+ additionalPermissions: Object.freeze(["createImport", "uploadImportSource", "updateImport", "startImport", "resumeImport"].includes(operation) ? ["documents:write"] : []),
68
+ requestMediaTypes: Object.freeze(noBody ? [] : operation === "uploadImportSource" ? ["application/octet-stream"] : method === "POST" || method === "PUT" || method === "PATCH" ? ["application/json"] : []),
69
+ 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]),
70
+ idempotencyKey: "none", replaySafe: !write, contract: "docs/terrabase/api.md", contractText: Object.freeze([]),
71
+ });
72
+ }
@@ -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 6cb9745 (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,6 +20,7 @@ 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
26
  { operation: "getDocument", method: "GET", pathTemplate: `${documents}/by-key`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
@@ -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
+ }
@@ -0,0 +1,125 @@
1
+ const MAGIC = "TBS1";
2
+ const HEADER_BYTES = 70;
3
+ const KEY_ID_BYTES = 26;
4
+ const FRAME_BYTES = 1048576;
5
+ const MAX_PLAINTEXT_BYTES = 8388608;
6
+ const TAG_BYTES = 16;
7
+ const LAST_FRAME_FLAG = 0x80000000;
8
+ const LENGTH_MASK = 0x7fffffff;
9
+ const MAX_UINT64 = 2n ** 64n - 1n;
10
+
11
+ /**
12
+ * @typedef {object} ShapeObjectRequest
13
+ * @property {string} handle The shape handle this client requested, e.g. `h1-…`.
14
+ * @property {string} offset Canonical offset of the object's URL, e.g. `2` or `snap.0.0`.
15
+ * @property {string} epoch Decimal epoch the client expects in the header.
16
+ * @property {string} keyId The 26-character project data key id the client expects in the header.
17
+ * @property {Uint8Array} key Raw 32-byte epoch key `K_e`.
18
+ */
19
+
20
+ /**
21
+ * Verifies and decrypts one TBS1 sealed shape object. The plaintext is returned only when the
22
+ * header, every frame tag and the framing rules pass. Any failure throws and yields no bytes.
23
+ *
24
+ * @param {Uint8Array} input Exact TBS1 object bytes.
25
+ * @param {ShapeObjectRequest} request
26
+ * @returns {Promise<Uint8Array>}
27
+ */
28
+ export async function decryptShapeObject(input, { handle, offset, epoch, keyId, key: keyInput }) {
29
+ if (input.byteLength > HEADER_BYTES + 9 * (4 + TAG_BYTES) + MAX_PLAINTEXT_BYTES)
30
+ throw new TypeError("TBS1 object exceeds the maximum sealed size");
31
+ // WebCrypto needs ArrayBuffer-backed views; copying keeps the caller's (possibly shared) buffer out of it.
32
+ const bytes = Uint8Array.from(input);
33
+ const key = Uint8Array.from(keyInput);
34
+ if (key.byteLength !== 32) throw new TypeError("TBS1 epoch key must be 32 bytes");
35
+ if (typeof keyId !== "string" || !/^[\x00-\x7f]{26}$/u.test(keyId))
36
+ throw new TypeError("TBS1 key id must be 26 ASCII characters");
37
+ if (bytes.byteLength < HEADER_BYTES) throw new TypeError("TBS1 header is short");
38
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
39
+ if (new TextDecoder().decode(bytes.subarray(0, 4)) !== MAGIC) throw new TypeError("TBS1 magic mismatch");
40
+ if (view.getBigUint64(4) !== parseEpoch(epoch)) throw new TypeError("TBS1 epoch mismatch");
41
+ if (new TextDecoder().decode(bytes.subarray(12, 12 + KEY_ID_BYTES)) !== keyId) throw new TypeError("TBS1 key id mismatch");
42
+
43
+ const header = bytes.subarray(0, HEADER_BYTES);
44
+ const { subtle } = globalThis.crypto;
45
+ const epochKey = await subtle.importKey("raw", key, "HKDF", false, ["deriveKey"]);
46
+ const objectKey = await subtle.deriveKey(
47
+ {
48
+ name: "HKDF",
49
+ hash: "SHA-256",
50
+ salt: bytes.slice(38, HEADER_BYTES),
51
+ info: new TextEncoder().encode(`TBS1 object\u0000${handle}\u0000${offset}`),
52
+ },
53
+ epochKey,
54
+ { name: "AES-GCM", length: 256 },
55
+ false,
56
+ ["decrypt"],
57
+ );
58
+
59
+ const plaintextParts = [];
60
+ let plaintextTotal = 0;
61
+ let position = HEADER_BYTES;
62
+ for (let index = 0; ; index += 1) {
63
+ if (position + 4 > bytes.byteLength) throw new TypeError("TBS1 object ends before its last frame");
64
+ const lengthWord = view.getUint32(position);
65
+ const last = (lengthWord & LAST_FRAME_FLAG) !== 0;
66
+ const length = lengthWord & LENGTH_MASK;
67
+ if (last ? length >= FRAME_BYTES : length !== FRAME_BYTES) throw new TypeError("TBS1 frame length is invalid");
68
+ plaintextTotal += length;
69
+ if (plaintextTotal > MAX_PLAINTEXT_BYTES) throw new TypeError("TBS1 plaintext exceeds 8 MiB");
70
+ const bodyStart = position + 4;
71
+ const frameEnd = bodyStart + length + TAG_BYTES;
72
+ if (frameEnd > bytes.byteLength) throw new TypeError("TBS1 object is truncated");
73
+ const nonce = concat(new Uint8Array(4), uint64(index));
74
+ const additionalData = concat(header, uint64(index), bytes.subarray(position, bodyStart));
75
+ try {
76
+ const plaintext = await subtle.decrypt(
77
+ { name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 },
78
+ objectKey,
79
+ bytes.subarray(bodyStart, frameEnd),
80
+ );
81
+ plaintextParts.push(new Uint8Array(plaintext));
82
+ } catch {
83
+ throw new TypeError("TBS1 frame failed authentication");
84
+ }
85
+ position = frameEnd;
86
+ if (last) break;
87
+ }
88
+ if (position !== bytes.byteLength) throw new TypeError("TBS1 object has bytes after its last frame");
89
+ return concat(...plaintextParts);
90
+ }
91
+
92
+ /**
93
+ * @param {string} value
94
+ * @returns {bigint}
95
+ */
96
+ function parseEpoch(value) {
97
+ if (!/^(0|[1-9][0-9]*)$/u.test(value)) throw new TypeError("TBS1 epoch must be a canonical decimal");
98
+ const epoch = BigInt(value);
99
+ if (epoch > MAX_UINT64) throw new TypeError("TBS1 epoch exceeds uint64");
100
+ return epoch;
101
+ }
102
+
103
+ /**
104
+ * @param {number} value
105
+ * @returns {Uint8Array}
106
+ */
107
+ function uint64(value) {
108
+ const out = new Uint8Array(8);
109
+ new DataView(out.buffer).setBigUint64(0, BigInt(value));
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * @param {...Uint8Array} parts
115
+ * @returns {Uint8Array<ArrayBuffer>}
116
+ */
117
+ function concat(...parts) {
118
+ const out = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
119
+ let at = 0;
120
+ for (const part of parts) {
121
+ out.set(part, at);
122
+ at += part.byteLength;
123
+ }
124
+ return out;
125
+ }
@@ -0,0 +1,31 @@
1
+ import { requireId } from "../ids.js";
2
+ import { malformed } from "./decode.js";
3
+ import { list, numeric, record, text } from "./management-decode.js";
4
+ import { requireFilter } from "./filter.js";
5
+ import { stringifyExactJson } from "./json.js";
6
+ import { requireShapeHandle, requireShapeOffset } from "./shapes.js";
7
+ /** @import { V1Transport } from "./transport.js" */
8
+ const manifest = record({ handle: requireShapeHandle, objects: list(record({ offset: requireShapeOffset, next_offset: requireShapeOffset,
9
+ binding: text, object: text, url: text, expires_at: text, epoch: text, key_id: requireId, sha256: text, size: numeric })) });
10
+ /** @typedef {ReturnType<typeof manifest>} V1ShapeManifest */
11
+ /** @typedef {{readonly handle: string; readonly offset: string; readonly where?: import("./filter.js").V1Filter}} V1ShapeManifestRequest */
12
+ /** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {V1ShapeManifestRequest} request @param {AbortSignal | undefined} signal @returns {Promise<V1ShapeManifest>} */
13
+ export async function readShapeManifest(transport, params, request, signal) {
14
+ const handle = requireShapeHandle(request.handle);
15
+ const offset = requireShapeOffset(request.offset);
16
+ if (offset === "-1") throw new TypeError("A manifest requires a snapshot or log offset.");
17
+ const query = new URLSearchParams({ handle, offset });
18
+ if (request.where !== undefined) query.set("where", stringifyExactJson(requireFilter(request.where), "shape filter"));
19
+ const response = await transport.call("getShapeManifest", params, { query, cache: "no-store", signal });
20
+ const result = manifest(response.value);
21
+ if (result.handle !== handle || result.objects.length > 8) malformed("shape manifest binding");
22
+ let next = offset;
23
+ for (const item of result.objects) {
24
+ if (item.offset !== next || item.next_offset === item.offset || item.offset === "-1" || item.next_offset === "-1" ||
25
+ !/^[a-f0-9]{64}$/u.test(item.sha256) || !/^(?:0|[1-9][0-9]*)$/u.test(item.epoch)) malformed("shape manifest object");
26
+ const url = new URL(item.url);
27
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "") malformed("shape object URL");
28
+ next = item.next_offset;
29
+ }
30
+ return result;
31
+ }