@estra/schema 0.0.0-stage → 0.1.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 Realm 8
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 CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # @estra/schema
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The schema for an [Estra](https://estra.cloud) backend: `defineSchema`, `collection` and the `f` field
4
+ builders, the query and write types shared with the clients, and the `estra migrate` CLI.
5
+
6
+ ```sh
7
+ npm install @estra/schema
8
+ ```
9
+
10
+ ## Define collections
11
+
12
+ ```ts
13
+ import { collection, defineSchema, f } from "@estra/schema";
14
+
15
+ export const schema = defineSchema({
16
+ posts: collection({
17
+ fields: {
18
+ title: f.string({ required: true }),
19
+ authorId: f.string(),
20
+ published: f.bool(),
21
+ score: f.int(),
22
+ at: f.timestamp(),
23
+ tags: f.array("string", { indexed: true }), // array-contains uses an index
24
+ loc: f.geo(), // near() radius queries
25
+ meta: f.json(), // any JSON, not queryable
26
+ stats: f.map({ views: f.int(), likes: f.int() }), // "stats.views" in queries and updates
27
+ },
28
+ indexes: [["authorId", ["at", "desc"]], ["published", "at"]],
29
+ sub: { comments: collection({ fields: { body: f.string(), by: f.string() } }) },
30
+ }),
31
+ });
32
+ ```
33
+
34
+ Each collection is a table; `f.map` leaves become their own columns, and subcollections are keyed by
35
+ their parent ids. Fields are typed, so queries on fields the schema does not know are rejected.
36
+
37
+ ## Migrations
38
+
39
+ ```sh
40
+ npx estra migrate --schema schema.ts --out migrations
41
+ ```
42
+
43
+ Diffs the schema against the last snapshot in `migrations/` and writes the next numbered SQL file.
44
+ Destructive changes need `--allow-destructive`. Loading `schema.ts` directly needs Node 22.18 or later
45
+ (type stripping); older Nodes can load a compiled `schema.js`.
46
+
47
+ ## Types
48
+
49
+ `Query`, `Write`, `DocOut`, `Filter`, `Order` and the sentinels (`{ $op: "inc" | "union" | "remove" |
50
+ "now" | "delete" }`) describe the wire format that `@estra/client`, `@estra/admin-node` and the server
51
+ share.
52
+
53
+ Docs: https://github.com/Realm-8/estra/tree/main/docs. MIT licensed.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ // estra migrate --schema <file.ts> --out <migrations dir> [--name <label>] [--allow-destructive]
11
+ // Diffs the schema against <out>/schema.snapshot.json and writes the next numbered SQL file.
12
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
13
+ import { resolve, join } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+ import { diff, pluginDiff, snapshot } from "./sql.js";
16
+ const args = process.argv.slice(2);
17
+ const opt = (k) => {
18
+ const i = args.indexOf(`--${k}`);
19
+ return i >= 0 ? args[i + 1] : undefined;
20
+ };
21
+ if (args[0] !== "migrate" || !opt("schema") || !opt("out")) {
22
+ console.error("usage: estra migrate --schema <file.ts> --out <dir> [--name <label>] [--allow-destructive]");
23
+ process.exit(2);
24
+ }
25
+ const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(resolve(opt("schema"))).href)));
26
+ const schema = mod.schema ?? mod.default;
27
+ if (!schema?.tables)
28
+ throw new Error("the schema file must export `schema` (or default) from defineSchema()");
29
+ const out = resolve(opt("out"));
30
+ mkdirSync(out, { recursive: true });
31
+ const snapFile = join(out, "schema.snapshot.json");
32
+ const prev = existsSync(snapFile) ? JSON.parse(readFileSync(snapFile, "utf8")) : null;
33
+ const plugins = mod.plugins ?? [];
34
+ const next = snapshot(schema, plugins);
35
+ const d = diff(prev, next, { allowDestructive: args.includes("--allow-destructive") });
36
+ d.statements.push(...pluginDiff(prev, plugins));
37
+ if (d.destructive.length && !args.includes("--allow-destructive")) {
38
+ console.error("destructive changes need --allow-destructive (or a hand-written migration):\n " + d.destructive.join("\n "));
39
+ process.exit(1);
40
+ }
41
+ if (!d.statements.length) {
42
+ console.log("schema unchanged");
43
+ process.exit(0);
44
+ }
45
+ const n = readdirSync(out).filter((f) => /^\d{4}_.*\.sql$/.test(f)).length + 1;
46
+ const file = join(out, `${String(n).padStart(4, "0")}_${(opt("name") ?? (prev ? "change" : "init")).replace(/[^a-z0-9_]/gi, "_")}.sql`);
47
+ writeFileSync(file, `-- Generated by estra migrate. Edit the schema, not this file.\n${d.statements.join(";\n")};\n`);
48
+ writeFileSync(snapFile, JSON.stringify(next, null, 2) + "\n");
49
+ console.log(`wrote ${file}`);
@@ -0,0 +1,41 @@
1
+ export type ScalarKind = "string" | "int" | "float" | "bool" | "timestamp";
2
+ export type FieldKind = ScalarKind | "json" | "ref" | "array" | "geo" | "map";
3
+ export interface FieldDef {
4
+ kind: FieldKind;
5
+ /** Array element type ("json" = anything). */
6
+ of?: ScalarKind | "json";
7
+ /** Arrays only: keep a side table so `array-contains` / `array-contains-any` use an index. */
8
+ indexed?: boolean;
9
+ /** Must be present when a document is created with a full `set`. */
10
+ required?: boolean;
11
+ /** Maps only: the sub-fields (each leaf is its own column, "parent.child"). */
12
+ fields?: Record<string, FieldDef>;
13
+ }
14
+ type Opts = {
15
+ required?: boolean;
16
+ };
17
+ export declare const f: {
18
+ string: (o?: Opts) => FieldDef;
19
+ int: (o?: Opts) => FieldDef;
20
+ float: (o?: Opts) => FieldDef;
21
+ bool: (o?: Opts) => FieldDef;
22
+ /** Milliseconds since the epoch on disk; `{ $t: ms }` on the wire. */
23
+ timestamp: (o?: Opts) => FieldDef;
24
+ /** Any JSON value, stored as text. Not queryable. */
25
+ json: (o?: Opts) => FieldDef;
26
+ /** A document path, e.g. "users/abc". */
27
+ ref: (o?: Opts) => FieldDef;
28
+ array: (of?: ScalarKind | "json", o?: Opts & {
29
+ indexed?: boolean;
30
+ }) => FieldDef;
31
+ /** A point: `<name>__lat` and `<name>__lng` columns, queried with `near`. */
32
+ geo: (o?: Opts) => FieldDef;
33
+ /**
34
+ * A typed sub-object. Each leaf is a real column ("stats.plays"), so dotted paths work in
35
+ * where/orderBy/indexes/rules and in updates ("stats.plays": inc(1)). Writing the whole map
36
+ * replaces it (unlisted sub-fields become null), as in Firestore.
37
+ */
38
+ map: (fields: Record<string, FieldDef>, o?: Opts) => FieldDef;
39
+ };
40
+ export declare const SQL_TYPE: Record<FieldKind, string>;
41
+ export {};
package/dist/fields.js ADDED
@@ -0,0 +1,40 @@
1
+ // Field types. Every field is one SQL column (geo is two), so queries run on real indexes.
2
+ // Columns are nullable: a missing field and a null field read the same (see docs/decisions/001).
3
+ const def = (kind, o = {}) => ({ kind, ...(o.required ? { required: true } : {}) });
4
+ export const f = {
5
+ string: (o) => def("string", o),
6
+ int: (o) => def("int", o),
7
+ float: (o) => def("float", o),
8
+ bool: (o) => def("bool", o),
9
+ /** Milliseconds since the epoch on disk; `{ $t: ms }` on the wire. */
10
+ timestamp: (o) => def("timestamp", o),
11
+ /** Any JSON value, stored as text. Not queryable. */
12
+ json: (o) => def("json", o),
13
+ /** A document path, e.g. "users/abc". */
14
+ ref: (o) => def("ref", o),
15
+ array: (of = "json", o = {}) => {
16
+ if (o.indexed && of === "json")
17
+ throw new Error("only arrays of scalars can be indexed");
18
+ return { ...def("array", o), of, ...(o.indexed ? { indexed: true } : {}) };
19
+ },
20
+ /** A point: `<name>__lat` and `<name>__lng` columns, queried with `near`. */
21
+ geo: (o) => def("geo", o),
22
+ /**
23
+ * A typed sub-object. Each leaf is a real column ("stats.plays"), so dotted paths work in
24
+ * where/orderBy/indexes/rules and in updates ("stats.plays": inc(1)). Writing the whole map
25
+ * replaces it (unlisted sub-fields become null), as in Firestore.
26
+ */
27
+ map: (fields, o) => ({ ...def("map", o), fields }),
28
+ };
29
+ export const SQL_TYPE = {
30
+ string: "TEXT",
31
+ int: "INTEGER",
32
+ float: "REAL",
33
+ bool: "INTEGER",
34
+ timestamp: "INTEGER",
35
+ json: "TEXT",
36
+ ref: "TEXT",
37
+ array: "TEXT",
38
+ geo: "REAL",
39
+ map: "TEXT", // never a column itself: its leaves are
40
+ };
@@ -0,0 +1,5 @@
1
+ export * from "./fields.js";
2
+ export * from "./schema.js";
3
+ export * from "./sql.js";
4
+ export * from "./query.js";
5
+ export * from "./local.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./fields.js";
2
+ export * from "./schema.js";
3
+ export * from "./sql.js";
4
+ export * from "./query.js";
5
+ export * from "./local.js";
@@ -0,0 +1,14 @@
1
+ import { type DocOut, type Filter, type Query, type Write } from "./query.js";
2
+ /** The value at a dotted path ("stats.plays"), or undefined. */
3
+ export declare function getPath(o: Record<string, unknown>, path: string): unknown;
4
+ /** A doc's data after a write, or null after a delete. `now` stands in for serverTimestamp. Keys may be dotted. */
5
+ export declare function applyWrite(before: Record<string, unknown> | null, w: Write, now: number): Record<string, unknown> | null;
6
+ export declare function matchesFilter(d: DocOut, f: Filter | undefined): boolean;
7
+ /** Is this document in the query's collection (path or group)? */
8
+ export declare function inCollection(d: DocOut, q: Query): boolean;
9
+ /** Compares two docs in the query's result order (orderBy, then id). */
10
+ export declare function orderComparator(query: Query): (a: DocOut, b: DocOut) => number;
11
+ /** Could this doc be in the query's results, ignoring limit/limitToLast/offset? */
12
+ export declare function matchesQuery(d: DocOut, q: Query): boolean;
13
+ /** Runs a query over documents in memory, with Firestore/Estra semantics. */
14
+ export declare function runLocal(docs: Iterable<DocOut>, query: Query): DocOut[];
package/dist/local.js ADDED
@@ -0,0 +1,186 @@
1
+ // In-memory evaluation over wire-format documents: apply a write (sentinels included) and run a
2
+ // query (filters, order, cursors, limit, near). Clients use it for the offline cache and latency
3
+ // compensation; the server's realtime sessions use it to skip re-reads a change can't affect.
4
+ // Mirrors the SQL engine's semantics (packages/server/src/compile.ts): keep them in step.
5
+ import { withImplicitOrder } from "./query.js";
6
+ const isObj = (x) => typeof x === "object" && x !== null && !Array.isArray(x);
7
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
8
+ const isSentinel = (v) => isObj(v) && typeof v.$op === "string";
9
+ /** The value at a dotted path ("stats.plays"), or undefined. */
10
+ export function getPath(o, path) {
11
+ let cur = o;
12
+ for (const k of path.split("."))
13
+ cur = isObj(cur) ? cur[k] : undefined;
14
+ return cur;
15
+ }
16
+ function setPath(o, path, v) {
17
+ const segs = path.split(".");
18
+ let cur = o;
19
+ for (const k of segs.slice(0, -1)) {
20
+ if (!isObj(cur[k]))
21
+ cur[k] = {};
22
+ cur = cur[k];
23
+ }
24
+ const last = segs[segs.length - 1];
25
+ if (v === undefined || v === null)
26
+ delete cur[last];
27
+ else
28
+ cur[last] = v;
29
+ }
30
+ /** A doc's data after a write, or null after a delete. `now` stands in for serverTimestamp. Keys may be dotted. */
31
+ export function applyWrite(before, w, now) {
32
+ if (w.op === "delete")
33
+ return null;
34
+ if (w.op === "check")
35
+ return before;
36
+ const out = w.op === "set" && !w.merge ? {} : structuredClone(before ?? {});
37
+ for (const [k, v] of Object.entries(w.data)) {
38
+ if (!isSentinel(v)) {
39
+ setPath(out, k, v);
40
+ continue;
41
+ }
42
+ const cur = getPath(out, k);
43
+ if (v.$op === "delete")
44
+ setPath(out, k, undefined);
45
+ else if (v.$op === "now")
46
+ setPath(out, k, { $t: now });
47
+ else if (v.$op === "inc")
48
+ setPath(out, k, (typeof cur === "number" ? cur : 0) + (v.by ?? 0));
49
+ else if (v.$op === "union") {
50
+ const arr = Array.isArray(cur) ? [...cur] : [];
51
+ for (const e of v.values ?? [])
52
+ if (!arr.some((x) => same(x, e)))
53
+ arr.push(e);
54
+ setPath(out, k, arr);
55
+ }
56
+ else if (v.$op === "remove")
57
+ setPath(out, k, (Array.isArray(cur) ? cur : []).filter((x) => !(v.values ?? []).some((r) => same(x, r))));
58
+ }
59
+ return out;
60
+ }
61
+ /** Comparable form of a wire value (timestamps → ms, refs → path). */
62
+ const key = (v) => (isObj(v) && typeof v.$t === "number" ? v.$t : isObj(v) && typeof v.$ref === "string" ? v.$ref : typeof v === "boolean" ? (v ? 1 : 0) : v);
63
+ function cmp(a, b) {
64
+ const x = key(a), y = key(b);
65
+ if (typeof x === "number" && typeof y === "number")
66
+ return x - y;
67
+ if (typeof x === "string" && typeof y === "string")
68
+ return x < y ? -1 : x > y ? 1 : 0;
69
+ return null;
70
+ }
71
+ const fieldOf = (d, f) => (f === "id" || f === "__name__" ? d.id : getPath(d.data, f));
72
+ export function matchesFilter(d, f) {
73
+ if (!f)
74
+ return true;
75
+ if ("and" in f)
76
+ return f.and.every((x) => matchesFilter(d, x));
77
+ if ("or" in f)
78
+ return f.or.some((x) => matchesFilter(d, x));
79
+ const v = fieldOf(d, f.field);
80
+ const missing = v === undefined || v === null;
81
+ const eq = (a, b) => same(key(a), key(b));
82
+ switch (f.op) {
83
+ case "==":
84
+ return f.value === null ? missing : !missing && eq(v, f.value);
85
+ case "!=":
86
+ return f.value === null ? !missing : !missing && !eq(v, f.value);
87
+ case "<":
88
+ case "<=":
89
+ case ">":
90
+ case ">=": {
91
+ if (missing)
92
+ return false;
93
+ const c = cmp(v, f.value);
94
+ if (c === null)
95
+ return false;
96
+ return f.op === "<" ? c < 0 : f.op === "<=" ? c <= 0 : f.op === ">" ? c > 0 : c >= 0;
97
+ }
98
+ case "in":
99
+ return !missing && Array.isArray(f.value) && f.value.some((x) => eq(v, x));
100
+ case "not-in":
101
+ return !missing && Array.isArray(f.value) && !f.value.some((x) => eq(v, x));
102
+ case "array-contains":
103
+ return Array.isArray(v) && v.some((x) => eq(x, f.value));
104
+ case "array-contains-any":
105
+ return Array.isArray(v) && Array.isArray(f.value) && v.some((x) => f.value.some((y) => eq(x, y)));
106
+ }
107
+ return false;
108
+ }
109
+ const haversineMi = (a, b, c, d) => {
110
+ const r = (x) => (x * Math.PI) / 180;
111
+ const h = Math.sin(r(c - a) / 2) ** 2 + Math.cos(r(a)) * Math.cos(r(c)) * Math.sin(r(d - b) / 2) ** 2;
112
+ return 3958.8 * 2 * Math.asin(Math.min(1, Math.sqrt(h)));
113
+ };
114
+ /** Is this document in the query's collection (path or group)? */
115
+ export function inCollection(d, q) {
116
+ const segs = d.path.split("/");
117
+ if (q.group !== undefined)
118
+ return segs[segs.length - 2] === q.group;
119
+ return segs.slice(0, -1).join("/") === q.from;
120
+ }
121
+ const orderKeys = (q) => {
122
+ const orders = q.orderBy ?? [];
123
+ return orders.some((o) => o.field === "id" || o.field === "__name__") ? orders : [...orders, { field: "id", dir: orders.at(-1)?.dir ?? "asc" }];
124
+ };
125
+ /** Compares two docs in the query's result order (orderBy, then id). */
126
+ export function orderComparator(query) {
127
+ const keys = orderKeys(withImplicitOrder(query));
128
+ return (a, b) => {
129
+ for (const k of keys) {
130
+ const c = cmp(fieldOf(a, k.field), fieldOf(b, k.field)) ?? 0;
131
+ if (c)
132
+ return k.dir === "desc" ? -c : c;
133
+ }
134
+ return 0;
135
+ };
136
+ }
137
+ /** Could this doc be in the query's results, ignoring limit/limitToLast/offset? */
138
+ export function matchesQuery(d, q) {
139
+ return runLocal([d], { ...q, limit: undefined, limitToLast: undefined, offset: undefined }).length === 1;
140
+ }
141
+ /** Runs a query over documents in memory, with Firestore/Estra semantics. */
142
+ export function runLocal(docs, query) {
143
+ const q = withImplicitOrder(query);
144
+ let out = [...docs].filter((d) => inCollection(d, q) && matchesFilter(d, q.where));
145
+ const orders = q.orderBy ?? [];
146
+ for (const o of orders)
147
+ if (o.field !== "id" && o.field !== "__name__")
148
+ out = out.filter((d) => getPath(d.data, o.field) !== undefined && getPath(d.data, o.field) !== null);
149
+ if (q.near) {
150
+ const n = q.near;
151
+ out = out.flatMap((d) => {
152
+ const g = getPath(d.data, n.field);
153
+ if (!g?.$geo)
154
+ return [];
155
+ const dist = haversineMi(n.lat, n.lng, g.$geo[0], g.$geo[1]);
156
+ return dist <= n.radiusMi ? [{ ...d, distanceMi: Math.round(dist * 100) / 100 }] : [];
157
+ });
158
+ }
159
+ const keys = orderKeys(q);
160
+ const sortKey = orderComparator(q);
161
+ if (q.near && !orders.length)
162
+ out.sort((a, b) => (a.distanceMi ?? 0) - (b.distanceMi ?? 0));
163
+ else
164
+ out.sort(sortKey);
165
+ // Where a doc sits against a cursor in result order: <0 before it, 0 on it, >0 past it.
166
+ const against = (d, cursor) => {
167
+ for (let i = 0; i < cursor.length; i++) {
168
+ const k = keys[i];
169
+ const c = cmp(fieldOf(d, k.field), cursor[i]) ?? 0;
170
+ if (c)
171
+ return k.dir === "desc" ? -c : c;
172
+ }
173
+ return 0;
174
+ };
175
+ const start = q.startAfter ?? q.startAt;
176
+ if (start)
177
+ out = out.filter((d) => (q.startAt ? against(d, start) >= 0 : against(d, start) > 0));
178
+ const end = q.endBefore ?? q.endAt;
179
+ if (end)
180
+ out = out.filter((d) => (q.endAt ? against(d, end) <= 0 : against(d, end) < 0));
181
+ if (q.offset)
182
+ out = out.slice(q.offset);
183
+ if (q.limitToLast !== undefined)
184
+ return out.slice(Math.max(0, out.length - q.limitToLast));
185
+ return q.limit !== undefined ? out.slice(0, q.limit) : out;
186
+ }
@@ -0,0 +1,132 @@
1
+ export type Op = "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" | "not-in" | "array-contains" | "array-contains-any";
2
+ export interface FieldFilter {
3
+ field: string;
4
+ op: Op;
5
+ value: unknown;
6
+ }
7
+ export type Filter = FieldFilter | {
8
+ and: Filter[];
9
+ } | {
10
+ or: Filter[];
11
+ };
12
+ export interface Order {
13
+ field: string;
14
+ dir?: "asc" | "desc";
15
+ }
16
+ export interface Near {
17
+ field: string;
18
+ lat: number;
19
+ lng: number;
20
+ radiusMi: number;
21
+ }
22
+ export interface Query {
23
+ /** Collection path ("posts", "posts/abc/comments"), or `group` for every parent at once. */
24
+ from?: string;
25
+ /** Collection id for a collection-group query ("comments"). */
26
+ group?: string;
27
+ where?: Filter;
28
+ orderBy?: Order[];
29
+ limit?: number;
30
+ /** The last N results in orderBy order (needs an orderBy; not with limit). */
31
+ limitToLast?: number;
32
+ /** Skip this many results first (with limit, not limitToLast). Service key only (clients page with cursors); at most 10,000. */
33
+ offset?: number;
34
+ /** Cursor values in orderBy order, optionally followed by the document id. */
35
+ startAfter?: unknown[];
36
+ startAt?: unknown[];
37
+ /** Upper cursors, same shape as the start ones. */
38
+ endAt?: unknown[];
39
+ endBefore?: unknown[];
40
+ /** Points within radiusMi of (lat, lng). Results carry `distanceMi`. */
41
+ near?: Near;
42
+ aggregate?: {
43
+ count?: boolean;
44
+ sum?: string[];
45
+ avg?: string[];
46
+ };
47
+ }
48
+ /** Field value sentinels (the Firestore FieldValue equivalents). */
49
+ export type Sentinel = {
50
+ $op: "inc";
51
+ by: number;
52
+ } | {
53
+ $op: "union";
54
+ values: unknown[];
55
+ } | {
56
+ $op: "remove";
57
+ values: unknown[];
58
+ } | {
59
+ $op: "now";
60
+ } | {
61
+ $op: "delete";
62
+ };
63
+ export interface Precondition {
64
+ exists?: boolean;
65
+ /** Row version (`v` of a read); 0 = must not exist. */
66
+ version?: number;
67
+ }
68
+ export type Write = {
69
+ op: "set";
70
+ path: string;
71
+ data: Record<string, unknown>;
72
+ merge?: boolean;
73
+ pre?: Precondition;
74
+ } | {
75
+ op: "update";
76
+ path: string;
77
+ data: Record<string, unknown>;
78
+ pre?: Precondition;
79
+ } | {
80
+ op: "delete";
81
+ path: string;
82
+ pre?: Precondition;
83
+ }
84
+ /** Writes nothing: only asserts `pre` (transactions guard the documents they read but don't write). */
85
+ | {
86
+ op: "check";
87
+ path: string;
88
+ pre: Precondition;
89
+ };
90
+ export interface CommitRequest {
91
+ writes: Write[];
92
+ /** Idempotency key: a commit replayed with the same key is applied once. */
93
+ opKey?: string;
94
+ }
95
+ export interface DocOut {
96
+ path: string;
97
+ id: string;
98
+ v: number;
99
+ createTime: number;
100
+ updateTime: number;
101
+ data: Record<string, unknown>;
102
+ distanceMi?: number;
103
+ /** Set by the client's offline cache: where this copy came from. */
104
+ meta?: {
105
+ fromCache: boolean;
106
+ hasPendingWrites: boolean;
107
+ };
108
+ }
109
+ export declare const inc: (by?: number) => Sentinel;
110
+ export declare const arrayUnion: (...values: unknown[]) => Sentinel;
111
+ export declare const arrayRemove: (...values: unknown[]) => Sentinel;
112
+ export declare const serverTimestamp: () => Sentinel;
113
+ export declare const deleteField: () => Sentinel;
114
+ /** Tagged wire values: timestamps, refs and points. */
115
+ export declare const ts: (ms: number) => {
116
+ $t: number;
117
+ };
118
+ export declare const ref: (path: string) => {
119
+ $ref: string;
120
+ };
121
+ export declare const geo: (lat: number, lng: number) => {
122
+ $geo: [number, number];
123
+ };
124
+ /**
125
+ * Firestore's implicit order: a query with inequality filters and no orderBy is ordered by the
126
+ * inequality fields (ascending, by field name), then by id. Everything that orders results (SQL,
127
+ * the gateway's paging, the in-memory engine) goes through this, so a `limit` keeps the same rows.
128
+ * https://firebase.google.com/docs/firestore/query-data/order-limit-data#limitations
129
+ */
130
+ export declare function withImplicitOrder<Q extends Query>(q: Q): Q & {
131
+ orderBy?: Order[];
132
+ };
package/dist/query.js ADDED
@@ -0,0 +1,34 @@
1
+ // The query and write AST: the JSON every client sends (POST /v1/query, /v1/commit).
2
+ export const inc = (by = 1) => ({ $op: "inc", by });
3
+ export const arrayUnion = (...values) => ({ $op: "union", values });
4
+ export const arrayRemove = (...values) => ({ $op: "remove", values });
5
+ export const serverTimestamp = () => ({ $op: "now" });
6
+ export const deleteField = () => ({ $op: "delete" });
7
+ /** Tagged wire values: timestamps, refs and points. */
8
+ export const ts = (ms) => ({ $t: ms });
9
+ export const ref = (path) => ({ $ref: path });
10
+ export const geo = (lat, lng) => ({ $geo: [lat, lng] });
11
+ const INEQUALITY = new Set(["<", "<=", ">", ">=", "!=", "not-in"]);
12
+ /**
13
+ * Firestore's implicit order: a query with inequality filters and no orderBy is ordered by the
14
+ * inequality fields (ascending, by field name), then by id. Everything that orders results (SQL,
15
+ * the gateway's paging, the in-memory engine) goes through this, so a `limit` keeps the same rows.
16
+ * https://firebase.google.com/docs/firestore/query-data/order-limit-data#limitations
17
+ */
18
+ export function withImplicitOrder(q) {
19
+ if (q.orderBy?.length || q.near)
20
+ return q;
21
+ const fields = new Set();
22
+ const walk = (f) => {
23
+ if (!f)
24
+ return;
25
+ if ("and" in f)
26
+ f.and.forEach(walk);
27
+ else if ("or" in f)
28
+ f.or.forEach(walk);
29
+ else if (INEQUALITY.has(f.op) && f.field !== "id" && f.field !== "__name__")
30
+ fields.add(f.field);
31
+ };
32
+ walk(q.where);
33
+ return fields.size ? { ...q, orderBy: [...fields].sort().map((field) => ({ field, dir: "asc" })) } : q;
34
+ }
@@ -0,0 +1,47 @@
1
+ import { type FieldDef } from "./fields.js";
2
+ export type IndexField = string | [string, "asc" | "desc"];
3
+ export interface CollectionDef {
4
+ fields: Record<string, FieldDef>;
5
+ /** Composite indexes, leading column first. Single fields used alone get one too. */
6
+ indexes?: IndexField[][];
7
+ sub?: Record<string, CollectionDef>;
8
+ }
9
+ export declare const collection: (c: CollectionDef) => CollectionDef;
10
+ export interface Column {
11
+ name: string;
12
+ type: string;
13
+ }
14
+ export interface TableInfo {
15
+ /** Quoted-safe table name: collection ids joined with "/". */
16
+ table: string;
17
+ /** Collection ids from the root, e.g. ["posts", "comments"]. */
18
+ pattern: string[];
19
+ /** Parent id columns, `_p0` … */
20
+ parents: string[];
21
+ fields: Record<string, FieldDef>;
22
+ /** Index name → column list (with " DESC" where descending). */
23
+ indexes: Record<string, string[]>;
24
+ /** Indexed array field → side table name. */
25
+ sides: Record<string, string>;
26
+ }
27
+ export interface Schema {
28
+ tables: Record<string, TableInfo>;
29
+ }
30
+ export declare function defineSchema(root: Record<string, CollectionDef>): Schema;
31
+ /** Data columns of a table (geo fields expand to two). */
32
+ export declare function columnsOf(t: TableInfo): Column[];
33
+ /** Every non-map field, with its dotted path ("stats.plays"). Maps contribute their leaves. */
34
+ export declare function leavesOf(fields: Record<string, FieldDef>, prefix?: string): {
35
+ path: string;
36
+ fd: FieldDef;
37
+ }[];
38
+ /**
39
+ * Resolves a dotted path. Walks through maps; stops at the first non-map field and returns the
40
+ * rest of the path (non-empty only for json fields: a path inside the JSON value).
41
+ * Null when the path names no field, or goes "inside" a scalar.
42
+ */
43
+ export declare function fieldAt(fields: Record<string, FieldDef>, path: string): {
44
+ fd: FieldDef;
45
+ base: string;
46
+ rest: string[];
47
+ } | null;
package/dist/schema.js ADDED
@@ -0,0 +1,99 @@
1
+ // defineSchema(): collections → SQL tables. A subcollection is its own table whose rows carry
2
+ // their parents' ids in `_p0`, `_p1`, … (posts/{a}/comments/{b} → table "posts/comments", _p0 = a).
3
+ import { SQL_TYPE } from "./fields.js";
4
+ export const collection = (c) => c;
5
+ const ID_RE = /^[a-z][a-zA-Z0-9_]{0,62}$/;
6
+ const FIELD_RE = /^[a-zA-Z][a-zA-Z0-9_]{0,62}$/;
7
+ const RESERVED = new Set(["id"]);
8
+ export function defineSchema(root) {
9
+ const tables = {};
10
+ const walk = (defs, prefix) => {
11
+ for (const [id, c] of Object.entries(defs)) {
12
+ if (!ID_RE.test(id))
13
+ throw new Error(`bad collection id "${id}"`);
14
+ const pattern = [...prefix, id];
15
+ const table = pattern.join("/");
16
+ const checkNames = (fields, prefix, depth) => {
17
+ if (depth > 4)
18
+ throw new Error(`${table}: maps nest at most 4 deep ("${prefix}")`);
19
+ for (const [name, fd] of Object.entries(fields)) {
20
+ if (!FIELD_RE.test(name) || (depth === 0 && RESERVED.has(name)) || name.includes("__"))
21
+ throw new Error(`${table}: bad field name "${prefix}${name}"`);
22
+ if (fd.kind === "map")
23
+ checkNames(fd.fields ?? {}, `${prefix}${name}.`, depth + 1);
24
+ }
25
+ };
26
+ checkNames(c.fields, "", 0);
27
+ const indexes = {};
28
+ for (const ix of c.indexes ?? []) {
29
+ const cols = ix.flatMap((e) => {
30
+ const [name, dir] = typeof e === "string" ? [e, "asc"] : e;
31
+ const at = name === "id" ? null : fieldAt(c.fields, name);
32
+ const fd = at && at.rest.length === 0 ? at.fd : undefined;
33
+ if (name !== "id" && !fd)
34
+ throw new Error(`${table}: index on unknown field "${name}"`);
35
+ if (fd && (fd.kind === "json" || fd.kind === "array" || fd.kind === "map"))
36
+ throw new Error(`${table}: cannot index ${fd.kind} field "${name}" (index arrays with { indexed: true }; index a map's leaves)`);
37
+ const d = dir === "desc" ? " DESC" : "";
38
+ return fd?.kind === "geo" ? [`${name}__lat${d}`, `${name}__lng${d}`] : [`${name}${d}`];
39
+ });
40
+ indexes[`${table}(${cols.join(",")})`] = cols;
41
+ }
42
+ const sides = {};
43
+ for (const { path: name, fd } of leavesOf(c.fields)) {
44
+ if (fd.kind === "array" && fd.indexed)
45
+ sides[name] = `${table}#${name}`;
46
+ if (fd.kind === "geo") {
47
+ const cols = [`${name}__lat`, `${name}__lng`];
48
+ indexes[`${table}(${cols.join(",")})`] ??= cols;
49
+ }
50
+ }
51
+ tables[table] = { table, pattern, parents: prefix.map((_, i) => `_p${i}`), fields: c.fields, indexes, sides };
52
+ if (c.sub)
53
+ walk(c.sub, pattern);
54
+ }
55
+ };
56
+ walk(root, []);
57
+ return { tables };
58
+ }
59
+ /** Data columns of a table (geo fields expand to two). */
60
+ export function columnsOf(t) {
61
+ const out = [];
62
+ for (const { path: name, fd } of leavesOf(t.fields)) {
63
+ if (fd.kind === "geo")
64
+ out.push({ name: `${name}__lat`, type: "REAL" }, { name: `${name}__lng`, type: "REAL" });
65
+ else
66
+ out.push({ name, type: SQL_TYPE[fd.kind] });
67
+ }
68
+ return out;
69
+ }
70
+ /** Every non-map field, with its dotted path ("stats.plays"). Maps contribute their leaves. */
71
+ export function leavesOf(fields, prefix = "") {
72
+ return Object.entries(fields).flatMap(([name, fd]) => (fd.kind === "map" ? leavesOf(fd.fields ?? {}, `${prefix}${name}.`) : [{ path: `${prefix}${name}`, fd }]));
73
+ }
74
+ /**
75
+ * Resolves a dotted path. Walks through maps; stops at the first non-map field and returns the
76
+ * rest of the path (non-empty only for json fields: a path inside the JSON value).
77
+ * Null when the path names no field, or goes "inside" a scalar.
78
+ */
79
+ export function fieldAt(fields, path) {
80
+ const segs = path.split(".");
81
+ let cur = fields;
82
+ for (let i = 0; i < segs.length; i++) {
83
+ const fd = cur[segs[i]];
84
+ if (!fd)
85
+ return null;
86
+ const base = segs.slice(0, i + 1).join(".");
87
+ const rest = segs.slice(i + 1);
88
+ if (fd.kind === "map" && rest.length) {
89
+ cur = fd.fields ?? {};
90
+ continue;
91
+ }
92
+ if (rest.length && fd.kind !== "json")
93
+ return null;
94
+ if (rest.some((r) => !r || /["\\]/.test(r)))
95
+ return null;
96
+ return { fd, base, rest };
97
+ }
98
+ return null;
99
+ }
package/dist/sql.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { type Schema, type TableInfo } from "./schema.js";
2
+ export declare const q: (name: string) => string;
3
+ /** Tables every Estra database has, by version (a snapshot records how many it has applied). */
4
+ export declare const SYSTEM_MIGRATIONS: string[][];
5
+ /** Every system statement (fresh databases). */
6
+ export declare const SYSTEM_SQL: string[];
7
+ export interface TableSnap {
8
+ parents: string[];
9
+ columns: Record<string, string>;
10
+ indexes: Record<string, string[]>;
11
+ sides: Record<string, string>;
12
+ }
13
+ export interface Snapshot {
14
+ version: 1;
15
+ tables: Record<string, TableSnap>;
16
+ /** Plugin name → migrations applied. */
17
+ plugins?: Record<string, number>;
18
+ /** System migrations applied (absent in snapshots older than the field = 1). */
19
+ system?: number;
20
+ }
21
+ /** The part of a plugin migrations care about (EstraPlugin in @estra/server). */
22
+ export interface PluginMigrations {
23
+ name: string;
24
+ migrations: string[][];
25
+ }
26
+ /** Plugin SQL a database at `prev` hasn't had yet. */
27
+ export declare function pluginDiff(prev: Snapshot | null, plugins: PluginMigrations[]): string[];
28
+ export declare function snapshot(schema: Schema, plugins?: PluginMigrations[]): Snapshot;
29
+ /** Full SQL for a fresh database. */
30
+ export declare function schemaToSql(schema: Schema): string[];
31
+ export interface Diff {
32
+ statements: string[];
33
+ /** Drops and type changes, left out unless `allowDestructive`. */
34
+ destructive: string[];
35
+ }
36
+ export declare function diff(prev: Snapshot | null, next: Snapshot, opts?: {
37
+ allowDestructive?: boolean;
38
+ }): Diff;
39
+ export type { TableInfo };
package/dist/sql.js ADDED
@@ -0,0 +1,115 @@
1
+ // Schema → SQL (SQLite). `snapshot()` records what a migration produced, and `diff()`
2
+ // turns the change between two snapshots into the next migration.
3
+ import { columnsOf } from "./schema.js";
4
+ export const q = (name) => `"${name.replace(/"/g, '""')}"`;
5
+ /** Tables every Estra database has, by version (a snapshot records how many it has applied). */
6
+ export const SYSTEM_MIGRATIONS = [
7
+ [
8
+ `CREATE TABLE IF NOT EXISTS "_changes" ("seq" INTEGER PRIMARY KEY AUTOINCREMENT, "tbl" TEXT NOT NULL, "key" TEXT NOT NULL, "op" TEXT NOT NULL, "v" INTEGER, "at" INTEGER NOT NULL, "origin" TEXT)`,
9
+ `CREATE INDEX IF NOT EXISTS "_changes(at)" ON "_changes" ("at")`,
10
+ `CREATE TABLE IF NOT EXISTS "_ops" ("op_key" TEXT PRIMARY KEY, "at" INTEGER NOT NULL)`,
11
+ // A batch inserts 0 here to abort itself (failed precondition): CHECK fails → whole batch rolls back.
12
+ `CREATE TABLE IF NOT EXISTS "_assert" ("ok" INTEGER NOT NULL CHECK ("ok" = 1))`,
13
+ ],
14
+ [
15
+ // Trigger outbox: one row per write to a table with triggers, written in the commit's own batch.
16
+ `CREATE TABLE IF NOT EXISTS "_events" ("id" INTEGER PRIMARY KEY AUTOINCREMENT, "seq" INTEGER, "tbl" TEXT NOT NULL, "path" TEXT NOT NULL, "op" TEXT NOT NULL, "before" TEXT, "after" TEXT, "origin" TEXT, "at" INTEGER NOT NULL, "attempts" INTEGER NOT NULL DEFAULT 0, "claimed_at" INTEGER, "state" INTEGER NOT NULL DEFAULT 0, "error" TEXT)`,
17
+ `CREATE INDEX IF NOT EXISTS "_events(state,id)" ON "_events" ("state", "id")`,
18
+ ],
19
+ ];
20
+ /** Every system statement (fresh databases). */
21
+ export const SYSTEM_SQL = SYSTEM_MIGRATIONS.flat();
22
+ /** Plugin SQL a database at `prev` hasn't had yet. */
23
+ export function pluginDiff(prev, plugins) {
24
+ return plugins.flatMap((p) => p.migrations.slice(prev?.plugins?.[p.name] ?? 0).flat());
25
+ }
26
+ export function snapshot(schema, plugins = []) {
27
+ const tables = {};
28
+ for (const t of Object.values(schema.tables)) {
29
+ tables[t.table] = {
30
+ parents: t.parents,
31
+ columns: Object.fromEntries(columnsOf(t).map((c) => [c.name, c.type])),
32
+ indexes: t.indexes,
33
+ sides: t.sides,
34
+ };
35
+ }
36
+ return { version: 1, tables, system: SYSTEM_MIGRATIONS.length, ...(plugins.length ? { plugins: Object.fromEntries(plugins.map((p) => [p.name, p.migrations.length])) } : {}) };
37
+ }
38
+ const colList = (cols) => cols.map((c) => (c.endsWith(" DESC") ? `${q(c.slice(0, -5))} DESC` : q(c))).join(", ");
39
+ function createTable(name, t) {
40
+ const key = [...t.parents, "id"];
41
+ const cols = [...key.map((k) => `${q(k)} TEXT NOT NULL`), ...Object.entries(t.columns).map(([c, ty]) => `${q(c)} ${ty}`)];
42
+ cols.push(`"_v" INTEGER NOT NULL`, `"_created" INTEGER NOT NULL`, `"_updated" INTEGER NOT NULL`);
43
+ const out = [`CREATE TABLE IF NOT EXISTS ${q(name)} (${cols.join(", ")}, PRIMARY KEY (${colList(key)}))`];
44
+ for (const [ix, ixCols] of Object.entries(t.indexes))
45
+ out.push(createIndex(name, t, ix, ixCols));
46
+ for (const side of Object.values(t.sides))
47
+ out.push(...createSide(side, t));
48
+ return out;
49
+ }
50
+ // Subcollection indexes lead with the parent ids: queries run inside one parent.
51
+ const createIndex = (table, t, ix, cols) => `CREATE INDEX IF NOT EXISTS ${q(ix)} ON ${q(table)} (${colList([...t.parents, ...cols])})`;
52
+ function createSide(side, t) {
53
+ const key = [...t.parents, "id"];
54
+ return [
55
+ `CREATE TABLE IF NOT EXISTS ${q(side)} (${key.map((k) => `${q(k)} TEXT NOT NULL`).join(", ")}, "value" NOT NULL, PRIMARY KEY (${colList([...key, "value"])}))`,
56
+ `CREATE INDEX IF NOT EXISTS ${q(`${side}(value)`)} ON ${q(side)} (${colList(["value", ...key])})`,
57
+ ];
58
+ }
59
+ const backfillSide = (table, field, side, t) => {
60
+ const key = [...t.parents, "id"];
61
+ return `INSERT OR IGNORE INTO ${q(side)} (${colList([...key, "value"])}) SELECT ${key.map((k) => `d.${q(k)}`).join(", ")}, j.value FROM ${q(table)} AS d, json_each(d.${q(field)}) AS j WHERE d.${q(field)} IS NOT NULL AND j.type NOT IN ('object', 'array', 'null')`;
62
+ };
63
+ /** Full SQL for a fresh database. */
64
+ export function schemaToSql(schema) {
65
+ return diff(null, snapshot(schema)).statements;
66
+ }
67
+ export function diff(prev, next, opts = {}) {
68
+ const statements = SYSTEM_MIGRATIONS.slice(prev ? (prev.system ?? 1) : 0).flat();
69
+ const destructive = [];
70
+ const drop = (desc, sql) => {
71
+ destructive.push(desc);
72
+ if (opts.allowDestructive)
73
+ statements.push(sql);
74
+ };
75
+ const before = prev?.tables ?? {};
76
+ for (const [name, t] of Object.entries(next.tables)) {
77
+ const p = before[name];
78
+ if (!p) {
79
+ statements.push(...createTable(name, t));
80
+ continue;
81
+ }
82
+ if (p.parents.join() !== t.parents.join())
83
+ throw new Error(`${name}: parent keys changed; needs a hand-written migration`);
84
+ for (const [c, ty] of Object.entries(t.columns)) {
85
+ if (!(c in p.columns))
86
+ statements.push(`ALTER TABLE ${q(name)} ADD COLUMN ${q(c)} ${ty}`);
87
+ else if (p.columns[c] !== ty)
88
+ destructive.push(`${name}.${c}: type ${p.columns[c]} → ${ty} (hand-written migration)`);
89
+ }
90
+ for (const c of Object.keys(p.columns))
91
+ if (!(c in t.columns))
92
+ drop(`${name}.${c}: column removed`, `ALTER TABLE ${q(name)} DROP COLUMN ${q(c)}`);
93
+ for (const [ix, cols] of Object.entries(t.indexes))
94
+ if (!(ix in p.indexes))
95
+ statements.push(createIndex(name, t, ix, cols));
96
+ for (const ix of Object.keys(p.indexes))
97
+ if (!(ix in t.indexes))
98
+ statements.push(`DROP INDEX IF EXISTS ${q(ix)}`);
99
+ for (const [field, side] of Object.entries(t.sides)) {
100
+ if (!(field in p.sides))
101
+ statements.push(...createSide(side, t), backfillSide(name, field, side, t));
102
+ }
103
+ for (const [field, side] of Object.entries(p.sides))
104
+ if (!(field in t.sides))
105
+ statements.push(`DROP TABLE IF EXISTS ${q(side)}`);
106
+ }
107
+ for (const [name, p] of Object.entries(before)) {
108
+ if (name in next.tables)
109
+ continue;
110
+ drop(`${name}: table removed`, `DROP TABLE IF EXISTS ${q(name)}`);
111
+ for (const side of Object.values(p.sides))
112
+ drop(`${side}: side table removed`, `DROP TABLE IF EXISTS ${q(side)}`);
113
+ }
114
+ return { statements, destructive };
115
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@estra/schema",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Estra's schema: defineSchema and field types, the query and write types, and the estra migrate CLI.",
5
+ "license": "MIT",
6
+ "homepage": "https://estra.cloud",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Realm-8/estra.git",
10
+ "directory": "packages/schema"
11
+ },
12
+ "bugs": "https://github.com/Realm-8/estra/issues",
13
+ "keywords": [
14
+ "estra",
15
+ "schema",
16
+ "database",
17
+ "migrations",
18
+ "firestore"
19
+ ],
20
+ "type": "module",
21
+ "main": "./dist/index.js",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "bin": {
29
+ "estra": "./dist/cli.js"
30
+ },
31
+ "devDependencies": {
32
+ "typescript": "^5.9.2",
33
+ "vitest": "4.1.0",
34
+ "@types/node": "^24.0.0"
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "scripts": {
43
+ "typecheck": "tsc -p .",
44
+ "build": "node ../../scripts/build-package.ts schema",
45
+ "test": "vitest run"
46
+ },
47
+ "types": "./dist/index.d.ts"
6
48
  }