lowlander 0.5.0 → 0.6.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.
Files changed (41) hide show
  1. package/README.md +43 -67
  2. package/build/client/client.d.ts +8 -1
  3. package/build/client/client.js +39 -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.js +238 -246
  9. package/build/dashboard/client/main.js.map +1 -1
  10. package/build/dashboard/dashboard.html +8 -8
  11. package/build/dashboard/server.d.ts +20 -9
  12. package/build/dashboard/server.d.ts.map +1 -1
  13. package/build/dashboard/server.js +139 -3
  14. package/build/dashboard/server.js.map +1 -1
  15. package/build/examples/helloworld/.edinburgh/commit_worker.log +1809 -0
  16. package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
  17. package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
  18. package/build/examples/helloworld/client/assets/style.css +0 -45
  19. package/build/examples/helloworld/client/index.html +2 -13
  20. package/build/examples/helloworld/client/js/base.d.ts +1 -4
  21. package/build/examples/helloworld/client/js/base.js +8 -217
  22. package/build/examples/helloworld/client/js/base.js.map +1 -1
  23. package/build/examples/helloworld/server/api.d.ts +4 -0
  24. package/build/examples/helloworld/server/api.d.ts.map +1 -1
  25. package/build/examples/helloworld/server/api.js +10 -0
  26. package/build/examples/helloworld/server/api.js.map +1 -1
  27. package/build/server/server.d.ts +3 -3
  28. package/build/server/server.d.ts.map +1 -1
  29. package/build/server/server.js +44 -5
  30. package/build/server/server.js.map +1 -1
  31. package/build/tsconfig.client.tsbuildinfo +1 -1
  32. package/build/tsconfig.server.tsbuildinfo +1 -1
  33. package/client/client.ts +41 -23
  34. package/dashboard/build-bundle.ts +8 -2
  35. package/dashboard/client/crud.ts +634 -0
  36. package/dashboard/client/main.ts +234 -246
  37. package/dashboard/server.ts +149 -5
  38. package/package.json +9 -5
  39. package/server/server.ts +43 -8
  40. package/skill/SKILL.md +30 -47
  41. package/skill/Connection_pruneCommitIds.md +0 -8
@@ -18,13 +18,111 @@ function passwordOk(provided: string): boolean {
18
18
  return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
19
19
  }
20
20
 
21
- function describeType(type: E.TypeWrapper<unknown>): { kind: string; display: string; linkedModel?: string } {
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;
22
41
  const linked = type.getLinkedModel();
23
- return {
24
- kind: (type as any).kind,
42
+
43
+ const info: TypeInfo = {
44
+ kind,
25
45
  display: type.toString(),
26
46
  linkedModel: linked?.tableName,
27
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
+ }
28
126
  }
29
127
 
30
128
  function describeIndex(index: any) {
@@ -38,18 +136,21 @@ function describeIndex(index: any) {
38
136
  }
39
137
 
40
138
  function describeModel(Model: E.AnyModelClass) {
41
- const fields: { name: string; type: ReturnType<typeof describeType>; description?: string; hasDefault: boolean }[] = [];
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 }[] = [];
42
142
  for (const [name, cfg] of Object.entries(Model.fields) as [string, E.FieldConfig<unknown>][]) {
43
143
  fields.push({
44
144
  name,
45
145
  type: describeType(cfg.type),
46
146
  description: cfg.description,
47
147
  hasDefault: cfg.default !== undefined,
148
+ isPk: pkFields.has(name),
48
149
  });
49
150
  }
50
151
  const indexes: { name: string; info: ReturnType<typeof describeIndex> }[] = [{
51
152
  name: '(primary)',
52
- info: describeIndex(Model),
153
+ info: pkIdx,
53
154
  }];
54
155
  for (const [name, idx] of Object.entries((Model as any)._secondaries || {})) {
55
156
  indexes.push({ name, info: describeIndex(idx) });
@@ -216,6 +317,49 @@ class DashboardAPI {
216
317
  return new ST(instance);
217
318
  }
218
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
+
219
363
  getDebugState(mode: 'channels' | 'sockets' | 'workers' | 'kv') {
220
364
  return warpsocket.getDebugState(mode as any);
221
365
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lowlander",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",
@@ -40,8 +40,10 @@
40
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",
41
41
  "typecheck": "npx tsc -b",
42
42
  "clean": "rm -rf build dist skill",
43
- "prepack": "npm install && npm run build",
43
+ "prepack": "npm run build",
44
44
  "test": "vitest run",
45
+ "test:e2e": "shotest test",
46
+ "test:e2e:review": "shotest review",
45
47
  "example": "npm run build && (cd examples/helloworld && npm install) && node build/examples/helloworld/server/main.js",
46
48
  "postinstall": "[ -f .local-post-install.sh ] && sh .local-post-install.sh || true"
47
49
  },
@@ -49,15 +51,17 @@
49
51
  "warpsocket": "^0.9.1"
50
52
  },
51
53
  "peerDependencies": {
52
- "aberdeen": "^1.15.0",
54
+ "aberdeen": "^1.17.1",
53
55
  "edinburgh": "^0.6.4"
54
56
  },
55
57
  "devDependencies": {
56
58
  "@types/node": "^20.19.41",
57
- "aberdeen": "^1.15.0",
59
+ "aberdeen": "^1.17.1",
58
60
  "esbuild": "^0.28.0",
59
- "readme-tsdoc": "^1.1.6",
61
+ "readme-tsdoc": "^1.1.8",
62
+ "shotest": "^1.6.1",
60
63
  "sirv": "^3.0.2",
64
+ "staffa": "^0.6.0",
61
65
  "tsx": "^4.0.0",
62
66
  "vitest": "^4.1.7"
63
67
  },
package/server/server.ts CHANGED
@@ -37,8 +37,8 @@ export function getStreamTypesForModel(Model: E.AnyModelClass): readonly (typeof
37
37
  * @internal
38
38
  */
39
39
  export abstract class StreamTypeBase<T> {
40
- /** @internal */
41
- 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 };
42
42
  /** @internal */
43
43
  static id: number;
44
44
  /** @internal */
@@ -136,7 +136,11 @@ function getIdForData(namespace: string, ...data: any): number {
136
136
 
137
137
  const countKey = new DataPack().write(namespace).toUint8Array();
138
138
  while(true) {
139
- // 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
+ }
140
144
  const countPack = warpsocket.getKey(countKey);
141
145
  const newCount = countPack ? new DataPack(countPack).readNumber() + 1 : 1;
142
146
  const newCountPack = new DataPack().write(newCount).toUint8Array();
@@ -190,25 +194,33 @@ export function createStreamType<T, S extends FieldSelection<T>>(
190
194
  Model: E.AnyModelClass & (new (...args: any[]) => T),
191
195
  selection: S & ValidateSelection<T, S>,
192
196
  options?: { cache?: number }
193
- ): { 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 } {
194
198
  let streamTypes = streamTypesPerModel.get(Model);
195
199
  if (!streamTypes) streamTypesPerModel.set(Model, streamTypes = []);
196
200
 
197
201
  const streamTypeId = getIdForData("streamType", Model.tableName, selection);
198
202
 
199
- const fields: Record<string, true|number> = {};
203
+ const fields: Record<string, boolean|number> = {};
200
204
  for(const prop of Array.from(Object.keys(selection)).sort() as (string & keyof S)[]) {
205
+ const sel = (selection as any)[prop];
206
+
201
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
+ }
202
214
  throw new Error(`Property ${prop} does not exist in model ${Model.name}`);
203
215
  }
204
216
  const LinkedModel = Model.fields[prop].type.getLinkedModel() as E.AnyModelClass | undefined;
205
217
 
206
- if (selection[prop] === true) {
218
+ if (sel === true) {
207
219
  if (LinkedModel) throw new Error(`Property ${prop} is a link; must specify sub-selection`);
208
220
  fields[prop] = true; // Include field without tracking links
209
221
  } else {
210
222
  if (!LinkedModel) throw new Error(`Property ${prop} is not a link; cannot specify sub-selection`);
211
- const SubStreamType = createStreamType(LinkedModel as any, selection[prop] as any)
223
+ const SubStreamType = createStreamType(LinkedModel as any, sel as any)
212
224
  fields[prop] = SubStreamType.id;
213
225
  }
214
226
  }
@@ -261,6 +273,14 @@ function updateLinkDeltas(value: any, linkDeltas: Map<E.Model<unknown>, Map<numb
261
273
  }
262
274
  }
263
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
+
264
284
  E.setOnSaveCallback((commitId: number, items: Map<E.Model<any>, E.Change>) => {
265
285
  if (logLevel >= 3) console.log('[lowlander] onSave', commitId);
266
286
  for(const [model, changed] of items.entries()) {
@@ -303,10 +323,24 @@ export function sendModel(target: Uint8Array | number | number[], model: E.Model
303
323
  else { // changed is an object or 'created'
304
324
  const linkDeltas = new Map<E.Model<unknown>, Map<number,number>>();
305
325
 
326
+ let oldClone: any;
327
+
306
328
  pack.writeCollection('object', (addRecord) => {
307
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
+
308
343
  if (typeof changed === 'object' && !changed.hasOwnProperty(fieldName)) continue;
309
- let streamIndex = StreamType.fields[fieldName];
310
344
 
311
345
  let fieldValue = (model as any)[fieldName];
312
346
  mustSend = true;
@@ -437,6 +471,7 @@ export class Socket<T> {
437
471
  return `{Socket id=${this.virtualSocketId}}`;
438
472
  }
439
473
 
474
+ /** @internal */
440
475
  [Symbol.for('nodejs.util.inspect.custom')]() {
441
476
  return this.toString();
442
477
  }
package/skill/SKILL.md CHANGED
@@ -35,6 +35,10 @@ npm run example
35
35
 
36
36
  Opens at http://localhost:8080 with the Aberdeen dashboard at http://localhost:8080/_dashboard (password printed to console on start).
37
37
 
38
+ This is what the example dashboard looks like:
39
+
40
+ ![dashboard screenshot](dashboard_screenshot.png)
41
+
38
42
  ## Tutorial
39
43
 
40
44
  ### Project Setup
@@ -158,6 +162,24 @@ const PersonStream = createStreamType(Person, fields, { cache: 30 }); // cache f
158
162
 
159
163
  After a stream with caching goes out of scope, the server keeps it alive for that many seconds, so that if the same stream is requested again with the same parameters, it can be reused instantly without re-sending initial data or re-subscribing to updates. Cached stream rpcs also deduplicate within that time window, so if the same stream is requested multiple times while it's still active or cached, only one stream is created on the server and shared among all requests.
160
164
 
165
+ #### Virtual (computed) fields
166
+
167
+ Plain getter properties on the model can be selected like any other field. Lowlander detects them and re-evaluates on each commit; an update is pushed only when the getter's return value actually changes:
168
+
169
+ ```ts
170
+ const Person = E.defineModel('Person', class {
171
+ name = E.field(E.string);
172
+ age = E.field(E.number);
173
+ get greeting() { return `Hi, I'm ${this.name} and I'm ${this.age}!`; }
174
+ }, { pk: 'name' });
175
+
176
+ const PersonStream = createStreamType(Person, {
177
+ greeting: true,
178
+ });
179
+ ```
180
+
181
+ On every model update, `greeting` will be invoked for both the old and new data, to check for changes. So avoid doing expensive operations in these getters.
182
+
161
183
  ### ServerProxy for Stateful APIs
162
184
 
163
185
  Wrap a class instance to expose per-connection stateful methods:
@@ -417,7 +439,7 @@ Starts the Lowlander WebSocket server.
417
439
 
418
440
  ### StreamTypeBase · abstract class
419
441
 
420
- [object Object],[object Object],[object Object]
442
+ Base class for stream types created by .
421
443
 
422
444
  **Type Parameters:**
423
445
 
@@ -425,7 +447,7 @@ Starts the Lowlander WebSocket server.
425
447
 
426
448
  #### StreamTypeBase.fields · static property
427
449
 
428
- **Type:** `{ [key: string]: number | true; }`
450
+ **Type:** `{ [key: string]: number | boolean; }`
429
451
 
430
452
  #### StreamTypeBase.id · static property
431
453
 
@@ -494,29 +516,9 @@ Transforms server-side API objects to client-side proxy objects with type-safe R
494
516
  WebSocket connection to a Lowlander server with type-safe RPC, automatic reconnection,
495
517
  and reactive updates.
496
518
 
497
- #### connection.ws · property
498
-
499
- **Type:** `WebSocket`
500
-
501
- #### connection.activeRequests · property
502
-
503
- **Type:** `Map<number, ActiveRequest>`
504
-
505
- #### connection.requestCounter · property
506
-
507
- **Type:** `number`
519
+ #### connection.[A.OPAQUE] · getter
508
520
 
509
- #### connection.reconnectAttempts · property
510
-
511
- **Type:** `number`
512
-
513
- #### connection.onlineProxy · property
514
-
515
- **Type:** `ValueRef<boolean>`
516
-
517
- #### connection.streamCache · property
518
-
519
- **Type:** `Map<string, StreamCacheEntry>`
521
+ **Type:** `true`
520
522
 
521
523
  #### connection.api · property
522
524
 
@@ -532,29 +534,10 @@ Returns the current connection status. Reactive in Aberdeen scopes.
532
534
 
533
535
  **Signature:** `() => boolean`
534
536
 
535
- #### connection.connect · method
536
-
537
- **Signature:** `() => void`
538
-
539
- #### connection.reconnect · method
540
-
541
- **Signature:** `() => void`
542
-
543
- #### [connection.pruneCommitIds](Connection_pruneCommitIds.md) · method
537
+ #### connection.getError · method
544
538
 
545
- #### connection.cancelRequest · method
539
+ Returns the last WebSocket error message, or `undefined` if there is none.
540
+ Clears automatically when the connection comes online. Reactive in Aberdeen scopes.
546
541
 
547
- **Signature:** `(request: ActiveRequest) => void`
548
-
549
- **Parameters:**
550
-
551
- - `request: ActiveRequest`
552
-
553
- #### connection.startLinger · method
554
-
555
- **Signature:** `(cached: StreamCacheEntry) => void`
556
-
557
- **Parameters:**
558
-
559
- - `cached: StreamCacheEntry`
542
+ **Signature:** `() => string`
560
543
 
@@ -1,8 +0,0 @@
1
- #### connection.pruneCommitIds · method
2
-
3
- **Signature:** `(request: ActiveRequest, maxCommitId: number) => void`
4
-
5
- **Parameters:**
6
-
7
- - `request: ActiveRequest`
8
- - `maxCommitId: number`