@terrascale/sdk 0.0.8 → 0.0.9
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 +8 -0
- package/package.json +15 -4
- package/src/codegen-cli.js +17 -0
- package/src/codegen.js +137 -0
- package/types/codegen-cli.d.ts +2 -0
- package/types/codegen.d.ts +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.0.9 — declared JSON Schema models
|
|
4
|
+
|
|
5
|
+
Node tooling at `@terrascale/sdk/codegen` generates document, create and
|
|
6
|
+
merge-patch body types from `collection.getSchema()` or a schema file. Install
|
|
7
|
+
the exact tooling peer `json-schema-to-typescript@16.0.0`; the `terrascale-models`
|
|
8
|
+
command is separate from browser/runtime entrypoints. Unsupported constructs
|
|
9
|
+
produce JSON Pointer diagnostics. Runtime validation remains authoritative.
|
|
10
|
+
|
|
3
11
|
## 0.0.7 — live shape reconnect, browser bootstrap and read replica
|
|
4
12
|
|
|
5
13
|
`subscribeShape` keeps its state and retries the same position after a lost
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrascale/sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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
|
+
}
|
|
@@ -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";
|