lakebed 0.0.1

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.
@@ -0,0 +1,8 @@
1
+ export type Auth = {
2
+ userId: string;
3
+ displayName: string;
4
+ };
5
+
6
+ export function useAuth(): Auth;
7
+ export function useQuery<T>(name: string): T;
8
+ export function useMutation<TArgs extends unknown[], TResult>(name: string): (...args: TArgs) => Promise<TResult>;
package/src/client.js ADDED
@@ -0,0 +1,154 @@
1
+ import { useEffect, useState } from "preact/hooks";
2
+
3
+ let socket = null;
4
+ let nextRequestId = 1;
5
+ let auth = {
6
+ userId: "guest:local",
7
+ displayName: "Local Guest"
8
+ };
9
+ const authListeners = new Set();
10
+ const queryValues = new Map();
11
+ const queryListeners = new Map();
12
+ const pending = new Map();
13
+ const activeSubscriptions = new Set();
14
+
15
+ function emitAuth() {
16
+ for (const listener of authListeners) {
17
+ listener(auth);
18
+ }
19
+ }
20
+
21
+ function emitQuery(name, value) {
22
+ queryValues.set(name, value);
23
+ const listeners = queryListeners.get(name);
24
+ if (!listeners) {
25
+ return;
26
+ }
27
+
28
+ for (const listener of listeners) {
29
+ listener(value);
30
+ }
31
+ }
32
+
33
+ function send(message) {
34
+ const ws = connect();
35
+ const payload = JSON.stringify(message);
36
+ if (ws.readyState === WebSocket.OPEN) {
37
+ ws.send(payload);
38
+ return;
39
+ }
40
+
41
+ ws.addEventListener(
42
+ "open",
43
+ () => {
44
+ ws.send(payload);
45
+ },
46
+ { once: true }
47
+ );
48
+ }
49
+
50
+ function request(op, payload) {
51
+ const id = nextRequestId++;
52
+ send({ id, op, ...payload });
53
+
54
+ return new Promise((resolve, reject) => {
55
+ pending.set(id, { resolve, reject });
56
+ });
57
+ }
58
+
59
+ function connect() {
60
+ if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
61
+ return socket;
62
+ }
63
+
64
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
65
+ const basePath = window.__LAKEBED_BASE_PATH__ ?? "";
66
+ const url = new URL(`${protocol}//${window.location.host}${basePath}/__lakebed/ws`);
67
+ const guestName = new URLSearchParams(window.location.search).get("lakebed_guest");
68
+ if (guestName) {
69
+ url.searchParams.set("lakebed_guest", guestName);
70
+ }
71
+
72
+ socket = new WebSocket(url);
73
+
74
+ socket.addEventListener("open", () => {
75
+ send({ op: "auth.get" });
76
+ for (const name of activeSubscriptions) {
77
+ send({ op: "query.subscribe", name });
78
+ }
79
+ });
80
+
81
+ socket.addEventListener("message", (event) => {
82
+ const message = JSON.parse(String(event.data));
83
+
84
+ if (message.op === "auth.result") {
85
+ auth = message.auth;
86
+ emitAuth();
87
+ return;
88
+ }
89
+
90
+ if (message.op === "query.result") {
91
+ emitQuery(message.name, message.data);
92
+ return;
93
+ }
94
+
95
+ if (message.id && pending.has(message.id)) {
96
+ const handlers = pending.get(message.id);
97
+ pending.delete(message.id);
98
+
99
+ if (message.ok) {
100
+ handlers.resolve(message.result);
101
+ } else {
102
+ handlers.reject(new Error(message.error ?? "Lakebed request failed"));
103
+ }
104
+ }
105
+ });
106
+
107
+ socket.addEventListener("close", () => {
108
+ window.setTimeout(() => {
109
+ socket = null;
110
+ connect();
111
+ }, 500);
112
+ });
113
+
114
+ return socket;
115
+ }
116
+
117
+ export function useAuth() {
118
+ const [value, setValue] = useState(auth);
119
+
120
+ useEffect(() => {
121
+ connect();
122
+ authListeners.add(setValue);
123
+ return () => {
124
+ authListeners.delete(setValue);
125
+ };
126
+ }, []);
127
+
128
+ return value;
129
+ }
130
+
131
+ export function useQuery(name) {
132
+ const [value, setValue] = useState(queryValues.get(name) ?? []);
133
+
134
+ useEffect(() => {
135
+ connect();
136
+ activeSubscriptions.add(name);
137
+ if (!queryListeners.has(name)) {
138
+ queryListeners.set(name, new Set());
139
+ }
140
+
141
+ queryListeners.get(name).add(setValue);
142
+ send({ op: "query.subscribe", name });
143
+
144
+ return () => {
145
+ queryListeners.get(name)?.delete(setValue);
146
+ };
147
+ }, [name]);
148
+
149
+ return value;
150
+ }
151
+
152
+ export function useMutation(name) {
153
+ return (...args) => request("mutation.run", { name, args });
154
+ }
package/src/runtime.js ADDED
@@ -0,0 +1,252 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ function now() {
4
+ return new Date().toISOString();
5
+ }
6
+
7
+ function compareValues(left, right) {
8
+ if (left === right) {
9
+ return 0;
10
+ }
11
+
12
+ return left > right ? 1 : -1;
13
+ }
14
+
15
+ class QueryBuilder {
16
+ constructor(table, rows, filters = [], sort = null, max = null) {
17
+ this.table = table;
18
+ this.rows = rows;
19
+ this.filters = filters;
20
+ this.sort = sort;
21
+ this.max = max;
22
+ }
23
+
24
+ where(field, value) {
25
+ return new QueryBuilder(this.table, this.rows, [...this.filters, { field, value }], this.sort, this.max);
26
+ }
27
+
28
+ orderBy(field, direction = "asc") {
29
+ return new QueryBuilder(this.table, this.rows, this.filters, { field, direction }, this.max);
30
+ }
31
+
32
+ limit(count) {
33
+ return new QueryBuilder(this.table, this.rows, this.filters, this.sort, count);
34
+ }
35
+
36
+ all() {
37
+ let results = Array.from(this.rows.values());
38
+
39
+ for (const filter of this.filters) {
40
+ results = results.filter((row) => row[filter.field] === filter.value);
41
+ }
42
+
43
+ if (this.sort) {
44
+ const direction = this.sort.direction === "desc" ? -1 : 1;
45
+ results = [...results].sort((left, right) => compareValues(left[this.sort.field], right[this.sort.field]) * direction);
46
+ }
47
+
48
+ if (typeof this.max === "number") {
49
+ results = results.slice(0, this.max);
50
+ }
51
+
52
+ return results.map((row) => ({ ...row }));
53
+ }
54
+ }
55
+
56
+ function metadataFields() {
57
+ return new Set(["id", "createdAt", "updatedAt"]);
58
+ }
59
+
60
+ function fieldDefault(field) {
61
+ if (!field || !Object.prototype.hasOwnProperty.call(field, "defaultValue")) {
62
+ return undefined;
63
+ }
64
+
65
+ return typeof field.defaultValue === "function" ? field.defaultValue() : field.defaultValue;
66
+ }
67
+
68
+ function assertFieldValue(tableName, fieldName, field, value) {
69
+ if (value === undefined) {
70
+ throw new Error(`Missing value for ${tableName}.${fieldName}`);
71
+ }
72
+
73
+ if (field.kind === "string" && typeof value !== "string") {
74
+ throw new Error(`Expected ${tableName}.${fieldName} to be a string.`);
75
+ }
76
+
77
+ if (field.kind === "boolean" && typeof value !== "boolean") {
78
+ throw new Error(`Expected ${tableName}.${fieldName} to be a boolean.`);
79
+ }
80
+ }
81
+
82
+ class TableApi extends QueryBuilder {
83
+ constructor(stateCell, name) {
84
+ const rows = stateCell.tables.get(name);
85
+ super(name, rows);
86
+ this.stateCell = stateCell;
87
+ this.name = name;
88
+ this.definition = stateCell.schema?.[name];
89
+ }
90
+
91
+ validateInsert(value) {
92
+ const fields = this.definition?.fields ?? {};
93
+ const metadata = metadataFields();
94
+
95
+ for (const key of Object.keys(value)) {
96
+ if (!fields[key] && !metadata.has(key)) {
97
+ throw new Error(`Unknown field for ${this.name}: ${key}`);
98
+ }
99
+
100
+ if (metadata.has(key)) {
101
+ throw new Error(`Lakebed manages ${this.name}.${key}; app code cannot set it directly.`);
102
+ }
103
+ }
104
+
105
+ const row = {};
106
+ for (const [fieldName, field] of Object.entries(fields)) {
107
+ const valueOrDefault = value[fieldName] ?? fieldDefault(field);
108
+ assertFieldValue(this.name, fieldName, field, valueOrDefault);
109
+ row[fieldName] = valueOrDefault;
110
+ }
111
+
112
+ return row;
113
+ }
114
+
115
+ validatePatch(patch) {
116
+ const fields = this.definition?.fields ?? {};
117
+ const metadata = metadataFields();
118
+ const cleanPatch = {};
119
+
120
+ for (const [key, value] of Object.entries(patch)) {
121
+ if (!fields[key] && !metadata.has(key)) {
122
+ throw new Error(`Unknown field for ${this.name}: ${key}`);
123
+ }
124
+
125
+ if (metadata.has(key)) {
126
+ throw new Error(`Lakebed manages ${this.name}.${key}; app code cannot update it directly.`);
127
+ }
128
+
129
+ assertFieldValue(this.name, key, fields[key], value);
130
+ cleanPatch[key] = value;
131
+ }
132
+
133
+ return cleanPatch;
134
+ }
135
+
136
+ get(id) {
137
+ const row = this.rows.get(id);
138
+ return row ? { ...row } : null;
139
+ }
140
+
141
+ insert(value) {
142
+ const timestamp = now();
143
+ const fields = this.validateInsert(value);
144
+ const row = {
145
+ ...fields,
146
+ id: randomUUID(),
147
+ createdAt: timestamp,
148
+ updatedAt: timestamp
149
+ };
150
+ this.rows.set(row.id, row);
151
+ this.stateCell.changedTables.add(this.name);
152
+ return { ...row };
153
+ }
154
+
155
+ update(id, patch) {
156
+ const row = this.rows.get(id);
157
+ if (!row) {
158
+ return;
159
+ }
160
+
161
+ const cleanPatch = this.validatePatch(patch);
162
+ this.rows.set(id, {
163
+ ...row,
164
+ ...cleanPatch,
165
+ id,
166
+ updatedAt: now()
167
+ });
168
+ this.stateCell.changedTables.add(this.name);
169
+ }
170
+
171
+ delete(id) {
172
+ if (this.rows.delete(id)) {
173
+ this.stateCell.changedTables.add(this.name);
174
+ }
175
+ }
176
+ }
177
+
178
+ export class StateCell {
179
+ constructor(schema) {
180
+ this.schema = schema;
181
+ this.tables = new Map();
182
+ this.changedTables = new Set();
183
+ this.queue = Promise.resolve();
184
+
185
+ for (const tableName of Object.keys(schema ?? {})) {
186
+ this.tables.set(tableName, new Map());
187
+ }
188
+ }
189
+
190
+ createDb() {
191
+ const db = {};
192
+ for (const tableName of this.tables.keys()) {
193
+ db[tableName] = new TableApi(this, tableName);
194
+ }
195
+ return db;
196
+ }
197
+
198
+ listTables() {
199
+ return Array.from(this.tables.keys()).sort();
200
+ }
201
+
202
+ dump() {
203
+ const tables = {};
204
+ for (const [tableName, rows] of this.tables) {
205
+ tables[tableName] = Array.from(rows.values()).map((row) => ({ ...row }));
206
+ }
207
+
208
+ return { tables };
209
+ }
210
+
211
+ async transaction(handler) {
212
+ const run = async () => {
213
+ this.changedTables.clear();
214
+ const result = await handler(this.createDb());
215
+ const changedTables = Array.from(this.changedTables);
216
+ this.changedTables.clear();
217
+ return { result, changedTables };
218
+ };
219
+
220
+ const next = this.queue.then(run, run);
221
+ this.queue = next.then(
222
+ () => undefined,
223
+ () => undefined
224
+ );
225
+ return next;
226
+ }
227
+ }
228
+
229
+ export class LogBuffer {
230
+ constructor() {
231
+ this.entries = [];
232
+ }
233
+
234
+ append(level, message, data) {
235
+ const entry = {
236
+ level,
237
+ message,
238
+ data,
239
+ at: now()
240
+ };
241
+ this.entries.push(entry);
242
+ console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](`[lakebed:${level}] ${message}`, data ?? "");
243
+ }
244
+
245
+ createLogger() {
246
+ return {
247
+ info: (message, data) => this.append("info", message, data),
248
+ warn: (message, data) => this.append("warn", message, data),
249
+ error: (message, data) => this.append("error", message, data)
250
+ };
251
+ }
252
+ }
@@ -0,0 +1,53 @@
1
+ export type Field<T> = {
2
+ kind: string;
3
+ defaultValue?: T;
4
+ default(value: T): Field<T>;
5
+ };
6
+
7
+ export type TableDefinition = {
8
+ kind: "table";
9
+ fields: Record<string, Field<unknown>>;
10
+ };
11
+
12
+ export type AuthContext = {
13
+ userId: string;
14
+ displayName: string;
15
+ };
16
+
17
+ export type LogContext = {
18
+ info(message: string, data?: unknown): void;
19
+ warn(message: string, data?: unknown): void;
20
+ error(message: string, data?: unknown): void;
21
+ };
22
+
23
+ export type QueryBuilder<T> = {
24
+ where(field: keyof T & string, value: unknown): QueryBuilder<T>;
25
+ orderBy(field: keyof T & string, direction?: "asc" | "desc"): QueryBuilder<T>;
26
+ limit(count: number): QueryBuilder<T>;
27
+ all(): Array<T & { id: string; createdAt: string; updatedAt: string }>;
28
+ };
29
+
30
+ export type TableApi<T> = QueryBuilder<T> & {
31
+ get(id: string): (T & { id: string; createdAt: string; updatedAt: string }) | null;
32
+ insert(value: T): T & { id: string; createdAt: string; updatedAt: string };
33
+ update(id: string, patch: Partial<T>): void;
34
+ delete(id: string): void;
35
+ };
36
+
37
+ export type DbContext = Record<string, TableApi<Record<string, unknown>>>;
38
+
39
+ export type ServerContext = {
40
+ auth: AuthContext;
41
+ db: DbContext;
42
+ env: Record<string, string | undefined>;
43
+ log: LogContext;
44
+ };
45
+
46
+ export function capsule<T>(definition: T): T;
47
+ export function query<T>(handler: (ctx: ServerContext) => T): (ctx: ServerContext) => T;
48
+ export function mutation<TArgs extends unknown[], TResult>(
49
+ handler: (ctx: ServerContext, ...args: TArgs) => TResult
50
+ ): (ctx: ServerContext, ...args: TArgs) => TResult;
51
+ export function table(fields: Record<string, Field<unknown>>): TableDefinition;
52
+ export function string(): Field<string>;
53
+ export function boolean(): Field<boolean>;
package/src/server.js ADDED
@@ -0,0 +1,39 @@
1
+ export function capsule(definition) {
2
+ return definition;
3
+ }
4
+
5
+ export function query(handler) {
6
+ return handler;
7
+ }
8
+
9
+ export function mutation(handler) {
10
+ return handler;
11
+ }
12
+
13
+ function field(kind) {
14
+ return {
15
+ kind,
16
+ defaultValue: undefined,
17
+ default(value) {
18
+ return {
19
+ ...this,
20
+ defaultValue: value
21
+ };
22
+ }
23
+ };
24
+ }
25
+
26
+ export function table(fields) {
27
+ return {
28
+ kind: "table",
29
+ fields
30
+ };
31
+ }
32
+
33
+ export function string() {
34
+ return field("string");
35
+ }
36
+
37
+ export function boolean() {
38
+ return field("boolean");
39
+ }
@@ -0,0 +1,110 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { posix } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ function normalizePath(path) {
7
+ return path.replaceAll("\\", "/").replace(/^\/+/, "").replace(/\/+/g, "/");
8
+ }
9
+
10
+ async function readDirectory(rootDir, dir = rootDir, files = new Map()) {
11
+ const entries = await readdir(dir, { withFileTypes: true });
12
+
13
+ for (const entry of entries) {
14
+ if (entry.name === "node_modules" || entry.name === ".lakebed" || entry.name === ".DS_Store") {
15
+ continue;
16
+ }
17
+
18
+ const absolutePath = join(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ await readDirectory(rootDir, absolutePath, files);
21
+ continue;
22
+ }
23
+
24
+ if (!entry.isFile()) {
25
+ continue;
26
+ }
27
+
28
+ const storePath = normalizePath(relative(rootDir, absolutePath).split(sep).join("/"));
29
+ files.set(storePath, await readFile(absolutePath, "utf8"));
30
+ }
31
+
32
+ return files;
33
+ }
34
+
35
+ export class MemorySourceStore {
36
+ constructor(files = new Map(), snapshots = new Map()) {
37
+ this.files = new Map(files);
38
+ this.snapshots = new Map(snapshots);
39
+ this.archived = false;
40
+ }
41
+
42
+ clone() {
43
+ return new MemorySourceStore(this.files, this.snapshots);
44
+ }
45
+
46
+ hasFile(path) {
47
+ return this.files.has(normalizePath(path));
48
+ }
49
+
50
+ async readFile(path) {
51
+ const normalized = normalizePath(path);
52
+ const contents = this.files.get(normalized);
53
+ if (contents === undefined) {
54
+ throw new Error(`Source file not found: ${normalized}`);
55
+ }
56
+
57
+ return contents;
58
+ }
59
+
60
+ async writeFile(path, contents) {
61
+ if (this.archived) {
62
+ throw new Error("Cannot write to an archived source store.");
63
+ }
64
+
65
+ this.files.set(normalizePath(path), contents);
66
+ }
67
+
68
+ async listFiles(root = "") {
69
+ const normalizedRoot = normalizePath(root);
70
+ return Array.from(this.files.keys())
71
+ .filter((path) => path === normalizedRoot || path.startsWith(normalizedRoot ? `${normalizedRoot}/` : ""))
72
+ .sort();
73
+ }
74
+
75
+ async snapshot(message = "") {
76
+ const id = randomUUID();
77
+ this.snapshots.set(id, {
78
+ id,
79
+ message,
80
+ at: new Date().toISOString(),
81
+ files: new Map(this.files)
82
+ });
83
+ return id;
84
+ }
85
+
86
+ async fork(snapshotId) {
87
+ const snapshot = this.snapshots.get(snapshotId);
88
+ if (!snapshot) {
89
+ throw new Error(`Unknown source snapshot: ${snapshotId}`);
90
+ }
91
+
92
+ return new MemorySourceStore(snapshot.files, this.snapshots);
93
+ }
94
+
95
+ async archive() {
96
+ this.archived = true;
97
+ }
98
+ }
99
+
100
+ export async function createMemorySourceStoreFromDirectory(rootDir) {
101
+ return new MemorySourceStore(await readDirectory(rootDir));
102
+ }
103
+
104
+ export function sourcePathJoin(...parts) {
105
+ return normalizePath(posix.join(...parts));
106
+ }
107
+
108
+ export function sourcePathDirname(path) {
109
+ return normalizePath(posix.dirname(normalizePath(path)));
110
+ }