@tangentfeed/schema 0.2.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sreeraj T A
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # @tangentfeed/schema
2
+
3
+ A typed schema for tangentfeed: infers TypeScript types from one declaration
4
+ and validates local writes. Zero runtime dependencies.
5
+
6
+ ```ts
7
+ import { openSpace } from "tangentfeed";
8
+ import { s, defineSchema } from "@tangentfeed/schema";
9
+
10
+ const schema = defineSchema({
11
+ tasks: {
12
+ title: s.string(),
13
+ done: s.boolean().default(false),
14
+ priority: s.number().optional(),
15
+ tags: s.array(s.string()).default([]),
16
+ },
17
+ });
18
+
19
+ const db = await openSpace({ space: "kitchen-42", schema });
20
+
21
+ await db.insert("tasks", { title: "Oat milk" }); // done and tags defaulted
22
+ await db.insert("tasks", { titel: "typo" }); // compile error + throws
23
+
24
+ const rows = await db.list("tasks");
25
+ // ^? { id: string; title: string; done: boolean;
26
+ // priority?: number; tags: string[] }[]
27
+ ```
28
+
29
+ ## What it validates
30
+
31
+ Local writes only, in `insert` and `update`, before ops are generated.
32
+ Unknown tables, unknown columns, type mismatches and missing required fields
33
+ throw a `SchemaError` carrying `table`, `column`, `expected` and `received`.
34
+
35
+ **Data arriving from peers is never inspected.** Validation is a local
36
+ precondition — rejected data never becomes an op — so a peer running a
37
+ different schema still syncs with you completely. Filtering reads through a
38
+ local schema would make visible state depend on schema version, which would
39
+ break convergence.
40
+
41
+ That property is enforced by a test: for input where every defaulted column is
42
+ supplied explicitly, the ops emitted through a schema-wrapped space are
43
+ identical to those from an unwrapped one.
44
+
45
+ ## Reads are asserted, not proven
46
+
47
+ `list("tasks")` returns `Task[]` because that is the schema you write through,
48
+ not because anything checked the op log. A peer on an older schema may have
49
+ written a number where you expect a string, and nothing here will catch it.
50
+
51
+ Where that matters, check explicitly:
52
+
53
+ ```ts
54
+ import { parseRow } from "@tangentfeed/schema";
55
+
56
+ const row = await db.get("tasks", id);
57
+ const checked = parseRow(schema.tasks, row);
58
+ if (!checked.ok) console.warn(checked.issues);
59
+ ```
60
+
61
+ `parseRow` returns a result rather than throwing, and reports every issue
62
+ rather than only the first.
63
+
64
+ ## Field types
65
+
66
+ `s.string()`, `s.number()`, `s.boolean()`, `s.array(field)`, `s.object(shape)`,
67
+ `s.enum(...values)` — each with `.optional()`, `.nullable()` and
68
+ `.default(value)`.
69
+
70
+ `.optional()` makes a column omissible on insert and possibly absent on read.
71
+ `.default(v)` makes it omissible on insert but always present on read, because
72
+ the default is written as a real cell. `.nullable()` widens the value, not the
73
+ presence — `Json` allows `null` everywhere, so the DSL makes you say when it is
74
+ meaningful.
75
+
76
+ `s.object(...)` validates its interior but is **one cell**: cell-level LWW
77
+ merges it atomically. Nesting is storage, not structure.
78
+
79
+ `update` never applies defaults — it writes individual cells, and inventing a
80
+ default there would clobber a peer's value.
81
+
82
+ ## React
83
+
84
+ The hooks in `@tangentfeed/react` are generic over the schema, so types carry
85
+ through:
86
+
87
+ ```ts
88
+ const db = useSpace({ space: "kitchen-42", schema });
89
+ const { rows } = useRows(db, "tasks"); // Task[]
90
+ const { insert } = useTable(db, "tasks"); // insert({ title: string, done?: boolean })
91
+ ```
92
+
93
+ ## Not included
94
+
95
+ Migrations and schema versioning, read validation, relations, indexes, and
96
+ bring-your-own-validator interop. Versioning a schema while peers still hold
97
+ rows written under an older one is a hard distributed problem and is
98
+ deliberately out of scope.
@@ -0,0 +1,170 @@
1
+ import { Json } from '@tangentfeed/core';
2
+
3
+ /**
4
+ * Field descriptors.
5
+ *
6
+ * A descriptor is plain data (kind + flags) carrying three phantom type
7
+ * parameters that the inference types in ./types.ts read:
8
+ *
9
+ * Out — the value type when read back
10
+ * InOpt — may the key be omitted on insert? (.optional() and .default())
11
+ * OutOpt — may the key be absent when read? (.optional() only)
12
+ *
13
+ * A defaulted field is optional going in and guaranteed coming out, which is
14
+ * why InOpt and OutOpt are tracked separately rather than as one flag.
15
+ */
16
+
17
+ type FieldKind = "string" | "number" | "boolean" | "array" | "object" | "enum";
18
+ interface FieldInit {
19
+ kind: FieldKind;
20
+ isOptional?: boolean;
21
+ isNullable?: boolean;
22
+ hasDefault?: boolean;
23
+ defaultValue?: Json | undefined;
24
+ element?: AnyField | undefined;
25
+ shape?: Record<string, AnyField> | undefined;
26
+ values?: readonly (string | number)[] | undefined;
27
+ }
28
+ declare class Field<Out = unknown, InOpt extends boolean = false, OutOpt extends boolean = false> {
29
+ readonly kind: FieldKind;
30
+ readonly isOptional: boolean;
31
+ readonly isNullable: boolean;
32
+ readonly hasDefault: boolean;
33
+ readonly defaultValue: Json | undefined;
34
+ readonly element: AnyField | undefined;
35
+ readonly shape: Record<string, AnyField> | undefined;
36
+ readonly values: readonly (string | number)[] | undefined;
37
+ readonly __out?: Out;
38
+ readonly __inOpt?: InOpt;
39
+ readonly __outOpt?: OutOpt;
40
+ constructor(init: FieldInit);
41
+ private clone;
42
+ /** Key may be omitted on insert, and may be absent when read. */
43
+ optional(): Field<Out, true, true>;
44
+ /** Value may be null. Independent of presence. */
45
+ nullable(): Field<Out | null, InOpt, OutOpt>;
46
+ /** Key may be omitted on insert; the default is written, so reads always see it. */
47
+ default(value: Out & Json): Field<Out, true, OutOpt>;
48
+ }
49
+ type AnyField = Field<unknown, boolean, boolean>;
50
+ type TableShape = Record<string, AnyField>;
51
+ type SchemaShape = Record<string, TableShape>;
52
+ declare const s: {
53
+ string: () => Field<string, false, false>;
54
+ number: () => Field<number, false, false>;
55
+ boolean: () => Field<boolean, false, false>;
56
+ array: <E>(element: Field<E, boolean, boolean>) => Field<E[], false, false>;
57
+ /**
58
+ * Validates its interior and infers a nested type, but remains ONE cell:
59
+ * cell-level LWW merges the whole object atomically. Never add field-level
60
+ * merging inside an object.
61
+ */
62
+ object: <S extends TableShape>(shape: S) => Field<{ [K in keyof S]: S[K] extends Field<infer O, boolean, boolean> ? O : never; }, false, false>;
63
+ enum: <const V extends readonly (string | number)[]>(...values: V) => Field<V[number], false, false>;
64
+ };
65
+ /** Identity at runtime; exists to pin the generic so inference has something to read. */
66
+ declare function defineSchema<S extends SchemaShape>(shape: S): S;
67
+
68
+ /**
69
+ * Local write validation.
70
+ *
71
+ * Runs before ops are generated, so rejected data never enters the log. This
72
+ * is what makes the schema layer convergence-safe: it is a local precondition,
73
+ * not a filter on shared state. Remote data is never inspected here.
74
+ */
75
+
76
+ declare class SchemaError extends Error {
77
+ readonly table: string;
78
+ readonly column: string | undefined;
79
+ readonly expected: string;
80
+ readonly received: string;
81
+ constructor(init: {
82
+ table: string;
83
+ column?: string | undefined;
84
+ expected: string;
85
+ received: string;
86
+ message: string;
87
+ });
88
+ }
89
+ /** Full-row validation. Fills defaults and requires every non-optional field. */
90
+ declare function validateInsert(schema: SchemaShape, table: string, values: Record<string, unknown>): Record<string, Json>;
91
+ /**
92
+ * Partial validation. No defaults: update writes individual cells, and
93
+ * materialising a default here would clobber a peer's value with a locally
94
+ * invented one.
95
+ */
96
+ declare function validateUpdate(schema: SchemaShape, table: string, values: Record<string, unknown>): Record<string, Json>;
97
+ interface ParseIssue {
98
+ readonly path: string;
99
+ readonly expected: string;
100
+ readonly received: string;
101
+ }
102
+ type ParseResult<T> = {
103
+ readonly ok: true;
104
+ readonly row: T;
105
+ } | {
106
+ readonly ok: false;
107
+ readonly issues: readonly ParseIssue[];
108
+ };
109
+ /**
110
+ * Opt-in check of a row that has already been read.
111
+ *
112
+ * Reads are otherwise asserted rather than proven: the inferred row type
113
+ * describes the schema you write through, not the contents of the op log. Use
114
+ * this on paths where a peer may have written under a different schema.
115
+ *
116
+ * Collects every issue rather than throwing on the first — the caller is
117
+ * diagnosing foreign data, not fixing their own typo.
118
+ */
119
+ declare function parseRow(shape: TableShape, row: unknown): ParseResult<Record<string, Json> & {
120
+ id: string;
121
+ }>;
122
+
123
+ /**
124
+ * Inference. Types only — this module emits no runtime code.
125
+ *
126
+ * The split that matters: a field carries InOpt (may the key be omitted on
127
+ * insert?) and OutOpt (may the key be absent on read?). `.default()` sets only
128
+ * InOpt, so a defaulted column is optional going in and guaranteed coming out.
129
+ */
130
+
131
+ /** The value type a field reads back as. */
132
+ type OutOf<F> = F extends Field<infer Out, boolean, boolean> ? Out : never;
133
+ type InOptionalKeys<T extends TableShape> = {
134
+ [K in keyof T]: T[K] extends Field<unknown, true, boolean> ? K : never;
135
+ }[keyof T];
136
+ type InRequiredKeys<T extends TableShape> = Exclude<keyof T, InOptionalKeys<T>>;
137
+ type OutOptionalKeys<T extends TableShape> = {
138
+ [K in keyof T]: T[K] extends Field<unknown, boolean, true> ? K : never;
139
+ }[keyof T];
140
+ type OutRequiredKeys<T extends TableShape> = Exclude<keyof T, OutOptionalKeys<T>>;
141
+ /** Flattens an intersection so editor hovers show one object. */
142
+ type Pretty<T> = {
143
+ [K in keyof T]: T[K];
144
+ } & {};
145
+ /** A row as read back, including the engine-assigned id. */
146
+ type RowOf<S extends SchemaShape, T extends keyof S> = Pretty<{
147
+ id: string;
148
+ } & {
149
+ [K in OutRequiredKeys<S[T]>]: OutOf<S[T][K]>;
150
+ } & {
151
+ [K in OutOptionalKeys<S[T]>]?: OutOf<S[T][K]>;
152
+ }>;
153
+ /** What `insert` accepts: defaulted and optional columns may be omitted. */
154
+ type InsertInput<S extends SchemaShape, T extends keyof S> = Pretty<{
155
+ [K in InRequiredKeys<S[T]>]: OutOf<S[T][K]>;
156
+ } & {
157
+ [K in InOptionalKeys<S[T]>]?: OutOf<S[T][K]>;
158
+ }>;
159
+ /** What `update` accepts: any subset of columns, never the id. */
160
+ type UpdateInput<S extends SchemaShape, T extends keyof S> = Pretty<{
161
+ [K in keyof S[T]]?: OutOf<S[T][K]>;
162
+ }>;
163
+ /** Every table name in the schema. */
164
+ type TableName<S extends SchemaShape> = keyof S & string;
165
+ /** The whole database shape, keyed by table. */
166
+ type Infer<S extends SchemaShape> = {
167
+ [T in keyof S]: RowOf<S, T>;
168
+ };
169
+
170
+ export { type AnyField, Field, type FieldKind, type Infer, type InsertInput, type OutOf, type ParseIssue, type ParseResult, type RowOf, SchemaError, type SchemaShape, type TableName, type TableShape, type UpdateInput, defineSchema, parseRow, s, validateInsert, validateUpdate };
package/dist/index.js ADDED
@@ -0,0 +1,246 @@
1
+ // src/builders.ts
2
+ var Field = class _Field {
3
+ kind;
4
+ isOptional;
5
+ isNullable;
6
+ hasDefault;
7
+ defaultValue;
8
+ element;
9
+ shape;
10
+ values;
11
+ constructor(init) {
12
+ this.kind = init.kind;
13
+ this.isOptional = init.isOptional ?? false;
14
+ this.isNullable = init.isNullable ?? false;
15
+ this.hasDefault = init.hasDefault ?? false;
16
+ this.defaultValue = init.defaultValue;
17
+ this.element = init.element;
18
+ this.shape = init.shape;
19
+ this.values = init.values;
20
+ }
21
+ clone(patch) {
22
+ return new _Field({
23
+ kind: this.kind,
24
+ isOptional: this.isOptional,
25
+ isNullable: this.isNullable,
26
+ hasDefault: this.hasDefault,
27
+ defaultValue: this.defaultValue,
28
+ element: this.element,
29
+ shape: this.shape,
30
+ values: this.values,
31
+ ...patch
32
+ });
33
+ }
34
+ /** Key may be omitted on insert, and may be absent when read. */
35
+ optional() {
36
+ return this.clone({ isOptional: true });
37
+ }
38
+ /** Value may be null. Independent of presence. */
39
+ nullable() {
40
+ return this.clone({ isNullable: true });
41
+ }
42
+ /** Key may be omitted on insert; the default is written, so reads always see it. */
43
+ default(value) {
44
+ return this.clone({
45
+ hasDefault: true,
46
+ defaultValue: value
47
+ });
48
+ }
49
+ };
50
+ var s = {
51
+ string: () => new Field({ kind: "string" }),
52
+ number: () => new Field({ kind: "number" }),
53
+ boolean: () => new Field({ kind: "boolean" }),
54
+ array: (element) => new Field({ kind: "array", element }),
55
+ /**
56
+ * Validates its interior and infers a nested type, but remains ONE cell:
57
+ * cell-level LWW merges the whole object atomically. Never add field-level
58
+ * merging inside an object.
59
+ */
60
+ object: (shape) => new Field({
61
+ kind: "object",
62
+ shape
63
+ }),
64
+ enum: (...values) => new Field({ kind: "enum", values })
65
+ };
66
+ function defineSchema(shape) {
67
+ return shape;
68
+ }
69
+
70
+ // src/validate.ts
71
+ var SchemaError = class extends Error {
72
+ table;
73
+ column;
74
+ expected;
75
+ received;
76
+ constructor(init) {
77
+ super(init.message);
78
+ this.name = "SchemaError";
79
+ this.table = init.table;
80
+ this.column = init.column;
81
+ this.expected = init.expected;
82
+ this.received = init.received;
83
+ }
84
+ };
85
+ function describe(value) {
86
+ if (value === null) return "null";
87
+ if (Array.isArray(value)) return "array";
88
+ return typeof value;
89
+ }
90
+ function checkValue(field, value, path) {
91
+ if (value === null) {
92
+ return field.isNullable ? null : { path, expected: field.kind };
93
+ }
94
+ switch (field.kind) {
95
+ case "string":
96
+ case "number":
97
+ case "boolean": {
98
+ if (typeof value !== field.kind) return { path, expected: field.kind };
99
+ if (field.kind === "number" && !Number.isFinite(value)) {
100
+ return { path, expected: "finite number" };
101
+ }
102
+ return null;
103
+ }
104
+ case "enum": {
105
+ const allowed = field.values ?? [];
106
+ return allowed.includes(value) ? null : { path, expected: `one of ${allowed.map((v) => JSON.stringify(v)).join(", ")}` };
107
+ }
108
+ case "array": {
109
+ if (!Array.isArray(value)) return { path, expected: "array" };
110
+ const element = field.element;
111
+ if (!element) return null;
112
+ for (let i = 0; i < value.length; i++) {
113
+ const bad = checkValue(element, value[i], `${path}[${i}]`);
114
+ if (bad) return bad;
115
+ }
116
+ return null;
117
+ }
118
+ case "object": {
119
+ if (typeof value !== "object" || Array.isArray(value)) return { path, expected: "object" };
120
+ const shape = field.shape;
121
+ if (!shape) return null;
122
+ const record = value;
123
+ for (const key of Object.keys(record)) {
124
+ if (!(key in shape)) return { path: `${path}.${key}`, expected: "no such key" };
125
+ }
126
+ for (const [key, sub] of Object.entries(shape)) {
127
+ if (!(key in record)) {
128
+ if (sub.isOptional) continue;
129
+ return { path: `${path}.${key}`, expected: `${sub.kind} (missing)` };
130
+ }
131
+ const bad = checkValue(sub, record[key], `${path}.${key}`);
132
+ if (bad) return bad;
133
+ }
134
+ return null;
135
+ }
136
+ }
137
+ }
138
+ function tableShape(schema, table) {
139
+ const shape = schema[table];
140
+ if (!shape) {
141
+ throw new SchemaError({
142
+ table,
143
+ expected: `one of ${Object.keys(schema).join(", ")}`,
144
+ received: table,
145
+ message: `unknown table "${table}"`
146
+ });
147
+ }
148
+ return shape;
149
+ }
150
+ function checkColumns(shape, table, values) {
151
+ const out = {};
152
+ for (const [column, value] of Object.entries(values)) {
153
+ const field = shape[column];
154
+ if (!field) {
155
+ throw new SchemaError({
156
+ table,
157
+ column,
158
+ expected: `one of ${Object.keys(shape).join(", ")}`,
159
+ received: column,
160
+ message: `unknown column "${column}" on table "${table}"`
161
+ });
162
+ }
163
+ const bad = checkValue(field, value, column);
164
+ if (bad) {
165
+ throw new SchemaError({
166
+ table,
167
+ column,
168
+ expected: bad.expected,
169
+ received: describe(value),
170
+ message: `${table}.${bad.path}: expected ${bad.expected}, received ${describe(value)}`
171
+ });
172
+ }
173
+ out[column] = value;
174
+ }
175
+ return out;
176
+ }
177
+ function validateInsert(schema, table, values) {
178
+ const shape = tableShape(schema, table);
179
+ const out = checkColumns(shape, table, values);
180
+ for (const [column, field] of Object.entries(shape)) {
181
+ if (column in out) continue;
182
+ if (field.hasDefault) {
183
+ out[column] = field.defaultValue;
184
+ continue;
185
+ }
186
+ if (field.isOptional) continue;
187
+ throw new SchemaError({
188
+ table,
189
+ column,
190
+ expected: field.kind,
191
+ received: "undefined",
192
+ message: `missing required column "${column}" on table "${table}"`
193
+ });
194
+ }
195
+ return out;
196
+ }
197
+ function validateUpdate(schema, table, values) {
198
+ const shape = tableShape(schema, table);
199
+ if (Object.keys(values).length === 0) {
200
+ throw new SchemaError({
201
+ table,
202
+ expected: "at least one column",
203
+ received: "{}",
204
+ message: `no columns to update on table "${table}"`
205
+ });
206
+ }
207
+ return checkColumns(shape, table, values);
208
+ }
209
+ function parseRow(shape, row) {
210
+ if (row === null || typeof row !== "object" || Array.isArray(row)) {
211
+ return {
212
+ ok: false,
213
+ issues: [{ path: "", expected: "object", received: describe(row) }]
214
+ };
215
+ }
216
+ const record = row;
217
+ const issues = [];
218
+ for (const key of Object.keys(record)) {
219
+ if (key === "id") continue;
220
+ if (!(key in shape)) {
221
+ issues.push({ path: key, expected: "no such column", received: describe(record[key]) });
222
+ }
223
+ }
224
+ for (const [column, field] of Object.entries(shape)) {
225
+ if (!(column in record)) {
226
+ if (!field.isOptional) {
227
+ issues.push({ path: column, expected: field.kind, received: "undefined" });
228
+ }
229
+ continue;
230
+ }
231
+ const bad = checkValue(field, record[column], column);
232
+ if (bad) {
233
+ issues.push({ path: bad.path, expected: bad.expected, received: describe(record[column]) });
234
+ }
235
+ }
236
+ return issues.length === 0 ? { ok: true, row: record } : { ok: false, issues };
237
+ }
238
+ export {
239
+ Field,
240
+ SchemaError,
241
+ defineSchema,
242
+ parseRow,
243
+ s,
244
+ validateInsert,
245
+ validateUpdate
246
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@tangentfeed/schema",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "scripts": {
7
+ "test": "vitest run",
8
+ "build": "tsup src/index.ts --format esm --dts --clean",
9
+ "prepack": "npm run build"
10
+ },
11
+ "devDependencies": {
12
+ "@tangentfeed/core": "0.2.0",
13
+ "@types/node": "^20.0.0",
14
+ "typescript": "^5.5.0",
15
+ "vitest": "^2.0.0",
16
+ "tsup": "^8.5.0"
17
+ },
18
+ "description": "Typed schema layer for tangentfeed: inference plus local write validation",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/sreerajta/tangentfeed.git",
23
+ "directory": "packages/schema"
24
+ },
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20"
39
+ }
40
+ }