@makerclay/core 1.0.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.
Files changed (91) hide show
  1. package/LICENSE +221 -0
  2. package/README.md +82 -0
  3. package/package.json +52 -0
  4. package/src/admin/listing.js +149 -0
  5. package/src/admin/routes.js +362 -0
  6. package/src/attic.js +168 -0
  7. package/src/auth/can.js +79 -0
  8. package/src/auth/csrf.js +74 -0
  9. package/src/auth/none.js +24 -0
  10. package/src/auth/password.js +281 -0
  11. package/src/auth/passwords.js +79 -0
  12. package/src/auth/rate-limit.js +66 -0
  13. package/src/auth/sessions.js +86 -0
  14. package/src/auth/token-lanes.js +72 -0
  15. package/src/boot.js +120 -0
  16. package/src/client.js +99 -0
  17. package/src/collections/index.js +397 -0
  18. package/src/collections/routes.js +166 -0
  19. package/src/create-host.js +351 -0
  20. package/src/derived/data-extractor.js +22 -0
  21. package/src/derived/index.js +121 -0
  22. package/src/documents/format-html.js +313 -0
  23. package/src/documents/replace.js +257 -0
  24. package/src/documents/root-attrs.js +171 -0
  25. package/src/documents/serve.js +137 -0
  26. package/src/documents/stale.js +20 -0
  27. package/src/index.js +12 -0
  28. package/src/inspect.js +136 -0
  29. package/src/json-errors.js +59 -0
  30. package/src/livesync.js +75 -0
  31. package/src/nodes/identity.js +14 -0
  32. package/src/nodes/names.js +57 -0
  33. package/src/nodes/ops.js +613 -0
  34. package/src/nodes/scanner.js +321 -0
  35. package/src/nodes/store.js +111 -0
  36. package/src/pages.js +166 -0
  37. package/src/paths.js +302 -0
  38. package/src/recovery/overlay.js +327 -0
  39. package/src/recovery/replay.js +236 -0
  40. package/src/recovery-ui.js +185 -0
  41. package/src/recovery.js +30 -0
  42. package/src/requests.js +73 -0
  43. package/src/routes/meta.js +62 -0
  44. package/src/routes/read.js +105 -0
  45. package/src/routes/save.js +102 -0
  46. package/src/routes/sync.js +118 -0
  47. package/src/routes/upload.js +128 -0
  48. package/src/share/index.js +207 -0
  49. package/src/share/save-tokens.js +65 -0
  50. package/src/spec/codes.js +42 -0
  51. package/src/spec/meta.js +52 -0
  52. package/src/spec/wire.js +115 -0
  53. package/src/store/index.js +29 -0
  54. package/src/store/migrations/001-init.sql +114 -0
  55. package/src/store/sqlite.js +540 -0
  56. package/src/templates.js +50 -0
  57. package/src/tenants/index.js +355 -0
  58. package/src/tenants/isolation.js +91 -0
  59. package/src/tenants/routes.js +131 -0
  60. package/src/ui.js +95 -0
  61. package/src/util/cookies.js +26 -0
  62. package/src/util/express.js +8 -0
  63. package/src/util/fsx.js +205 -0
  64. package/src/util/id.js +37 -0
  65. package/src/util/lockfile.js +52 -0
  66. package/src/util/locks.js +35 -0
  67. package/src/util/multipart.js +33 -0
  68. package/src/versions/files.js +307 -0
  69. package/src/versions/index.js +16 -0
  70. package/src/versions/naming.js +172 -0
  71. package/src/versions/routes.js +91 -0
  72. package/src/wire-compat.js +61 -0
  73. package/ui/app.css +164 -0
  74. package/ui/attic.html +198 -0
  75. package/ui/dashboard.html +456 -0
  76. package/ui/editor.html +156 -0
  77. package/ui/error.html +18 -0
  78. package/ui/login.html +58 -0
  79. package/ui/records.html +173 -0
  80. package/ui/recovery.html +152 -0
  81. package/ui/setup.html +61 -0
  82. package/ui/share-qr.html +44 -0
  83. package/ui/templates/blank.html +16 -0
  84. package/ui/templates/devlog.html +71 -0
  85. package/ui/templates/hackable-dashboard.html +145 -0
  86. package/ui/templates/kanban.html +85 -0
  87. package/ui/templates/landing.html +108 -0
  88. package/ui/templates/writer.html +50 -0
  89. package/ui/tenants.html +172 -0
  90. package/ui/trash.html +134 -0
  91. package/ui/versions.html +114 -0
@@ -0,0 +1,540 @@
1
+ import fs from 'node:fs';
2
+ import path from 'upath';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // `node:sqlite` is only listed in module.builtinModules under its prefixed name,
6
+ // so a plain `import` sends bundlers and test runners hunting for a package
7
+ // called "sqlite" and failing. process.getBuiltinModule reaches the real module
8
+ // without going through any resolver at all.
9
+ const { DatabaseSync } = process.getBuiltinModule('node:sqlite');
10
+
11
+ const MIGRATIONS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'migrations');
12
+
13
+ function readMigrations() {
14
+ return fs.readdirSync(MIGRATIONS_DIR)
15
+ .filter((name) => name.endsWith('.sql'))
16
+ .sort()
17
+ .map((name) => ({ name, sql: fs.readFileSync(path.join(MIGRATIONS_DIR, name), 'utf8') }));
18
+ }
19
+
20
+ const NODE_COLUMNS = [
21
+ 'id', 'owner', 'path', 'kind',
22
+ 'etag', 'observed_etag', 'bytes', 'mtime_ms', 'dev', 'ino', 'seq',
23
+ 'created_at', 'updated_at', 'deleted_at', 'private', 'isolated', 'signups', 'source_id',
24
+ ];
25
+
26
+ function toNode(row) {
27
+ if (!row) return null;
28
+ return {
29
+ id: row.id,
30
+ owner: row.owner ?? '',
31
+ path: row.path,
32
+ kind: row.kind,
33
+ etag: row.etag ?? null,
34
+ observedEtag: row.observed_etag ?? null,
35
+ bytes: row.bytes ?? null,
36
+ mtimeMs: row.mtime_ms ?? null,
37
+ dev: row.dev ?? null,
38
+ ino: row.ino ?? null,
39
+ seq: row.seq,
40
+ createdAt: row.created_at,
41
+ updatedAt: row.updated_at,
42
+ deletedAt: row.deleted_at ?? null,
43
+ private: !!row.private,
44
+ isolated: row.isolated === null || row.isolated === undefined ? null : !!row.isolated,
45
+ signups: !!row.signups,
46
+ sourceId: row.source_id ?? null,
47
+ };
48
+ }
49
+
50
+ const CAMEL_TO_COLUMN = {
51
+ observedEtag: 'observed_etag',
52
+ mtimeMs: 'mtime_ms',
53
+ createdAt: 'created_at',
54
+ updatedAt: 'updated_at',
55
+ deletedAt: 'deleted_at',
56
+ sourceId: 'source_id',
57
+ };
58
+
59
+ function columnFor(key) {
60
+ const column = CAMEL_TO_COLUMN[key] || key;
61
+ if (!NODE_COLUMNS.includes(column)) {
62
+ throw new Error(`updateNode: unknown column "${key}"`);
63
+ }
64
+ return column;
65
+ }
66
+
67
+ function likeEscape(value) {
68
+ return String(value).replace(/[\\%_]/g, (char) => `\\${char}`);
69
+ }
70
+
71
+ function toBind(value) {
72
+ if (value === undefined || value === null) return null;
73
+ if (typeof value === 'boolean') return value ? 1 : 0;
74
+ return value;
75
+ }
76
+
77
+ export function createSqliteStore({ file } = {}) {
78
+ let db = null;
79
+ let inTx = 0;
80
+
81
+ function run(sql, ...params) {
82
+ return db.prepare(sql).run(...params.map(toBind));
83
+ }
84
+ function get(sql, ...params) {
85
+ return db.prepare(sql).get(...params.map(toBind)) ?? null;
86
+ }
87
+ function all(sql, ...params) {
88
+ return db.prepare(sql).all(...params.map(toBind));
89
+ }
90
+
91
+ const store = {
92
+ name: 'sqlite',
93
+
94
+ // `readOnly` is for inspection (`makerclay doctor`), and the guarantee is
95
+ // SQLite's rather than this module's: the connection itself refuses every
96
+ // write, so a reader cannot migrate, checkpoint or repair by accident. That
97
+ // matters because the normal open MIGRATES, which is a schema change nobody
98
+ // asked a health check to make.
99
+ //
100
+ // No journal_mode pragma on this path: setting WAL is itself a write, and it
101
+ // is not needed. A read-only connection reads a WAL database fine, including
102
+ // one another process is holding open, and including one left mid-crash with
103
+ // a -wal and -shm still on disk, which is exactly when a report is wanted.
104
+ open({ paths, readOnly = false } = {}) {
105
+ const target = file || paths?.files?.db;
106
+ if (!target) throw new Error('sqlite store needs a file path');
107
+ if (readOnly) {
108
+ db = new DatabaseSync(target, { readOnly: true });
109
+ db.exec('PRAGMA busy_timeout = 5000');
110
+ return store;
111
+ }
112
+ db = new DatabaseSync(target);
113
+ db.exec('PRAGMA journal_mode = WAL');
114
+ db.exec('PRAGMA synchronous = NORMAL');
115
+ db.exec('PRAGMA foreign_keys = ON');
116
+ db.exec('PRAGMA busy_timeout = 5000');
117
+
118
+ const current = db.prepare('PRAGMA user_version').get().user_version;
119
+ const migrations = readMigrations();
120
+ if (current < migrations.length) {
121
+ db.exec('BEGIN');
122
+ try {
123
+ for (let i = current; i < migrations.length; i++) db.exec(migrations[i].sql);
124
+ db.exec(`PRAGMA user_version = ${migrations.length}`);
125
+ db.exec('COMMIT');
126
+ } catch (error) {
127
+ db.exec('ROLLBACK');
128
+ throw error;
129
+ }
130
+ }
131
+ return store;
132
+ },
133
+
134
+ close() {
135
+ if (db) {
136
+ db.close();
137
+ db = null;
138
+ }
139
+ },
140
+
141
+ integrityCheck() {
142
+ return all('PRAGMA integrity_check').map((row) => row.integrity_check);
143
+ },
144
+
145
+ // Nested calls join the outer transaction rather than opening a second one:
146
+ // SQLite has no nested BEGIN, and the kernel calls tx() from inside node ops.
147
+ tx(fn) {
148
+ if (inTx > 0) {
149
+ inTx += 1;
150
+ try {
151
+ return fn(store);
152
+ } finally {
153
+ inTx -= 1;
154
+ }
155
+ }
156
+ db.exec('BEGIN');
157
+ inTx = 1;
158
+ try {
159
+ const result = fn(store);
160
+ db.exec('COMMIT');
161
+ return result;
162
+ } catch (error) {
163
+ db.exec('ROLLBACK');
164
+ throw error;
165
+ } finally {
166
+ inTx = 0;
167
+ }
168
+ },
169
+
170
+ nextSeq() {
171
+ const row = get('SELECT v FROM kv WHERE k = ?', 'seq');
172
+ const next = (row ? Number(row.v) : 0) + 1;
173
+ run('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', 'seq', String(next));
174
+ return next;
175
+ },
176
+
177
+ getNode(owner, nodePath) {
178
+ return toNode(get(
179
+ 'SELECT * FROM nodes WHERE owner = ? AND path = ? AND deleted_at IS NULL',
180
+ owner ?? '', nodePath,
181
+ ));
182
+ },
183
+
184
+ getNodeAnyState(owner, nodePath) {
185
+ return toNode(get('SELECT * FROM nodes WHERE owner = ? AND path = ?', owner ?? '', nodePath));
186
+ },
187
+
188
+ getNodeById(id) {
189
+ return toNode(get('SELECT * FROM nodes WHERE id = ?', id));
190
+ },
191
+
192
+ listNodes({ owner = '', prefix = null, includeDeleted = false, sinceSeq = null, kind = null } = {}) {
193
+ const where = ['owner = ?'];
194
+ const params = [owner];
195
+ if (!includeDeleted) where.push('deleted_at IS NULL');
196
+ // `_` is a LIKE wildcard and an extremely common filename character, so an
197
+ // unescaped prefix makes `my_notes/` match `myXnotes/` and a folder move
198
+ // rewrite rows belonging to a different folder.
199
+ if (prefix !== null) { where.push("path LIKE ? ESCAPE '\\'"); params.push(`${likeEscape(prefix)}%`); }
200
+ if (sinceSeq !== null) { where.push('seq > ?'); params.push(sinceSeq); }
201
+ if (kind !== null) { where.push('kind = ?'); params.push(kind); }
202
+ return all(`SELECT * FROM nodes WHERE ${where.join(' AND ')} ORDER BY path`, ...params).map(toNode);
203
+ },
204
+
205
+ listAllNodes({ includeDeleted = false } = {}) {
206
+ const where = includeDeleted ? '' : 'WHERE deleted_at IS NULL';
207
+ return all(`SELECT * FROM nodes ${where} ORDER BY owner, path`).map(toNode);
208
+ },
209
+
210
+ listTrash() {
211
+ return all('SELECT * FROM nodes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC').map(toNode);
212
+ },
213
+
214
+ insertNode(node) {
215
+ const now = node.createdAt ?? Date.now();
216
+ run(
217
+ `INSERT INTO nodes (${NODE_COLUMNS.join(', ')})
218
+ VALUES (${NODE_COLUMNS.map(() => '?').join(', ')})`,
219
+ node.id,
220
+ node.owner ?? '',
221
+ node.path,
222
+ node.kind,
223
+ node.etag ?? null,
224
+ node.observedEtag ?? null,
225
+ node.bytes ?? null,
226
+ node.mtimeMs ?? null,
227
+ node.dev ?? null,
228
+ node.ino ?? null,
229
+ node.seq ?? store.nextSeq(),
230
+ now,
231
+ node.updatedAt ?? now,
232
+ node.deletedAt ?? null,
233
+ node.private ? 1 : 0,
234
+ node.isolated === null || node.isolated === undefined ? null : (node.isolated ? 1 : 0),
235
+ node.signups ? 1 : 0,
236
+ node.sourceId ?? null,
237
+ );
238
+ return store.getNodeById(node.id);
239
+ },
240
+
241
+ updateNode(id, patch) {
242
+ const keys = Object.keys(patch).filter((key) => key !== 'id');
243
+ if (!keys.length) return store.getNodeById(id);
244
+ const assignments = keys.map((key) => `${columnFor(key)} = ?`).join(', ');
245
+ run(`UPDATE nodes SET ${assignments} WHERE id = ?`, ...keys.map((key) => toBind(patch[key])), id);
246
+ return store.getNodeById(id);
247
+ },
248
+
249
+ tombstone(id, at) {
250
+ run('UPDATE nodes SET deleted_at = ? WHERE id = ?', at, id);
251
+ return store.getNodeById(id);
252
+ },
253
+
254
+ deleteNode(id) {
255
+ return run('DELETE FROM nodes WHERE id = ?', id).changes > 0;
256
+ },
257
+
258
+ getKv(key) {
259
+ const row = get('SELECT v FROM kv WHERE k = ?', key);
260
+ return row ? row.v : null;
261
+ },
262
+
263
+ setKv(key, value) {
264
+ run('INSERT INTO kv (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', key, value);
265
+ return value;
266
+ },
267
+
268
+ deleteKv(key) {
269
+ run('DELETE FROM kv WHERE k = ?', key);
270
+ },
271
+
272
+ // shares
273
+ insertShare(share) {
274
+ run(
275
+ `INSERT INTO shares (token_hash, node_id, grant_kind, label, created_at, expires_at, revoked_at, last_used_at)
276
+ VALUES (?, ?, ?, ?, ?, ?, NULL, NULL)`,
277
+ share.tokenHash, share.nodeId, share.grantKind, share.label ?? null,
278
+ share.createdAt, share.expiresAt ?? null,
279
+ );
280
+ return store.getShare(share.tokenHash);
281
+ },
282
+ getShare(tokenHash) {
283
+ const row = get('SELECT * FROM shares WHERE token_hash = ?', tokenHash);
284
+ if (!row) return null;
285
+ return {
286
+ tokenHash: row.token_hash,
287
+ nodeId: row.node_id,
288
+ grantKind: row.grant_kind,
289
+ label: row.label ?? null,
290
+ createdAt: row.created_at,
291
+ expiresAt: row.expires_at ?? null,
292
+ revokedAt: row.revoked_at ?? null,
293
+ lastUsedAt: row.last_used_at ?? null,
294
+ };
295
+ },
296
+ listShares(nodeId) {
297
+ return all('SELECT token_hash FROM shares WHERE node_id = ? ORDER BY created_at DESC', nodeId)
298
+ .map((row) => store.getShare(row.token_hash));
299
+ },
300
+ touchShare(tokenHash, at) {
301
+ run('UPDATE shares SET last_used_at = ? WHERE token_hash = ?', at, tokenHash);
302
+ },
303
+ revokeShare(tokenHash, at) {
304
+ return run('UPDATE shares SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL', at, tokenHash).changes > 0;
305
+ },
306
+
307
+ // sessions
308
+ insertSession(session) {
309
+ run(
310
+ 'INSERT INTO sessions (token_hash, principal, created_at, expires_at, last_seen_at) VALUES (?, ?, ?, ?, ?)',
311
+ session.tokenHash, session.principal, session.createdAt, session.expiresAt, session.createdAt,
312
+ );
313
+ return session;
314
+ },
315
+ getSession(tokenHash) {
316
+ const row = get('SELECT * FROM sessions WHERE token_hash = ?', tokenHash);
317
+ if (!row) return null;
318
+ return {
319
+ tokenHash: row.token_hash,
320
+ principal: row.principal,
321
+ createdAt: row.created_at,
322
+ expiresAt: row.expires_at,
323
+ lastSeenAt: row.last_seen_at ?? null,
324
+ };
325
+ },
326
+ touchSession(tokenHash, at) {
327
+ run('UPDATE sessions SET last_seen_at = ? WHERE token_hash = ?', at, tokenHash);
328
+ },
329
+ deleteSession(tokenHash) {
330
+ return run('DELETE FROM sessions WHERE token_hash = ?', tokenHash).changes > 0;
331
+ },
332
+ deleteSessionsFor(principal) {
333
+ return run('DELETE FROM sessions WHERE principal = ?', principal).changes;
334
+ },
335
+ purgeExpiredSessions(now) {
336
+ return run('DELETE FROM sessions WHERE expires_at < ?', now).changes;
337
+ },
338
+
339
+ // save tokens
340
+ insertSaveToken(record) {
341
+ run(
342
+ `INSERT INTO save_tokens (token, node_id, principal, share_hash, grant_kind, expires_at)
343
+ VALUES (?, ?, ?, ?, ?, ?)`,
344
+ record.token, record.nodeId, record.principal ?? null,
345
+ record.shareHash ?? null, record.grantKind, record.expiresAt,
346
+ );
347
+ return record;
348
+ },
349
+ getSaveToken(token) {
350
+ const row = get('SELECT * FROM save_tokens WHERE token = ?', token);
351
+ if (!row) return null;
352
+ return {
353
+ token: row.token,
354
+ nodeId: row.node_id,
355
+ principal: row.principal ?? null,
356
+ shareHash: row.share_hash ?? null,
357
+ grantKind: row.grant_kind,
358
+ expiresAt: row.expires_at,
359
+ };
360
+ },
361
+ findSaveToken({ nodeId, principal, shareHash, now }) {
362
+ const row = get(
363
+ `SELECT * FROM save_tokens
364
+ WHERE node_id = ? AND expires_at > ?
365
+ AND principal IS ? AND share_hash IS ?
366
+ ORDER BY expires_at DESC LIMIT 1`,
367
+ nodeId, now, principal ?? null, shareHash ?? null,
368
+ );
369
+ return row ? store.getSaveToken(row.token) : null;
370
+ },
371
+ deleteSaveTokensForNode(nodeId) {
372
+ return run('DELETE FROM save_tokens WHERE node_id = ?', nodeId).changes;
373
+ },
374
+ deleteSaveTokensForPrincipal(principal) {
375
+ return run('DELETE FROM save_tokens WHERE principal = ?', String(principal)).changes;
376
+ },
377
+ purgeExpiredSaveTokens(now) {
378
+ return run('DELETE FROM save_tokens WHERE expires_at < ?', now).changes;
379
+ },
380
+
381
+ // tenants
382
+ insertTenant(tenant) {
383
+ run(
384
+ `INSERT INTO tenants (username, password_hash, recovery_hash, source_id, created_at, approved_at, disabled_at, bytes_used)
385
+ VALUES (?, ?, ?, ?, ?, ?, NULL, 0)`,
386
+ tenant.username, tenant.passwordHash, tenant.recoveryHash ?? null,
387
+ tenant.sourceId ?? null, tenant.createdAt, tenant.approvedAt ?? null,
388
+ );
389
+ return store.getTenant(tenant.username);
390
+ },
391
+ getTenant(username) {
392
+ const row = get('SELECT * FROM tenants WHERE username = ?', username);
393
+ if (!row) return null;
394
+ return {
395
+ username: row.username,
396
+ passwordHash: row.password_hash,
397
+ recoveryHash: row.recovery_hash ?? null,
398
+ sourceId: row.source_id ?? null,
399
+ createdAt: row.created_at,
400
+ approvedAt: row.approved_at ?? null,
401
+ disabledAt: row.disabled_at ?? null,
402
+ bytesUsed: row.bytes_used ?? 0,
403
+ };
404
+ },
405
+ listTenants() {
406
+ return all('SELECT username FROM tenants ORDER BY created_at').map((row) => store.getTenant(row.username));
407
+ },
408
+ updateTenant(username, patch) {
409
+ const map = {
410
+ passwordHash: 'password_hash', recoveryHash: 'recovery_hash', sourceId: 'source_id',
411
+ approvedAt: 'approved_at', disabledAt: 'disabled_at', bytesUsed: 'bytes_used',
412
+ };
413
+ const keys = Object.keys(patch).filter((key) => map[key]);
414
+ if (!keys.length) return store.getTenant(username);
415
+ run(
416
+ `UPDATE tenants SET ${keys.map((key) => `${map[key]} = ?`).join(', ')} WHERE username = ?`,
417
+ ...keys.map((key) => toBind(patch[key])), username,
418
+ );
419
+ return store.getTenant(username);
420
+ },
421
+ deleteTenant(username) {
422
+ return run('DELETE FROM tenants WHERE username = ?', username).changes > 0;
423
+ },
424
+
425
+ // collections
426
+ insertCollection(record) {
427
+ run(
428
+ `INSERT INTO collections (node_id, slug, submit_token, accepting, role_map, created_at)
429
+ VALUES (?, ?, ?, ?, ?, ?)`,
430
+ record.nodeId, record.slug, record.submitToken,
431
+ record.accepting === false ? 0 : 1,
432
+ record.roleMap ? JSON.stringify(record.roleMap) : null,
433
+ record.createdAt,
434
+ );
435
+ return store.getCollection(record.nodeId);
436
+ },
437
+ getCollection(nodeId) {
438
+ const row = get('SELECT * FROM collections WHERE node_id = ?', nodeId);
439
+ return row ? toCollection(row) : null;
440
+ },
441
+ getCollectionBySlug(slug) {
442
+ const row = get('SELECT * FROM collections WHERE slug = ?', slug);
443
+ return row ? toCollection(row) : null;
444
+ },
445
+ listCollections() {
446
+ return all('SELECT * FROM collections ORDER BY created_at DESC').map(toCollection);
447
+ },
448
+ updateCollection(nodeId, patch) {
449
+ const map = { slug: 'slug', submitToken: 'submit_token', accepting: 'accepting', roleMap: 'role_map' };
450
+ const keys = Object.keys(patch).filter((key) => map[key]);
451
+ if (!keys.length) return store.getCollection(nodeId);
452
+ run(
453
+ `UPDATE collections SET ${keys.map((key) => `${map[key]} = ?`).join(', ')} WHERE node_id = ?`,
454
+ ...keys.map((key) => (key === 'roleMap'
455
+ ? (patch[key] ? JSON.stringify(patch[key]) : null)
456
+ : toBind(patch[key]))),
457
+ nodeId,
458
+ );
459
+ return store.getCollection(nodeId);
460
+ },
461
+ deleteCollection(nodeId) {
462
+ return run('DELETE FROM collections WHERE node_id = ?', nodeId).changes > 0;
463
+ },
464
+
465
+ // record auth
466
+ upsertRecordAuth(record) {
467
+ run(
468
+ `INSERT INTO record_auth (node_id, code_hash, failures, locked_until, deleted_at)
469
+ VALUES (?, ?, 0, NULL, NULL)
470
+ ON CONFLICT(node_id) DO UPDATE SET code_hash = excluded.code_hash, failures = 0, locked_until = NULL, deleted_at = NULL`,
471
+ record.nodeId, record.codeHash,
472
+ );
473
+ return store.getRecordAuth(record.nodeId);
474
+ },
475
+ getRecordAuth(nodeId) {
476
+ const row = get('SELECT * FROM record_auth WHERE node_id = ?', nodeId);
477
+ if (!row) return null;
478
+ return {
479
+ nodeId: row.node_id,
480
+ codeHash: row.code_hash,
481
+ failures: row.failures,
482
+ lockedUntil: row.locked_until ?? null,
483
+ deletedAt: row.deleted_at ?? null,
484
+ };
485
+ },
486
+ updateRecordAuth(nodeId, patch) {
487
+ const map = { codeHash: 'code_hash', failures: 'failures', lockedUntil: 'locked_until', deletedAt: 'deleted_at' };
488
+ const keys = Object.keys(patch).filter((key) => map[key]);
489
+ if (!keys.length) return store.getRecordAuth(nodeId);
490
+ run(
491
+ `UPDATE record_auth SET ${keys.map((key) => `${map[key]} = ?`).join(', ')} WHERE node_id = ?`,
492
+ ...keys.map((key) => toBind(patch[key])), nodeId,
493
+ );
494
+ return store.getRecordAuth(nodeId);
495
+ },
496
+ deleteRecordAuth(nodeId) {
497
+ return run('DELETE FROM record_auth WHERE node_id = ?', nodeId).changes > 0;
498
+ },
499
+
500
+ // operations journal
501
+ insertOperation(op) {
502
+ run(
503
+ 'INSERT INTO operations (id, kind, node_id, phase, payload_json, started_at) VALUES (?, ?, ?, ?, ?, ?)',
504
+ op.id, op.kind, op.nodeId ?? null, op.phase, JSON.stringify(op.payload ?? {}), op.startedAt,
505
+ );
506
+ return op;
507
+ },
508
+ setOperationPhase(id, phase) {
509
+ run('UPDATE operations SET phase = ? WHERE id = ?', phase, id);
510
+ },
511
+ deleteOperation(id) {
512
+ return run('DELETE FROM operations WHERE id = ?', id).changes > 0;
513
+ },
514
+ listOperations() {
515
+ return all('SELECT * FROM operations ORDER BY started_at').map((row) => ({
516
+ id: row.id,
517
+ kind: row.kind,
518
+ nodeId: row.node_id ?? null,
519
+ phase: row.phase,
520
+ payload: JSON.parse(row.payload_json),
521
+ startedAt: row.started_at,
522
+ }));
523
+ },
524
+ };
525
+
526
+ function toCollection(row) {
527
+ let roleMap = null;
528
+ if (row.role_map) { try { roleMap = JSON.parse(row.role_map); } catch { roleMap = null; } }
529
+ return {
530
+ nodeId: row.node_id,
531
+ slug: row.slug,
532
+ submitToken: row.submit_token,
533
+ accepting: !!row.accepting,
534
+ roleMap,
535
+ createdAt: row.created_at,
536
+ };
537
+ }
538
+
539
+ return store;
540
+ }
@@ -0,0 +1,50 @@
1
+ import path from 'upath';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { readdirIfExists, readFileTextIfExists, statIfExists } from './util/fsx.js';
4
+
5
+ // Starter documents. They ship as ordinary files in the package rather than as
6
+ // strings in a module, because they are meant to be read, copied and pillaged:
7
+ // a starter you cannot open in an editor teaches nothing.
8
+
9
+ const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'ui', 'templates');
10
+
11
+ const BLANK = `<!DOCTYPE html>
12
+ <html>
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <title>Untitled</title>
17
+ <script src="/_/clay.js"></script>
18
+ <style>
19
+ body { font: 16px/1.6 system-ui, sans-serif; max-width: 40rem; margin: 4rem auto; padding: 0 1rem; }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <h1 contenteditable>Untitled</h1>
24
+ <p contenteditable>Click Edit, change anything, then save. The file you are reading is the database.</p>
25
+ </body>
26
+ </html>
27
+ `;
28
+
29
+ export function createTemplates({ logger = console } = {}) {
30
+ async function list() {
31
+ const names = await readdirIfExists(TEMPLATES_DIR);
32
+ const found = names
33
+ .filter((name) => name.endsWith('.html'))
34
+ .map((name) => name.replace(/\.html$/, ''));
35
+ return found.includes('blank') ? found : ['blank', ...found];
36
+ }
37
+
38
+ async function read(name = 'blank') {
39
+ const safe = String(name || 'blank').replace(/[^a-z0-9-]/gi, '');
40
+ if (!safe) return BLANK;
41
+ const file = path.join(TEMPLATES_DIR, `${safe}.html`);
42
+ if (!(await statIfExists(file))) {
43
+ if (safe !== 'blank') logger.warn?.(`[makerclay] unknown template "${name}", using blank`);
44
+ return BLANK;
45
+ }
46
+ return (await readFileTextIfExists(file)) || BLANK;
47
+ }
48
+
49
+ return { list, read, dir: TEMPLATES_DIR };
50
+ }