@terrascale/sdk 0.0.8 → 0.0.10

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.10 — production contract fixes
4
+
5
+ The named hosted-auth token operation accepts explicit project API-key grants
6
+ without a resource parameter. Public health probes accept omitted private build
7
+ metadata. Hosted-auth operations return authentication denials without replaying
8
+ requests with a changed credential. README qualification commands follow the
9
+ binary-free HTTP test policy and document the existing TypeScript model tooling.
10
+
11
+ ## 0.0.9 — declared JSON Schema models
12
+
13
+ Node tooling at `@terrascale/sdk/codegen` generates document, create and
14
+ merge-patch body types from `collection.getSchema()` or a schema file. Install
15
+ the exact tooling peer `json-schema-to-typescript@16.0.0`; the `terrascale-models`
16
+ command is separate from browser/runtime entrypoints. Unsupported constructs
17
+ produce JSON Pointer diagnostics. Runtime validation remains authoritative.
18
+
3
19
  ## 0.0.7 — live shape reconnect, browser bootstrap and read replica
4
20
 
5
21
  `subscribeShape` keeps its state and retries the same position after a lost
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @terrascale/sdk
2
2
 
3
3
  The JavaScript/JSDoc SDK for the project-scoped C#/.NET TerraBase server
4
- (`TerraScale/terrascale`). Current source compatibility is pinned at
4
+ (`TerraScale/terrascale`). The historical source compatibility audit records
5
5
  `e30ffdc287530551b161b0130220be53d3c0475e`; see the
6
6
  [compatibility audit](docs/api-compatibility-20261013.md) for exact evidence and open gates.
7
7
  The package targets Node.js 22 and 24 and current Chrome, Firefox, Safari, and Edge.
@@ -100,8 +100,8 @@ to TS-507; this release requires an explicit URL or caller-supplied resolver.
100
100
 
101
101
  Current handler source implements project-scoped database names and point
102
102
  addresses as `/documents/by-key?id=…`. The complete method/path/scope inventory
103
- is in [sdk-v1-route-manifest.json](sdk-v1-route-manifest.json), with 25 current
104
- source-backed routes and one retained historical shape route. Current generated
103
+ is in [sdk-v1-route-manifest.json](sdk-v1-route-manifest.json), including the
104
+ current public inventory and a retained historical shape route. Current generated
105
105
  OpenAPI and immutable release qualification remain open gates.
106
106
 
107
107
  | Surface | Public methods |
@@ -257,9 +257,9 @@ for await (const row of db.export({ after: last, signal: controller.signal })) {
257
257
  }
258
258
  ```
259
259
 
260
- The exported collection `shape()` remains compatible with the historical
261
- `6cb9745…` release. Its route is absent from the audited current server source;
262
- do not use it as current producer evidence. SSE changes are a separate database
260
+ The exported collection `shape()` reads the current free shape protocol using
261
+ `documents:read`. A retained historical `shapes:read` route descriptor does not
262
+ qualify that current protocol. SSE changes are a separate database
263
263
  feed, not authorized bounded-query snapshots or the planned reactive WebSocket API.
264
264
 
265
265
  ## Free shape subscriptions (protocol v1)
@@ -354,12 +354,13 @@ in either runtime.
354
354
  This implements the [proposed protocol](contracts/shape-v1/shape-reads.md) at
355
355
  `993927ce7ecf8f9b889d24d4d1d21ec96eaf9416`, using `documents:read`. HTTP fakes and the
356
356
  upstream [conformance vectors](test/fixtures/shape-vectors.json) verify client
357
- behavior; they do not qualify a producer deployment. Current generated OpenAPI,
358
- live endpoint availability, and automatic region discovery remain unqualified.
359
- No TerraBase binary is downloaded or run by these tests. The existing historical
360
- `shape()` method keeps its contract. Manifest/object delivery and 302 redirects
361
- are deferred to FR-22; vector `presigned-manifest` is explicitly skipped until its
362
- delivery contract is ready upstream.
357
+ behavior; they do not qualify a producer deployment. Isolated production checks
358
+ on 2026-10-26 verified shape bootstrap, continuation, manifest and epoch-key reads,
359
+ live subscription updates, and checkpoint restore. Full current generated
360
+ OpenAPI and semantic-corpus parity, sealed-object delivery/decryption, and
361
+ automatic region discovery remain open qualification gates. These collection
362
+ reads do not implement the planned bounded-query WebSocket result protocol.
363
+ No TerraBase binary is downloaded or run by SDK tests.
363
364
 
364
365
  Failures throw `TerraBaseV1Error` with status, code, optional transaction `op`,
365
366
  parsed `body`, raw `responseText`, response `headers`, and a received
@@ -376,44 +377,22 @@ failing in this draft. See the audit for exact results and producer dependencies
376
377
  ## Ouroboros and qualification
377
378
 
378
379
  Ouroboros means TerraScale services store their data in the same production
379
- TerraBase that customers use. SDK boundary tests must run the real C# TerraBase
380
- release binary, pinned by immutable version and SHA-256, with an isolated
381
- allocation. A fixture, mocked transport, SQLite SDK runtime, frozen Rust
382
- contract archive or typed build cannot establish producer compatibility.
383
-
384
- The v1 route manifest and producer contract evidence must agree with the C#
385
- release under test. Keep live tests environment-gated; report missing local
386
- configuration as skipped, never as successful integration evidence. Required
387
- release qualification must supply the verified binary and real producer inputs.
388
- Never commit keys, cookies, environment files or real tenant data.
389
-
390
- The immutable release pin is
391
- [`scripts/ci/terrabase-release.json`](scripts/ci/terrabase-release.json): C#
392
- TerraBase 0.1.0, release `ci-6cb97456b9b52a92a61856601cc025c4f2833518-run-16729`,
393
- with binary SHA-256
394
- `cc2db35b18c385c6d58036c6c94e59460b0e12b0d5616da2222713efbe666700`.
395
- This official prerelease contains the qualified TS-754 ULID producer, the renamed
396
- `TerraBase` assembly and `src/TerraBase` sources. Its asset name remains `terrabase-linux-amd64`.
397
- The SDK integration harness consumes its official asset read-only when the
398
- version, source revision, size and SHA-256 are verified.
399
- The acquisition script verifies the official release, revision, asset size and
400
- digest. Private downloads require `FORGEJO_TOKEN`. Missing or mismatched official
401
- assets block qualification rather than selecting older bytes.
402
-
403
- ```sh
404
- nice -n 19 npm run provision:terrabase
405
- export TERRABASE_BINARY=/absolute/path/printed/by/the/provisioner
406
- nice -n 19 npm run test:integration
407
- ```
408
-
409
- The integration runner rechecks the binary digest, starts an isolated TLS server,
410
- verifies its version and runtime OpenAPI digest, and provisions a disposable
411
- organization, project, database, collections and scoped keys. It supplies the
412
- `TB_V1_*` environment and trusted local certificate to the real-server tests and
413
- cleans up the process and allocation afterwards. No TLS validation is disabled.
414
- Direct live test configuration uses `TB_V1_URL`, `TB_V1_PROJECT`,
415
- `TB_V1_DATA_KEY` and optionally `TB_V1_DATABASE` (default `app`);
416
- `TB_V1_REQUIRED=1` makes absent integration inputs fail rather than skip.
380
+ TerraBase that customers use. Qualification requires an authorized, isolated
381
+ real-service allocation, independently verified producer release provenance,
382
+ and current generated-contract and route-manifest parity. HTTP fakes, local
383
+ SQLite, historical contract inputs and typed builds cannot establish deployed
384
+ producer compatibility.
385
+
386
+ This SDK repository does not download or launch a TerraBase binary. The
387
+ historical [release pin](scripts/ci/terrabase-release.json) does not qualify the
388
+ current producer or JWT authorization. Keep real HTTP tests environment-gated;
389
+ missing live configuration is skipped, never successful integration evidence.
390
+
391
+ Existing live tests consume `TB_V1_URL`, `TB_V1_PROJECT`, `TB_V1_DATA_KEY` and
392
+ optionally `TB_V1_DATABASE` (default `app`) from an independently provisioned
393
+ allocation. Denial checks use `TB_V1_READ_KEY` and `TB_V1_WRITE_KEY`. Supply
394
+ credentials privately; never commit keys, cookies, environment files or real
395
+ tenant data. Local checks below do not substitute for this live gate.
417
396
 
418
397
  Local checks run at low CPU priority:
419
398
 
@@ -429,6 +408,24 @@ nice -n 19 npm test
429
408
  without adding a linter dependency. CI runs these commands at normal priority.
430
409
  See [CHANGELOG.md](CHANGELOG.md) for the 0.0.1 migration and breaking changes.
431
410
 
411
+ ## TypeScript document models
412
+
413
+ Node-only `@terrascale/sdk/codegen` generates body aliases from declared JSON
414
+ Schema, using `generateDocumentModels(schema, "Order")` or the authorized
415
+ `generateCollectionModels(collection, "Order", { signal })` operation.
416
+ Install its exact tooling peer separately:
417
+
418
+ ```sh
419
+ npm install --save-dev json-schema-to-typescript@16.0.0
420
+ npx terrascale-models schema.json Order models.ts
421
+ ```
422
+
423
+ The input is the schema itself, without the API response wrapper. Output has
424
+ `OrderDocument`, `OrderCreate` and `OrderPatch`; keep codegen out of browser
425
+ bundles. Generated aliases do not validate or automatically narrow SDK reads.
426
+ See [TypeScript document models](https://docs.terrascale.tech/guides/schema-validation#generate-typescript-document-models)
427
+ for the supported subset, merge-patch null semantics and strict compiler setup.
428
+
432
429
  ## Separate and deprecated entrypoints
433
430
 
434
431
  `@terrascale/sdk/ts-auth` retains its separate hosted identity management and OIDC
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terrascale/sdk",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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",
@@ -70,10 +70,16 @@
70
70
  "workerd": null,
71
71
  "browser": null,
72
72
  "node": "./src/postgres.js"
73
+ },
74
+ "./codegen": {
75
+ "types": "./types/codegen.d.ts",
76
+ "browser": null,
77
+ "node": "./src/codegen.js"
73
78
  }
74
79
  },
75
80
  "bin": {
76
- "terrascale-local": "./src/local/cli.js"
81
+ "terrascale-local": "./src/local/cli.js",
82
+ "terrascale-models": "./src/codegen-cli.js"
77
83
  },
78
84
  "files": [
79
85
  "src",
@@ -109,7 +115,8 @@
109
115
  "@better-auth/core": "1.7.2",
110
116
  "react": ">=18.2.0 <20.0.0",
111
117
  "pg": "8.23.0",
112
- "@tanstack/db": "0.9.2"
118
+ "@tanstack/db": "0.9.2",
119
+ "json-schema-to-typescript": "16.0.0"
113
120
  },
114
121
  "peerDependenciesMeta": {
115
122
  "@better-auth/core": {
@@ -123,6 +130,9 @@
123
130
  },
124
131
  "@tanstack/db": {
125
132
  "optional": true
133
+ },
134
+ "json-schema-to-typescript": {
135
+ "optional": true
126
136
  }
127
137
  },
128
138
  "devDependencies": {
@@ -140,7 +150,8 @@
140
150
  "@types/react": "19.2.14",
141
151
  "@types/react-test-renderer": "19.1.0",
142
152
  "pg": "8.23.0",
143
- "@types/pg": "8.23.1"
153
+ "@types/pg": "8.23.1",
154
+ "json-schema-to-typescript": "16.0.0"
144
155
  },
145
156
  "engines": {
146
157
  "node": "22.x || 24.x",
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { generateDocumentModels } from "./codegen.js";
4
+ import { parseExactJson } from "./v1/json.js";
5
+ const [source, name, output, ...extra] = process.argv.slice(2);
6
+ if (!source || !name || !output || extra.length) {
7
+ process.stderr.write("Usage: terrascale-models declared-schema.json ModelName models.ts\nInput is the schema itself; use @terrascale/sdk/codegen to fetch a live declaration.\n");
8
+ process.exitCode = 1;
9
+ } else {
10
+ try {
11
+ const generated = await generateDocumentModels(parseExactJson(await readFile(source, "utf8")), name);
12
+ await writeFile(output, generated, "utf8");
13
+ } catch (error) {
14
+ process.stderr.write(`${error instanceof Error ? error.message : "Model generation failed"}\n`);
15
+ process.exitCode = 1;
16
+ }
17
+ }
package/src/codegen.js ADDED
@@ -0,0 +1,137 @@
1
+ // Node-only tooling. Never imported by the browser SDK entrypoint.
2
+ import { compile } from "json-schema-to-typescript";
3
+ import { isJsonObject, ExactDecimal } from "./v1/json.js";
4
+ /** @import { V1Collection } from "./v1/terrabase-v1.js" */
5
+ /** @typedef {Record<string, unknown>} Schema */
6
+ const dialect = "https://json-schema.org/draft/2020-12/schema";
7
+ const supported = new Set(["$schema", "type", "properties", "required", "additionalProperties", "items", "enum", "const", "readOnly", "title", "description", "default", "examples", "format", "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf", "minLength", "maxLength", "minItems", "maxItems", "uniqueItems", "minProperties", "maxProperties"]);
8
+ /** @param {string} pointer @param {string} message @returns {never} */
9
+ function diagnostic(pointer, message) { throw new TypeError(`Collection model ${pointer || "/"}: ${message}`); }
10
+ /** @param {string} pointer @param {string} key */
11
+ function at(pointer, key) { return `${pointer}/${key.replaceAll("~", "~0").replaceAll("/", "~1")}`; }
12
+
13
+ /**
14
+ * Generate body models from a declared Draft 2020-12 schema, never inferred samples.
15
+ * Runtime validation remains authoritative; constraints such as ranges and lengths
16
+ * cannot be expressed by TypeScript. Patch is an RFC 7396 merge-patch body.
17
+ * @param {unknown} input
18
+ * @param {string} name A TypeScript identifier prefix.
19
+ * @returns {Promise<string>}
20
+ */
21
+ export async function generateDocumentModels(input, name) {
22
+ if (!/^[A-Z][A-Za-z0-9]*$/u.test(name)) throw new TypeError("Model name must start with A-Z and contain only letters and digits.");
23
+ // Also reject cyclic objects, lossy numbers, and excessive input before compilation.
24
+ let values = 0;
25
+ /** @param {unknown} value @param {number} depth */
26
+ function bounded(value, depth) {
27
+ if (++values > 4093 || depth > 15) diagnostic("", "schema exceeds 4093 values or 15 nesting levels.");
28
+ if (value instanceof ExactDecimal || (typeof value === "number" && (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))))) diagnostic("", "exact numeric literals outside JavaScript's safe range are unsupported.");
29
+ if (Array.isArray(value) || isJsonObject(value)) for (const child of Object.values(value)) bounded(child, depth + 1);
30
+ }
31
+ bounded(input, 0);
32
+ if (Buffer.byteLength(JSON.stringify(input) ?? "") > 65536) diagnostic("", "schema exceeds 64 KiB.");
33
+ if (!isJsonObject(input) || input.type !== "object") diagnostic("/type", "document root must declare type: object.");
34
+ const models = [];
35
+ for (const mode of /** @type {const} */ (["Document", "Create", "Patch"])) {
36
+ const schema = convert(input, "", mode);
37
+ const identifier = `${name}${mode}`;
38
+ const output = await compile(/** @type {import("json-schema-to-typescript").JSONSchema} */ (schema), identifier, {
39
+ bannerComment: "", format: false, unknownAny: true, ignoreMinAndMaxItems: true,
40
+ $refOptions: { resolve: { file: false, http: false } },
41
+ });
42
+ // Object aliases remain assignable to the SDK's JSON object input type.
43
+ models.push(output.replace(`export interface ${identifier} {`, `export type ${identifier} = {`).trim());
44
+ }
45
+ return `// Generated by @terrascale/sdk/codegen (json-schema-to-typescript 16.0.0). Do not edit.\n// Bodies only; id/version are server envelope fields. Runtime validation is authoritative.\n\n${models.join("\n\n")}\n`;
46
+ }
47
+
48
+ /** @param {unknown} input @param {string} pointer @param {"Document" | "Create" | "Patch"} mode @returns {Schema | boolean} */
49
+ function convert(input, pointer, mode) {
50
+ if (input === false) return false;
51
+ const jsonType = `import("@terrascale/sdk").${mode === "Document" ? "V1JsonValue" : "V1JsonInput"}`;
52
+ if (input === true) return { tsType: jsonType };
53
+ if (!isJsonObject(input)) diagnostic(pointer, "expected a schema object or boolean.");
54
+ for (const key of Object.keys(input).sort()) if (!supported.has(key)) diagnostic(at(pointer, key), `unsupported keyword ${key}; inline references or simplify the schema to the documented subset.`);
55
+ for (const key of ["minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties"]) if (input[key] !== undefined && (typeof input[key] !== "number" || !Number.isSafeInteger(input[key]) || input[key] < 0)) diagnostic(at(pointer, key), "expected a nonnegative safe integer.");
56
+ for (const key of ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"]) if (input[key] !== undefined && (typeof input[key] !== "number" || (key === "multipleOf" && input[key] <= 0))) diagnostic(at(pointer, key), "expected a number (positive for multipleOf).");
57
+ for (const key of ["title", "description", "format"]) if (input[key] !== undefined && typeof input[key] !== "string") diagnostic(at(pointer, key), "expected a string annotation.");
58
+ if (input.examples !== undefined && !Array.isArray(input.examples)) diagnostic(at(pointer, "examples"), "expected an array.");
59
+ if (input.uniqueItems !== undefined && typeof input.uniqueItems !== "boolean") diagnostic(at(pointer, "uniqueItems"), "expected a boolean.");
60
+ if (input.$schema !== undefined && input.$schema !== dialect) diagnostic(at(pointer, "$schema"), `only ${dialect} is supported.`);
61
+ const types = Array.isArray(input.type) ? input.type : [input.type];
62
+ const kinds = types.filter(type => type !== "null");
63
+ if (new Set(types).size !== types.length || kinds.length > 1 || types.length === 0 || types.some(type => !["object", "array", "string", "number", "integer", "boolean", "null"].includes(String(type)))) diagnostic(at(pointer, "type"), "declare one type, optionally unioned with null.");
64
+ const kind = kinds[0];
65
+ const nullable = types.includes("null");
66
+ for (const key of ["properties", "required", "additionalProperties"]) if (Object.hasOwn(input, key) && kind !== "object") diagnostic(at(pointer, key), "this keyword is supported only with an object type.");
67
+ if (Object.hasOwn(input, "items") && kind !== "array") diagnostic(at(pointer, "items"), "items is supported only with an array type.");
68
+ if (input.readOnly !== undefined && typeof input.readOnly !== "boolean") diagnostic(at(pointer, "readOnly"), "expected a boolean annotation.");
69
+ /** @type {Schema} */
70
+ const result = { type: input.type };
71
+ if (typeof input.description === "string") result.description = input.description;
72
+ for (const key of ["enum", "const"]) {
73
+ if (!Object.hasOwn(input, key)) continue;
74
+ if (kind === "object" || kind === "array") diagnostic(at(pointer, key), "container literal constraints are unsupported; use type: null for a null-only value.");
75
+ const literals = key === "enum" ? input[key] : [input[key]];
76
+ if (!Array.isArray(literals) || literals.length === 0 || literals.some(value => value !== null && !["string", "number", "boolean"].includes(typeof value))) diagnostic(at(pointer, key), "only nonempty scalar literals are supported.");
77
+ if (literals.some(value => !types.some(type => type === "null" ? value === null : type === "integer" ? typeof value === "number" && Number.isInteger(value) : typeof value === type))) diagnostic(at(pointer, key), "literal/type intersections are unsupported; each literal must satisfy the declared type.");
78
+ result[key] = input[key];
79
+ }
80
+ if (Array.isArray(input.enum) && Object.hasOwn(input, "const") && !input.enum.includes(input.const ?? null)) diagnostic(at(pointer, "const"), "const must belong to enum.");
81
+ if ((kind === "number" || kind === "integer") && result.enum === undefined && result.const === undefined) result.tsType = `number | import("@terrascale/sdk").ExactDecimal${mode === "Document" ? "" : " | bigint"}${nullable ? " | null" : ""}`;
82
+ if (kind === "array") {
83
+ if (input.items === undefined) diagnostic(at(pointer, "items"), "declare the array item schema (or true for arbitrary JSON).");
84
+ result.items = convert(input.items, at(pointer, "items"), mode === "Patch" ? "Create" : mode);
85
+ }
86
+ if (kind === "object") {
87
+ const properties = input.properties === undefined ? {} : input.properties;
88
+ if (!isJsonObject(properties)) diagnostic(at(pointer, "properties"), "expected a property map.");
89
+ const required = input.required === undefined ? [] : input.required;
90
+ if (!Array.isArray(required) || required.some(key => typeof key !== "string" || !Object.hasOwn(properties, key))) diagnostic(at(pointer, "required"), "required must list declared property names.");
91
+ if (new Set(required).size !== required.length) diagnostic(at(pointer, "required"), "required names must be unique.");
92
+ const entries = Object.keys(properties).sort().map(key => {
93
+ const property = properties[key];
94
+ const location = at(at(pointer, "properties"), key);
95
+ // Validate even omitted read-only fields, so unsupported constructs never hide.
96
+ let converted = convert(property, location, mode);
97
+ if (mode !== "Document" && isJsonObject(property) && property.readOnly === true) converted = false;
98
+ else if (mode === "Patch") {
99
+ // null deletes in RFC 7396, including when the stored field permits null.
100
+ if (!required.includes(key)) converted = { anyOf: [converted, { type: "null" }] };
101
+ else if (property === true) converted = { tsType: `Exclude<${jsonType}, null>` };
102
+ else if (isJsonObject(property)) {
103
+ /** @type {Schema & {type: unknown[]}} */
104
+ const nonnull = { ...property, type: (Array.isArray(property.type) ? property.type : [property.type]).filter(t => t !== "null") };
105
+ if (Array.isArray(nonnull.enum)) nonnull.enum = nonnull.enum.filter(value => value !== null);
106
+ converted = nonnull.type.length === 0 || nonnull.const === null || (Array.isArray(nonnull.enum) && nonnull.enum.length === 0) ? false : convert(nonnull, location, mode);
107
+ }
108
+ }
109
+ return [key, converted];
110
+ });
111
+ for (const key of required) if (typeof key === "string" && isJsonObject(properties[key]) && properties[key].readOnly === true) diagnostic(at(at(pointer, "properties"), key), "required readOnly fields cannot be populated by the producer; default is not server generation. Make this field writable or optional.");
112
+ result.properties = Object.fromEntries(entries);
113
+ result.required = mode === "Patch" ? [] : required.filter(key => typeof key === "string" && (mode === "Document" || !isJsonObject(properties[key]) || properties[key].readOnly !== true));
114
+ const additional = input.additionalProperties;
115
+ if (additional === undefined || additional === true) {
116
+ // Separate the JSON index signature so the generator cannot widen it with
117
+ // undefined for optional properties (which SDK JSON inputs prohibit).
118
+ result.additionalProperties = false;
119
+ result.type = "object";
120
+ const object = { allOf: [result, { tsType: `Record<string, ${jsonType}>` }] };
121
+ return nullable ? { anyOf: [object, { type: "null" }] } : object;
122
+ }
123
+ if (additional !== false && Object.keys(properties).length) diagnostic(at(pointer, "additionalProperties"), "typed additional properties with named properties cannot be represented exactly; use a pure map or a closed object.");
124
+ let extra = convert(additional, at(pointer, "additionalProperties"), mode);
125
+ if (additional !== false && mode === "Patch") extra = { anyOf: [extra, { type: "null" }] };
126
+ result.additionalProperties = extra;
127
+ if (additional === false && entries.length === 0) return { tsType: `Record<string, never>${nullable ? " | null" : ""}` };
128
+ }
129
+ return result;
130
+ }
131
+
132
+ /** Fetch the authorized declared schema using the public SDK operation. @param {V1Collection} collection @param {string} name @param {import("./v1/terrabase-v1.js").V1RequestOptions} [options] */
133
+ export async function generateCollectionModels(collection, name, options = {}) {
134
+ const { schema } = await collection.getSchema(options);
135
+ if (schema === null) diagnostic("", "collection has no declared JSON Schema; attach one with collection.putSchema(). Inference is not a declaration.");
136
+ return generateDocumentModels(schema, name);
137
+ }
package/src/http.js CHANGED
@@ -75,9 +75,6 @@ export class TsAuthHttp {
75
75
  ? JSON.stringify(options.body)
76
76
  : options.formBody?.toString();
77
77
  let attempt = 0;
78
- let refreshed = false;
79
- /** @type {string | undefined} */
80
- let refreshedToken;
81
78
  while (true) {
82
79
  signal?.throwIfAborted();
83
80
  let responseReceived = false;
@@ -87,11 +84,10 @@ export class TsAuthHttp {
87
84
  const oidc = options.routeName.startsWith("tsauth.oidc.");
88
85
  const token = oidc
89
86
  ? undefined
90
- : (refreshedToken ??
91
- (await withCancellation(
87
+ : await withCancellation(
92
88
  resolveAccessToken(this.#accessToken),
93
89
  signal,
94
- )));
90
+ );
95
91
  if (token !== undefined && token.length > 0)
96
92
  headers.set(
97
93
  "authorization",
@@ -133,19 +129,31 @@ export class TsAuthHttp {
133
129
  ? "application/json"
134
130
  : "application/x-www-form-urlencoded",
135
131
  );
132
+ /** @type {RequestInit & { duplex?: "half" }} */
133
+ const init = {
134
+ method: options.method,
135
+ headers,
136
+ redirect: "manual",
137
+ ...(signal === undefined ? {} : { signal }),
138
+ ...(body === undefined ? {} : { body }),
139
+ };
140
+ if (options.method !== "GET" && typeof process !== "undefined" && process.release?.name === "node") {
141
+ // Native Node fetch replays buffered mutations on 421, even with SDK retries disabled.
142
+ // Empty DELETEs also need a one-shot body. Keep JSON/form bytes and media types unchanged.
143
+ const bytes = body === undefined ? undefined : new TextEncoder().encode(body);
144
+ init.body = new ReadableStream({ start(controller) {
145
+ if (bytes !== undefined && bytes.byteLength > 0) controller.enqueue(bytes);
146
+ controller.close();
147
+ } });
148
+ init.duplex = "half";
149
+ }
136
150
  const request = new Request(
137
151
  buildUrl(
138
152
  this.config.apiBaseUrl,
139
153
  options.path,
140
154
  options.query,
141
155
  ),
142
- {
143
- method: options.method,
144
- headers,
145
- redirect: "manual",
146
- ...(signal === undefined ? {} : { signal }),
147
- ...(body === undefined ? {} : { body }),
148
- },
156
+ init,
149
157
  );
150
158
  signal?.throwIfAborted();
151
159
  fetchStarted = true;
@@ -206,22 +214,6 @@ export class TsAuthHttp {
206
214
  /** @type {TResponse} */ (parsed.value),
207
215
  );
208
216
  }
209
- if (
210
- response.status === 401 &&
211
- !oidc &&
212
- !refreshed &&
213
- typeof this.#accessToken === "function"
214
- ) {
215
- const next = await withCancellation(
216
- resolveAccessToken(this.#accessToken),
217
- signal,
218
- );
219
- if (next !== undefined && next.length > 0 && next !== token) {
220
- refreshed = true;
221
- refreshedToken = next;
222
- continue;
223
- }
224
- }
225
217
  if (
226
218
  options.method === "GET" &&
227
219
  attempt < this.config.maxRetryAttempts &&
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createTerraBaseV1Client as createTerraBaseClient, TerraBaseV1Client as TerraBaseClient } from "./v1/terrabase-v1.js";
2
2
  export { terraScaleReleaseBlockers, TerraScaleCapabilityUnavailableError } from "./management-contracts.js";
3
- // Project-scoped TerraBase API; shape support is retained for the historical release.
3
+ // Project-scoped TerraBase API, including current free collection shape reads.
4
4
  export {
5
5
  createTerraBaseV1Client,
6
6
  TerraBaseV1Client,
package/src/ts-auth.js CHANGED
@@ -2086,6 +2086,13 @@ export class TsAuthInstanceClient {
2086
2086
  * readonly grant_type: "client_credentials";
2087
2087
  * readonly scope: string;
2088
2088
  * readonly resource: string;
2089
+ * readonly project?: never;
2090
+ * }
2091
+ * | {
2092
+ * readonly grant_type: "client_credentials";
2093
+ * readonly project: import("./ids.js").EntityId;
2094
+ * readonly scope?: string | undefined;
2095
+ * readonly resource?: never;
2089
2096
  * }
2090
2097
  * | {
2091
2098
  * readonly grant_type: "urn:ietf:params:oauth:grant-type:device_code";
@@ -2245,6 +2252,8 @@ export class TsAuthOidcClient {
2245
2252
 
2246
2253
  /**
2247
2254
  * client_secret_post only. Omitted resource retains the stored code/device/refresh binding.
2255
+ * Platform API keys use explicit project and optional subset scope, without resource
2256
+ * (producer PlatformGrants.ApiKeyAsync and platform-api.openapi.yaml ClientCredentialsGrant).
2248
2257
  * @param {string} clientId
2249
2258
  * @param {string} clientSecret
2250
2259
  * @param {TsAuthOidcTokenRequest} request
@@ -2259,7 +2268,7 @@ export class TsAuthOidcClient {
2259
2268
  : request.grant_type === "refresh_token"
2260
2269
  ? ["grant_type", "refresh_token", "scope", "resource"]
2261
2270
  : request.grant_type === "client_credentials"
2262
- ? ["grant_type", "scope", "resource"]
2271
+ ? ["grant_type", "scope", "project" in request ? "project" : "resource"]
2263
2272
  : request.grant_type ===
2264
2273
  "urn:ietf:params:oauth:grant-type:device_code"
2265
2274
  ? ["grant_type", "device_code", "resource"]
@@ -2289,13 +2298,24 @@ export class TsAuthOidcClient {
2289
2298
  if (request.scope !== undefined)
2290
2299
  form.set("scope", requireOauthScope(request.scope));
2291
2300
  } else if (request.grant_type === "client_credentials") {
2292
- form.set("resource", requireResourceAudience(request.resource));
2293
- form.set("scope", requireOauthScope(request.scope));
2301
+ if ("project" in request) {
2302
+ form.set("project", requireId(request.project, "project"));
2303
+ if (request.scope !== undefined) form.set("scope", requireOauthScope(request.scope));
2304
+ } else {
2305
+ form.set("resource", requireResourceAudience(request.resource));
2306
+ form.set("scope", requireOauthScope(request.scope));
2307
+ }
2294
2308
  } else {
2295
2309
  form.set("device_code", requireText(request.device_code, "device_code"));
2296
2310
  }
2311
+ const projectScope = request.grant_type === "client_credentials" && "project" in request ? request.scope : undefined;
2312
+ const pending = this.#postForm(`${this.#slug}/oauth/token`, "tsauth.oidc.token", form);
2297
2313
  return oauthResponse(
2298
- this.#postForm(`${this.#slug}/oauth/token`, "tsauth.oidc.token", form),
2314
+ // TokenBody follows RFC 6749 §5.1: omitted scope equals the explicit requested scope.
2315
+ // Snapshot it before dispatch; never infer a default or normalize other grant contracts.
2316
+ projectScope === undefined ? pending : pending.then(result =>
2317
+ result.ok && oauthRecord(result.value) && !Object.hasOwn(result.value, "scope")
2318
+ ? { ...result, value: { ...result.value, scope: projectScope } } : result),
2299
2319
  /** @returns {value is TsAuthOidcTokenResponse} */
2300
2320
  (value) => isTokenResponse(value, grantType),
2301
2321
  );
package/src/v1/system.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { decodeDocument } from "./decode.js";
2
2
  import { requireId } from "../ids.js";
3
3
  import { requireDocumentId, requireName } from "./ids.js";
4
- import { flag, list, nullable, numeric, record, text } from "./management-decode.js";
4
+ import { flag, list, nullable, numeric, optional, record, text } from "./management-decode.js";
5
5
  import { members } from "./management-validation.js";
6
6
  import { decodeCollection, decodeQueryPage } from "./terrabase-v1.js";
7
7
  import { V1Transport } from "./transport.js";
8
8
  /** @import { V1RequestOptions } from "./terrabase-v1.js" */
9
- const status = record({ status: text, version: text, region: text, storageFormat: text, revision: nullable(text), machine: text });
9
+ // Public probes omit build metadata; only the internal listener emits it.
10
+ const status = record({ status: text, version: text, region: text,
11
+ storageFormat: optional(nullable(text)), revision: optional(nullable(text)), machine: optional(nullable(text)) });
10
12
  const replication = record({ region: text, status: text, maxLagSeconds: nullable(numeric),
11
13
  peers: list(record({ region: text, status: text, lagSeconds: nullable(numeric) })) });
12
14
  const databases = record({ items: list(record({ name: text, id: requireId, homeRegion: text, replicaRegions: list(text), allRegions: flag })) });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Generate body models from a declared Draft 2020-12 schema, never inferred samples.
3
+ * Runtime validation remains authoritative; constraints such as ranges and lengths
4
+ * cannot be expressed by TypeScript. Patch is an RFC 7396 merge-patch body.
5
+ * @param {unknown} input
6
+ * @param {string} name A TypeScript identifier prefix.
7
+ * @returns {Promise<string>}
8
+ */
9
+ export function generateDocumentModels(input: unknown, name: string): Promise<string>;
10
+ /** Fetch the authorized declared schema using the public SDK operation. @param {V1Collection} collection @param {string} name @param {import("./v1/terrabase-v1.js").V1RequestOptions} [options] */
11
+ export function generateCollectionModels(collection: V1Collection, name: string, options?: import("./v1/terrabase-v1.js").V1RequestOptions): Promise<string>;
12
+ export type Schema = Record<string, unknown>;
13
+ import type { V1Collection } from "./v1/terrabase-v1.js";
@@ -1103,6 +1103,13 @@ export class TsAuthInstanceClient {
1103
1103
  * readonly grant_type: "client_credentials";
1104
1104
  * readonly scope: string;
1105
1105
  * readonly resource: string;
1106
+ * readonly project?: never;
1107
+ * }
1108
+ * | {
1109
+ * readonly grant_type: "client_credentials";
1110
+ * readonly project: import("./ids.js").EntityId;
1111
+ * readonly scope?: string | undefined;
1112
+ * readonly resource?: never;
1106
1113
  * }
1107
1114
  * | {
1108
1115
  * readonly grant_type: "urn:ietf:params:oauth:grant-type:device_code";
@@ -1206,6 +1213,8 @@ export class TsAuthOidcClient {
1206
1213
  deviceAuthorization(clientId: string, clientSecret: string, request?: TsAuthOidcDeviceAuthorizationRequest): Promise<TerraScaleResult<TsAuthOidcDeviceAuthorizationResponse>>;
1207
1214
  /**
1208
1215
  * client_secret_post only. Omitted resource retains the stored code/device/refresh binding.
1216
+ * Platform API keys use explicit project and optional subset scope, without resource
1217
+ * (producer PlatformGrants.ApiKeyAsync and platform-api.openapi.yaml ClientCredentialsGrant).
1209
1218
  * @param {string} clientId
1210
1219
  * @param {string} clientSecret
1211
1220
  * @param {TsAuthOidcTokenRequest} request
@@ -1766,6 +1775,12 @@ export type TsAuthOidcTokenRequest = {
1766
1775
  readonly grant_type: "client_credentials";
1767
1776
  readonly scope: string;
1768
1777
  readonly resource: string;
1778
+ readonly project?: never;
1779
+ } | {
1780
+ readonly grant_type: "client_credentials";
1781
+ readonly project: import("./ids.js").EntityId;
1782
+ readonly scope?: string | undefined;
1783
+ readonly resource?: never;
1769
1784
  } | {
1770
1785
  readonly grant_type: "urn:ietf:params:oauth:grant-type:device_code";
1771
1786
  readonly device_code: string;
@@ -59,9 +59,9 @@ declare const status: import("./management-decode.js").Decoder<Readonly<{
59
59
  status: string;
60
60
  version: string;
61
61
  region: string;
62
- storageFormat: string;
63
- revision: string | null;
64
- machine: string;
62
+ storageFormat: string | null | undefined;
63
+ revision: string | null | undefined;
64
+ machine: string | null | undefined;
65
65
  }>>;
66
66
  declare const replication: import("./management-decode.js").Decoder<Readonly<{
67
67
  region: string;