@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 +21 -0
- package/README.md +98 -0
- package/dist/index.d.ts +170 -0
- package/dist/index.js +246 -0
- package/package.json +40 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|