lowlander 0.4.0 → 0.6.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 (71) hide show
  1. package/README.md +108 -78
  2. package/build/client/client.d.ts +8 -1
  3. package/build/client/client.js +44 -22
  4. package/build/client/client.js.map +1 -1
  5. package/build/dashboard/client/crud.d.ts +16 -0
  6. package/build/dashboard/client/crud.js +525 -0
  7. package/build/dashboard/client/crud.js.map +1 -0
  8. package/build/dashboard/client/main.d.ts +1 -0
  9. package/build/dashboard/client/main.js +615 -0
  10. package/build/dashboard/client/main.js.map +1 -0
  11. package/build/dashboard/client/shim-server.d.ts +3 -0
  12. package/build/dashboard/client/shim-server.js +2 -0
  13. package/build/dashboard/client/shim-server.js.map +1 -0
  14. package/build/dashboard/dashboard.html +20 -0
  15. package/build/dashboard/index.d.ts +18 -0
  16. package/build/dashboard/index.d.ts.map +1 -0
  17. package/build/dashboard/index.js +50 -0
  18. package/build/dashboard/index.js.map +1 -0
  19. package/build/dashboard/serve.d.ts +18 -0
  20. package/build/dashboard/serve.d.ts.map +1 -0
  21. package/build/dashboard/serve.js +53 -0
  22. package/build/dashboard/serve.js.map +1 -0
  23. package/build/dashboard/server.d.ts +93 -0
  24. package/build/dashboard/server.d.ts.map +1 -0
  25. package/build/dashboard/server.js +384 -0
  26. package/build/dashboard/server.js.map +1 -0
  27. package/build/examples/helloworld/.edinburgh/commit_worker.log +4927 -0
  28. package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
  29. package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
  30. package/build/examples/helloworld/client/assets/style.css +0 -45
  31. package/build/examples/helloworld/client/index.html +3 -14
  32. package/build/examples/helloworld/client/js/base.css +1 -0
  33. package/build/examples/helloworld/client/js/base.d.ts +1 -4
  34. package/build/examples/helloworld/client/js/base.js +8 -71
  35. package/build/examples/helloworld/client/js/base.js.map +1 -1
  36. package/build/examples/helloworld/server/api.d.ts +8 -2
  37. package/build/examples/helloworld/server/api.d.ts.map +1 -1
  38. package/build/examples/helloworld/server/api.js +29 -8
  39. package/build/examples/helloworld/server/api.js.map +1 -1
  40. package/build/examples/helloworld/server/main.d.ts +1 -1
  41. package/build/examples/helloworld/server/main.d.ts.map +1 -1
  42. package/build/examples/helloworld/server/main.js +6 -17
  43. package/build/examples/helloworld/server/main.js.map +1 -1
  44. package/build/server/password.d.ts +10 -0
  45. package/build/server/password.d.ts.map +1 -0
  46. package/build/server/password.js +38 -0
  47. package/build/server/password.js.map +1 -0
  48. package/build/server/server.d.ts +5 -3
  49. package/build/server/server.d.ts.map +1 -1
  50. package/build/server/server.js +65 -7
  51. package/build/server/server.js.map +1 -1
  52. package/build/server/wshandler.d.ts +7 -1
  53. package/build/server/wshandler.d.ts.map +1 -1
  54. package/build/server/wshandler.js +54 -14
  55. package/build/server/wshandler.js.map +1 -1
  56. package/build/tsconfig.client.tsbuildinfo +1 -1
  57. package/build/tsconfig.server.tsbuildinfo +1 -1
  58. package/client/client.ts +47 -24
  59. package/dashboard/build-bundle.ts +44 -0
  60. package/dashboard/client/crud.ts +634 -0
  61. package/dashboard/client/index.html +12 -0
  62. package/dashboard/client/main.ts +554 -0
  63. package/dashboard/client/shim-server.ts +5 -0
  64. package/dashboard/index.ts +49 -0
  65. package/dashboard/server.ts +399 -0
  66. package/package.json +26 -11
  67. package/server/server.ts +61 -10
  68. package/server/wshandler.ts +57 -13
  69. package/skill/SKILL.md +82 -51
  70. package/skill/getStreamTypesForModel.md +7 -0
  71. package/skill/Connection_pruneCommitIds.md +0 -8
@@ -0,0 +1,399 @@
1
+ import * as E from 'edinburgh';
2
+ import { timingSafeEqual } from 'crypto';
3
+ import { ServerProxy, createStreamType, getStreamTypesForModel, warpsocket } from '../server/server.js';
4
+ import { getMainApi, getPassword } from '../server/wshandler.js';
5
+
6
+ // `modelRegistry` is a documented export of edinburgh's models module but is
7
+ // not re-exported from the package entry. Reach it by URL relative to the
8
+ // resolved package main — works in both Bun (.ts) and Node (built .js).
9
+ const _modelsModule = await import(new URL('./models.js', import.meta.resolve('edinburgh')).href) as {
10
+ modelRegistry: Record<string, E.AnyModelClass>;
11
+ };
12
+ const modelRegistry = _modelsModule.modelRegistry;
13
+
14
+
15
+ function passwordOk(provided: string): boolean {
16
+ const expected = getPassword();
17
+ if (typeof provided !== 'string' || provided.length !== expected.length) return false;
18
+ return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
19
+ }
20
+
21
+ /** Recursive type descriptor sent to the dashboard client for building CRUD editors. */
22
+ export interface TypeInfo {
23
+ kind: string;
24
+ display: string;
25
+ /** For 'link' kind: the linked model's tableName. */
26
+ linkedModel?: string;
27
+ /** For 'array', 'set', and opt('or' with undef) kinds: the element/inner type. */
28
+ inner?: TypeInfo;
29
+ /** For 'record' kind: the value type. */
30
+ innerValue?: TypeInfo;
31
+ /** For 'or' kind: all union choices (includes the undef literal for opt). */
32
+ choices?: TypeInfo[];
33
+ /** True when this is opt(T) — an or(undef, T) with exactly one non-undefined branch. */
34
+ isOptional?: boolean;
35
+ /** For 'literal' kind: the JSON-serialized literal value. */
36
+ literalValue?: string;
37
+ }
38
+
39
+ function describeType(type: E.TypeWrapper<unknown>): TypeInfo {
40
+ const kind = (type as any).kind as string;
41
+ const linked = type.getLinkedModel();
42
+
43
+ const info: TypeInfo = {
44
+ kind,
45
+ display: type.toString(),
46
+ linkedModel: linked?.tableName,
47
+ };
48
+
49
+ if (kind === 'array' || kind === 'set') {
50
+ info.inner = describeType((type as any).inner);
51
+ } else if (kind === 'record') {
52
+ info.innerValue = describeType((type as any).inner);
53
+ } else if (kind === 'or') {
54
+ const choices = (type as any).choices as E.TypeWrapper<unknown>[];
55
+ info.choices = choices.map(describeType);
56
+ const isUndefLiteral = (t: TypeInfo) => t.kind === 'literal' && t.literalValue === 'undefined';
57
+ const nonUndef = info.choices.filter(c => !isUndefLiteral(c));
58
+ const hasUndef = info.choices.some(c => isUndefLiteral(c));
59
+ if (hasUndef && nonUndef.length === 1) {
60
+ info.isOptional = true;
61
+ info.inner = nonUndef[0];
62
+ }
63
+ } else if (kind === 'literal') {
64
+ const val = (type as any).value;
65
+ info.literalValue = val === undefined ? 'undefined' : JSON.stringify(val);
66
+ }
67
+
68
+ return info;
69
+ }
70
+
71
+ function parseValueFromJson(type: E.TypeWrapper<unknown>, json: any): any {
72
+ if (json === null || json === undefined) return undefined;
73
+ const kind = (type as any).kind as string;
74
+ switch (kind) {
75
+ case 'string':
76
+ case 'id':
77
+ return typeof json === 'string' ? json : String(json);
78
+ case 'number':
79
+ return typeof json === 'number' ? json : Number(json);
80
+ case 'boolean':
81
+ return json === true || json === 'true' || json === 1;
82
+ case 'dateTime':
83
+ return new Date(json);
84
+ case 'link': {
85
+ const Model = type.getLinkedModel()!;
86
+ const pk = json?.__ref ? json.pk : json;
87
+ const pkArgs = Array.isArray(pk) ? pk : [pk];
88
+ const instance = (Model as any).get(...pkArgs);
89
+ if (!instance) throw new Error(`Linked ${Model.tableName} record not found: ${JSON.stringify(pk)}`);
90
+ return instance;
91
+ }
92
+ case 'array': {
93
+ const inner = (type as any).inner as E.TypeWrapper<unknown>;
94
+ if (!Array.isArray(json)) return [];
95
+ return json.map((v: any) => parseValueFromJson(inner, v));
96
+ }
97
+ case 'set': {
98
+ const inner = (type as any).inner as E.TypeWrapper<unknown>;
99
+ if (!Array.isArray(json)) return new Set();
100
+ return new Set(json.map((v: any) => parseValueFromJson(inner, v)));
101
+ }
102
+ case 'record': {
103
+ const inner = (type as any).inner as E.TypeWrapper<unknown>;
104
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return {};
105
+ const result: Record<string, any> = {};
106
+ for (const [k, v] of Object.entries(json)) result[k] = parseValueFromJson(inner, v);
107
+ return result;
108
+ }
109
+ case 'or': {
110
+ const choices = (type as any).choices as E.TypeWrapper<unknown>[];
111
+ const isUndefLiteral = (t: E.TypeWrapper<unknown>) =>
112
+ (t as any).kind === 'literal' && (t as any).value === undefined;
113
+ if (json === null || json === undefined) {
114
+ if (choices.some(isUndefLiteral)) return undefined;
115
+ return null;
116
+ }
117
+ const nonUndef = choices.filter(c => !isUndefLiteral(c));
118
+ if (nonUndef.length === 1) return parseValueFromJson(nonUndef[0]!, json);
119
+ return json;
120
+ }
121
+ case 'literal':
122
+ return (type as any).value;
123
+ default:
124
+ return json;
125
+ }
126
+ }
127
+
128
+ function describeIndex(index: any) {
129
+ const fields = Array.from(index._indexFields.keys()) as string[];
130
+ return {
131
+ fields,
132
+ fieldTypes: fields.map(f => describeType(index._indexFields.get(f))),
133
+ computed: !!index._computeFn,
134
+ kind: index._getTypeName() as 'primary' | 'unique' | 'secondary',
135
+ };
136
+ }
137
+
138
+ function describeModel(Model: E.AnyModelClass) {
139
+ const pkIdx = describeIndex(Model);
140
+ const pkFields = new Set(pkIdx.fields);
141
+ const fields: { name: string; type: TypeInfo; description?: string; hasDefault: boolean; isPk: boolean }[] = [];
142
+ for (const [name, cfg] of Object.entries(Model.fields) as [string, E.FieldConfig<unknown>][]) {
143
+ fields.push({
144
+ name,
145
+ type: describeType(cfg.type),
146
+ description: cfg.description,
147
+ hasDefault: cfg.default !== undefined,
148
+ isPk: pkFields.has(name),
149
+ });
150
+ }
151
+ const indexes: { name: string; info: ReturnType<typeof describeIndex> }[] = [{
152
+ name: '(primary)',
153
+ info: pkIdx,
154
+ }];
155
+ for (const [name, idx] of Object.entries((Model as any)._secondaries || {})) {
156
+ indexes.push({ name, info: describeIndex(idx) });
157
+ }
158
+ const streamTypes = getStreamTypesForModel(Model).map((ST: any) => ({
159
+ id: ST.id,
160
+ fields: ST.fields,
161
+ cache: ST.cache,
162
+ }));
163
+ return {
164
+ tableName: Model.tableName,
165
+ fields,
166
+ indexes,
167
+ streamTypes,
168
+ };
169
+ }
170
+
171
+ function serializeValue(v: any, depth = 0): any {
172
+ if (v === null || v === undefined) return v;
173
+ if (v instanceof E.Model) {
174
+ const cls: any = v.constructor;
175
+ const tn = cls.tableName || cls.name;
176
+ const pkBytes = (v as any).getPrimaryKey?.();
177
+ const pkArr = pkBytes && cls._pkToArray ? cls._pkToArray(pkBytes) : null;
178
+ const pk = pkArr ? (pkArr.length === 1 ? pkArr[0] : pkArr) : null;
179
+ return { __ref: tn, pk };
180
+ }
181
+ if (v instanceof Uint8Array) return Array.from(v.slice(0, 64)).map(b => b.toString(16).padStart(2, '0')).join('');
182
+ if (v instanceof Date) return v.toISOString();
183
+ if (v instanceof Set) return depth > 2 ? `Set(${v.size})` : Array.from(v).map(x => serializeValue(x, depth + 1));
184
+ if (Array.isArray(v)) return depth > 2 ? `Array(${v.length})` : v.map(x => serializeValue(x, depth + 1));
185
+ if (typeof v === 'object') {
186
+ if (depth > 2) return '...';
187
+ const out: Record<string, any> = {};
188
+ for (const k of Object.keys(v)) out[k] = serializeValue(v[k], depth + 1);
189
+ return out;
190
+ }
191
+ return v;
192
+ }
193
+
194
+ function instanceToPlain(instance: any): Record<string, any> {
195
+ const Model = instance.constructor as E.AnyModelClass;
196
+ const out: Record<string, any> = {};
197
+ for (const fieldName of Object.keys(Model.fields)) {
198
+ try {
199
+ out[fieldName] = serializeValue((instance as any)[fieldName]);
200
+ } catch (err: any) {
201
+ out[fieldName] = `<error: ${err?.message || err}>`;
202
+ }
203
+ }
204
+ return out;
205
+ }
206
+
207
+ function modelByName(name: string): E.AnyModelClass {
208
+ const Model = modelRegistry[name];
209
+ if (!Model) throw new Error(`Unknown model: ${name}`);
210
+ return Model;
211
+ }
212
+
213
+ function findIndex(Model: E.AnyModelClass, indexName: string): any {
214
+ if (indexName === '(primary)') return Model;
215
+ const idx = (Model as any)._secondaries?.[indexName];
216
+ if (!idx) throw new Error(`Unknown index ${indexName} on ${Model.tableName}`);
217
+ return idx;
218
+ }
219
+
220
+ // =====================================================================
221
+
222
+ class DashboardAPI {
223
+ listModels() {
224
+ return Object.keys(modelRegistry).sort().map(name => ({
225
+ tableName: name,
226
+ indexCount: 1 + Object.keys((modelRegistry[name] as any)._secondaries || {}).length,
227
+ fieldCount: Object.keys(modelRegistry[name].fields).length,
228
+ streamTypeCount: getStreamTypesForModel(modelRegistry[name]).length,
229
+ }));
230
+ }
231
+
232
+ getModel(name: string) {
233
+ return describeModel(modelByName(name));
234
+ }
235
+
236
+ listApiMethods() {
237
+ const api = getMainApi();
238
+ if (!api) return [];
239
+ const out: { name: string; kind: 'function' | 'value' }[] = [];
240
+ for (const key of Object.keys(api).sort()) {
241
+ if (key === '_dashboard' || key.startsWith('_')) continue;
242
+ const v = (api as any)[key];
243
+ out.push({ name: key, kind: typeof v === 'function' ? 'function' : 'value' });
244
+ }
245
+ return out;
246
+ }
247
+
248
+ getApiMethodSource(name: string): string | undefined {
249
+ const api = getMainApi();
250
+ const fn = api && (api as any)[name];
251
+ if (typeof fn !== 'function') return undefined;
252
+ try {
253
+ // Bundlers usually preserve enough source for a useful preview
254
+ const s = String(fn);
255
+ return s.length > 4000 ? s.slice(0, 4000) + '\n/* … truncated */' : s;
256
+ } catch {
257
+ return undefined;
258
+ }
259
+ }
260
+
261
+ findRecords(modelName: string, indexName: string, opts: {
262
+ search?: any;
263
+ reverse?: boolean;
264
+ limit?: number;
265
+ } = {}) {
266
+ const Model = modelByName(modelName);
267
+ const idx = findIndex(Model, indexName);
268
+ const fieldNames = Array.from(idx._indexFields.keys()) as string[];
269
+
270
+ const findOpts: any = {};
271
+ if (opts.search !== undefined && opts.search !== '') {
272
+ findOpts.is = opts.search;
273
+ }
274
+ if (opts.reverse) findOpts.reverse = true;
275
+
276
+ const limit = Math.min(opts.limit ?? 10, 1000);
277
+ let iter: Iterable<any>;
278
+ try { iter = idx.find(findOpts); }
279
+ catch { return { rows: [], indexFields: fieldNames, scanned: 0 }; }
280
+ const results: { pk: any; values: Record<string, any> }[] = [];
281
+ let scanned = 0;
282
+ for (const row of iter) {
283
+ scanned++;
284
+ const plain = instanceToPlain(row);
285
+ const cls: any = (row as any).constructor;
286
+ const pkBytes = (row as any).getPrimaryKey ? (row as any).getPrimaryKey() : null;
287
+ const pkArr = pkBytes && cls._pkToArray ? cls._pkToArray(pkBytes) : null;
288
+ const pk = pkArr ? (pkArr.length === 1 ? pkArr[0] : pkArr) : null;
289
+ results.push({
290
+ pk: serializeValue(pk),
291
+ values: plain,
292
+ });
293
+ if (results.length >= limit) break;
294
+ }
295
+ return {
296
+ rows: results,
297
+ indexFields: fieldNames,
298
+ scanned,
299
+ };
300
+ }
301
+
302
+ getRecord(modelName: string, pk: any) {
303
+ const Model = modelByName(modelName);
304
+ const pkArgs = Array.isArray(pk) ? pk : [pk];
305
+ const instance = (Model as any).get(...pkArgs);
306
+ if (!instance) return undefined;
307
+ return instanceToPlain(instance);
308
+ }
309
+
310
+ streamRecord(modelName: string, streamTypeId: number, pk: any) {
311
+ const Model = modelByName(modelName);
312
+ const ST = getStreamTypesForModel(Model).find((s: any) => s.id === streamTypeId) as any;
313
+ if (!ST) throw new Error(`No stream type with id ${streamTypeId} for ${modelName}`);
314
+ const pkArgs = Array.isArray(pk) ? pk : [pk];
315
+ const instance = (Model as any).get(...pkArgs);
316
+ if (!instance) throw new Error('Record not found');
317
+ return new ST(instance);
318
+ }
319
+
320
+ async createRecord(modelName: string, values: Record<string, any>): Promise<any> {
321
+ const Model = modelByName(modelName);
322
+ const parsed: Record<string, any> = {};
323
+ for (const [fieldName, jsonValue] of Object.entries(values)) {
324
+ const fieldConfig = Model.fields[fieldName] as E.FieldConfig<unknown> | undefined;
325
+ if (!fieldConfig) continue;
326
+ if ((fieldConfig.type as any).kind === 'id') continue; // auto-generated
327
+ const v = parseValueFromJson(fieldConfig.type, jsonValue);
328
+ if (v !== undefined) parsed[fieldName] = v;
329
+ }
330
+ return await E.transact(() => {
331
+ const instance = new (Model as any)(parsed);
332
+ const cls: any = instance.constructor;
333
+ const pkBytes = instance.getPrimaryKey?.();
334
+ const pkArr = pkBytes && cls._pkToArray ? cls._pkToArray(pkBytes) : null;
335
+ const pk = pkArr ? (pkArr.length === 1 ? pkArr[0] : pkArr) : null;
336
+ return serializeValue(pk);
337
+ });
338
+ }
339
+
340
+ async updateRecord(modelName: string, pk: any, values: Record<string, any>): Promise<void> {
341
+ const Model = modelByName(modelName);
342
+ const pkArgs = Array.isArray(pk) ? pk : [pk];
343
+ const instance = (Model as any).get(...pkArgs);
344
+ if (!instance) throw new Error(`Record not found: ${JSON.stringify(pk)}`);
345
+ await E.transact(() => {
346
+ for (const [fieldName, jsonValue] of Object.entries(values)) {
347
+ const fieldConfig = Model.fields[fieldName] as E.FieldConfig<unknown> | undefined;
348
+ if (!fieldConfig) continue;
349
+ if ((fieldConfig.type as any).kind === 'id') continue; // immutable
350
+ (instance as any)[fieldName] = parseValueFromJson(fieldConfig.type, jsonValue);
351
+ }
352
+ });
353
+ }
354
+
355
+ async deleteRecord(modelName: string, pk: any): Promise<void> {
356
+ const Model = modelByName(modelName);
357
+ const pkArgs = Array.isArray(pk) ? pk : [pk];
358
+ const instance = (Model as any).get(...pkArgs);
359
+ if (!instance) throw new Error(`Record not found: ${JSON.stringify(pk)}`);
360
+ await E.transact(() => { instance.delete(); });
361
+ }
362
+
363
+ getDebugState(mode: 'channels' | 'sockets' | 'workers' | 'kv') {
364
+ return warpsocket.getDebugState(mode as any);
365
+ }
366
+ }
367
+
368
+ const DashboardProxyValue = 'authenticated';
369
+
370
+ /**
371
+ * RPC entry point. Re-export this from your top-level api file:
372
+ *
373
+ * ```ts
374
+ * export { _dashboard } from 'lowlander/dashboard';
375
+ * ```
376
+ *
377
+ * On first call, a password is generated (or reused from the
378
+ * `LOWLANDER_DASHBOARD_PASSWORD` env var) and printed to the server console.
379
+ */
380
+ export function _dashboard(password: string) {
381
+ if (!passwordOk(password)) {
382
+ throw new Error('Invalid dashboard password');
383
+ }
384
+ return new ServerProxy(new DashboardAPI(), DashboardProxyValue);
385
+ }
386
+
387
+ // Re-export so callers don't need a separate import
388
+ export type { DashboardAPI };
389
+
390
+ // Convenience stream type used internally so live record viewing works
391
+ // for arbitrary models without the developer having to pre-create one.
392
+ // (Built lazily per model+selection because createStreamType is keyed on the
393
+ // shape of the selection.)
394
+ const allFieldsStreams = new Map<string, ReturnType<typeof createStreamType>>();
395
+ export function _dashboardAllFieldsStream(modelName: string) {
396
+ // Currently unused; kept for future "view live" feature.
397
+ void allFieldsStreams;
398
+ void modelName;
399
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lowlander",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "TypeScript framework for data persistence, type-safe RPCs and real-time (partial) client synchronization.",
5
5
  "type": "module",
6
6
  "main": "./build/server/server.js",
@@ -21,34 +21,49 @@
21
21
  "bun": "./server/server.ts",
22
22
  "import": "./build/server/server.js",
23
23
  "types": "./build/server/server.d.ts"
24
+ },
25
+ "./dashboard": {
26
+ "bun": "./dashboard/index.ts",
27
+ "import": "./build/dashboard/index.js",
28
+ "types": "./build/dashboard/index.d.ts"
24
29
  }
25
30
  },
26
31
  "files": [
27
32
  "build/",
28
33
  "server/",
29
34
  "client/",
35
+ "dashboard/",
30
36
  "skill/"
31
37
  ],
32
38
  "scripts": {
33
- "build": "tsc -b && cp -rf examples/helloworld/client/assets examples/helloworld/client/index.html build/examples/helloworld/client/ && npm run build:docs",
39
+ "build": "tsc -b && node --import=tsx/esm dashboard/build-bundle.ts && node --import=tsx/esm examples/helloworld/build-client.ts && npm run build:docs",
34
40
  "build:docs": "rm -rf skill ; mkdir skill ; cat skill-header.md README.md > skill/SKILL.md && readme-tsdoc --split --file skill/SKILL.md && readme-tsdoc --repo-url https://github.com/vanviegen/lowlander",
35
- "typecheck": "bun x tsc -b",
41
+ "typecheck": "npx tsc -b",
36
42
  "clean": "rm -rf build dist skill",
37
- "prepack": "bun install && bun run build",
38
- "test": "bun test",
43
+ "prepack": "npm run build",
44
+ "test": "vitest run",
45
+ "test:e2e": "shotest test",
46
+ "test:e2e:review": "shotest review",
47
+ "example": "npm run build && (cd examples/helloworld && npm install) && node build/examples/helloworld/server/main.js",
39
48
  "postinstall": "[ -f .local-post-install.sh ] && sh .local-post-install.sh || true"
40
49
  },
41
50
  "dependencies": {
42
- "warpsocket": "^0.8.3"
51
+ "warpsocket": "^0.9.1"
43
52
  },
44
53
  "peerDependencies": {
45
- "aberdeen": "^1.10.1",
46
- "edinburgh": "^0.6.1"
54
+ "aberdeen": "^1.17.1",
55
+ "edinburgh": "^0.6.4"
47
56
  },
48
57
  "devDependencies": {
49
- "@types/bun": "^1.3.10",
50
- "aberdeen": "^1.10.1",
51
- "readme-tsdoc": "^1.1.6"
58
+ "@types/node": "^20.19.41",
59
+ "aberdeen": "^1.17.1",
60
+ "esbuild": "^0.28.0",
61
+ "readme-tsdoc": "^1.1.8",
62
+ "shotest": "^1.6.1",
63
+ "sirv": "^3.0.2",
64
+ "staffa": "0.3.1",
65
+ "tsx": "^4.0.0",
66
+ "vitest": "^4.1.7"
52
67
  },
53
68
  "workspaces": [
54
69
  "examples/*"
package/server/server.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import * as E from "edinburgh";
2
2
  import DataPack from "edinburgh/datapack";
3
3
  import * as realWarpsocket from 'warpsocket';
4
+ import { randomBytes } from 'crypto';
5
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
6
+ import { join } from 'path';
4
7
 
5
8
  // Get log level from environment variable
6
9
  // 0: no logging (default)
@@ -22,6 +25,11 @@ const CHANNEL_TYPE_MODEL = 1;
22
25
 
23
26
  /** @internal Registry mapping model classes to their stream types */
24
27
  const streamTypesPerModel: Map<E.AnyModelClass, typeof StreamTypeBase<unknown>[]> = new Map();
28
+
29
+ /** @internal Used by the dashboard module to inspect registered stream types. */
30
+ export function getStreamTypesForModel(Model: E.AnyModelClass): readonly (typeof StreamTypeBase<unknown>)[] {
31
+ return streamTypesPerModel.get(Model) || [];
32
+ }
25
33
 
26
34
  /**
27
35
  * Base class for stream types created by {@link createStreamType}.
@@ -29,8 +37,8 @@ const streamTypesPerModel: Map<E.AnyModelClass, typeof StreamTypeBase<unknown>[]
29
37
  * @internal
30
38
  */
31
39
  export abstract class StreamTypeBase<T> {
32
- /** @internal */
33
- static fields: { [key: string]: true|number };
40
+ /** @internal `true`=plain field, number=sub-stream id, `false`=virtual getter */
41
+ static fields: { [key: string]: boolean|number };
34
42
  /** @internal */
35
43
  static id: number;
36
44
  /** @internal */
@@ -128,7 +136,11 @@ function getIdForData(namespace: string, ...data: any): number {
128
136
 
129
137
  const countKey = new DataPack().write(namespace).toUint8Array();
130
138
  while(true) {
131
- // Insert a new stream type
139
+ // Another worker may have won the race since we last checked
140
+ const existingPack = warpsocket.getKey(dataKey);
141
+ if (existingPack) {
142
+ return new DataPack(existingPack).readNumber();
143
+ }
132
144
  const countPack = warpsocket.getKey(countKey);
133
145
  const newCount = countPack ? new DataPack(countPack).readNumber() + 1 : 1;
134
146
  const newCountPack = new DataPack().write(newCount).toUint8Array();
@@ -182,25 +194,33 @@ export function createStreamType<T, S extends FieldSelection<T>>(
182
194
  Model: E.AnyModelClass & (new (...args: any[]) => T),
183
195
  selection: S & ValidateSelection<T, S>,
184
196
  options?: { cache?: number }
185
- ): { new(instance: T): StreamTypeBase<Project<T, S>>; id: number; fields: Record<string, true|number>; cache?: number } {
197
+ ): { new(instance: T): StreamTypeBase<Project<T, S>>; id: number; fields: Record<string, boolean|number>; cache?: number } {
186
198
  let streamTypes = streamTypesPerModel.get(Model);
187
199
  if (!streamTypes) streamTypesPerModel.set(Model, streamTypes = []);
188
200
 
189
201
  const streamTypeId = getIdForData("streamType", Model.tableName, selection);
190
202
 
191
- const fields: Record<string, true|number> = {};
203
+ const fields: Record<string, boolean|number> = {};
192
204
  for(const prop of Array.from(Object.keys(selection)).sort() as (string & keyof S)[]) {
205
+ const sel = (selection as any)[prop];
206
+
193
207
  if (!Model.fields.hasOwnProperty(prop)) {
208
+ // Allow selecting a getter on the model prototype as a virtual field
209
+ const desc = Object.getOwnPropertyDescriptor((Model as any).prototype, prop);
210
+ if (desc?.get && sel === true) {
211
+ fields[prop] = false;
212
+ continue;
213
+ }
194
214
  throw new Error(`Property ${prop} does not exist in model ${Model.name}`);
195
215
  }
196
216
  const LinkedModel = Model.fields[prop].type.getLinkedModel() as E.AnyModelClass | undefined;
197
217
 
198
- if (selection[prop] === true) {
218
+ if (sel === true) {
199
219
  if (LinkedModel) throw new Error(`Property ${prop} is a link; must specify sub-selection`);
200
220
  fields[prop] = true; // Include field without tracking links
201
221
  } else {
202
222
  if (!LinkedModel) throw new Error(`Property ${prop} is not a link; cannot specify sub-selection`);
203
- const SubStreamType = createStreamType(LinkedModel as any, selection[prop] as any)
223
+ const SubStreamType = createStreamType(LinkedModel as any, sel as any)
204
224
  fields[prop] = SubStreamType.id;
205
225
  }
206
226
  }
@@ -209,7 +229,9 @@ export function createStreamType<T, S extends FieldSelection<T>>(
209
229
  static id = streamTypeId;
210
230
  static cache = options?.cache;
211
231
  }
212
- streamTypes.push(StreamType);
232
+ if (!streamTypes.some(st => (st as any).id === streamTypeId)) {
233
+ streamTypes.push(StreamType);
234
+ }
213
235
  return StreamType as any;
214
236
  }
215
237
 
@@ -251,6 +273,14 @@ function updateLinkDeltas(value: any, linkDeltas: Map<E.Model<unknown>, Map<numb
251
273
  }
252
274
  }
253
275
 
276
+ /** Loose deep equality for getter result comparison (primitives via Object.is, objects via JSON). */
277
+ function valuesEqual(a: any, b: any): boolean {
278
+ if (Object.is(a, b)) return true;
279
+ if (a == null || b == null) return false;
280
+ if (typeof a !== 'object' || typeof b !== 'object') return false;
281
+ try { return JSON.stringify(a) === JSON.stringify(b); } catch { return false; }
282
+ }
283
+
254
284
  E.setOnSaveCallback((commitId: number, items: Map<E.Model<any>, E.Change>) => {
255
285
  if (logLevel >= 3) console.log('[lowlander] onSave', commitId);
256
286
  for(const [model, changed] of items.entries()) {
@@ -293,10 +323,24 @@ export function sendModel(target: Uint8Array | number | number[], model: E.Model
293
323
  else { // changed is an object or 'created'
294
324
  const linkDeltas = new Map<E.Model<unknown>, Map<number,number>>();
295
325
 
326
+ let oldClone: any;
327
+
296
328
  pack.writeCollection('object', (addRecord) => {
297
329
  for(const fieldName in StreamType.fields) {
330
+ const streamIndex = StreamType.fields[fieldName];
331
+
332
+ if (streamIndex === false) {
333
+ const newVal = (model as any)[fieldName];
334
+ if (typeof changed === 'object') {
335
+ oldClone ??= Object.assign(Object.create(model), changed);
336
+ if (valuesEqual(oldClone[fieldName], newVal)) continue;
337
+ }
338
+ addRecord(fieldName, newVal);
339
+ mustSend = true;
340
+ continue;
341
+ }
342
+
298
343
  if (typeof changed === 'object' && !changed.hasOwnProperty(fieldName)) continue;
299
- let streamIndex = StreamType.fields[fieldName];
300
344
 
301
345
  let fieldValue = (model as any)[fieldName];
302
346
  mustSend = true;
@@ -427,6 +471,7 @@ export class Socket<T> {
427
471
  return `{Socket id=${this.virtualSocketId}}`;
428
472
  }
429
473
 
474
+ /** @internal */
430
475
  [Symbol.for('nodejs.util.inspect.custom')]() {
431
476
  return this.toString();
432
477
  }
@@ -454,10 +499,16 @@ export async function start(mainApiFile: string, opts: {bind?: string, threads?:
454
499
  if (opts.injectWarpSocket) {
455
500
  warpsocket = opts.injectWarpSocket;
456
501
  }
502
+ const pwFile = join(process.cwd(), '.lowlander_dashboard_password');
503
+ if (!existsSync(pwFile)) try { writeFileSync(pwFile, randomBytes(24).toString('base64url'), { flag: 'wx' }); } catch {}
504
+ const password = process.env.LOWLANDER_DASHBOARD_PASSWORD ?? readFileSync(pwFile, 'utf8').trim();
505
+ if (!process.env.LOWLANDER_DASHBOARD_PASSWORD) {
506
+ console.log(`\n${'='.repeat(56)}\n Lowlander dashboard password: ${password}\n${'='.repeat(56)}\n`);
507
+ }
457
508
  await warpsocket.start({
458
509
  bind: opts.bind || '0.0.0.0:8080',
459
510
  threads: opts.threads,
460
511
  workerPath: WSHANDLER_FILE,
461
- workerArg: mainApiFile,
512
+ workerArg: { apiFile: mainApiFile, password },
462
513
  });
463
514
  }