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
package/client/client.ts CHANGED
@@ -200,9 +200,12 @@ export class Connection<T> {
200
200
  private reconnectAttempts = 0;
201
201
  /** @internal */
202
202
  public _proxyCounter = 0;
203
- private onlineProxy = A.proxy(false);
203
+ private $state = A.proxy({online: false, error: undefined as undefined|string});
204
204
  private streamCache = new Map<string, StreamCacheEntry>();
205
205
 
206
+ /** @internal - allows storing Connection instances in Aberdeen proxies without re-proxying */
207
+ get [A.OPAQUE]() { return true as const; }
208
+
206
209
  /**
207
210
  * Type-safe proxy to the server-side API. Methods return `PromiseProxy` objects
208
211
  * that work reactively in Aberdeen scopes. `ServerProxy` returns include a
@@ -221,22 +224,36 @@ export class Connection<T> {
221
224
  /**
222
225
  * Returns the current connection status. Reactive in Aberdeen scopes.
223
226
  */
224
- isOnline(): boolean { return this.onlineProxy.value; }
227
+ isOnline(): boolean { return this.$state.online; }
228
+
229
+ /**
230
+ * Returns the last WebSocket error message, or `undefined` if there is none.
231
+ * Clears automatically when the connection comes online. Reactive in Aberdeen scopes.
232
+ */
233
+ getError(): string | undefined { return this.$state.error; }
225
234
 
226
235
  private connect() {
227
- const ws: WebSocket = this.ws = typeof this.url === 'string'
228
- ? new WebSocket(this.url)
229
- : this.url();
236
+ let ws: WebSocket;
237
+ try {
238
+ ws = this.ws = typeof this.url === 'string'
239
+ ? new WebSocket(this.url)
240
+ : this.url();
241
+ } catch (error: any) {
242
+ console.error('WebSocket connection error:', error);
243
+ this.$state.error = error?.message || 'WebSocket connection error';
244
+ return;
245
+ }
230
246
  ws.binaryType = "arraybuffer";
231
247
  if (logLevel >= 1) console.log(`[lowlander] Connecting to WebSocket at ${typeof this.url === 'string' ? this.url : '[custom WebSocket]'}`);
232
248
 
233
249
  ws.onopen = () => {
234
250
  if (ws !== this.ws) return; // No longer the current connection
235
251
  if (logLevel >= 1) console.log('[lowlander] WebSocket connected');
236
- this.onlineProxy.value = true;
252
+ this.$state.online = true;
253
+ this.$state.error = undefined;
237
254
  this.reconnectAttempts = 0;
238
255
  for(const request of this.activeRequests.values()) {
239
- request.resultProxy.busy = true;
256
+ request.$result.busy = true;
240
257
  this.ws!.send(request.requestBuffer as Uint8Array<ArrayBuffer>);
241
258
  }
242
259
  };
@@ -250,6 +267,7 @@ export class Connection<T> {
250
267
  ws.onerror = (error: any) => {
251
268
  if (ws !== this.ws) return; // No longer the current connection
252
269
  console.error('WebSocket error:', error);
270
+ this.$state.error = error?.message || 'WebSocket connection error';
253
271
  this.reconnect();
254
272
  };
255
273
 
@@ -261,7 +279,7 @@ export class Connection<T> {
261
279
 
262
280
  const request = this.activeRequests.get(requestId);
263
281
  if (!request) return; // Raced
264
- const result = A.unproxy(request.resultProxy);
282
+ const result = A.unproxy(request.$result);
265
283
 
266
284
  const type = pack.read();
267
285
  if (typeof type === 'number') {
@@ -318,12 +336,12 @@ export class Connection<T> {
318
336
  } else {
319
337
  // Create new object
320
338
  if (prevCommitIds && commitId < (prevCommitIds.get(DEFAULT_COMMIT) ?? -1)) return; // Stale create
321
- const entry = A.proxy(delta);
339
+ const $entry = A.proxy(delta);
322
340
  // OPAQUE=false: excluded from A.copy recursion (so this proxy is never
323
341
  // corrupted by being overwritten with data from a different linked model
324
342
  // at the same array index), but still wrapped in a proxy on read.
325
- (A.unproxy(entry) as any)[A.OPAQUE] = false;
326
- request.database.set(dbKeyHash, entry);
343
+ (A.unproxy($entry) as any)[A.OPAQUE] = false;
344
+ request.database.set(dbKeyHash, $entry);
327
345
  request.commitIds.set(dbKeyHash, new Map([[DEFAULT_COMMIT, commitId]]));
328
346
  }
329
347
  return;
@@ -332,11 +350,11 @@ export class Connection<T> {
332
350
  // Each request should get one of these packet types as a response
333
351
  if (type === SERVER_MESSAGES.error) {
334
352
  const errorMessage = pack.readString();
335
- request.resultProxy.error = new Error(errorMessage);
353
+ request.$result.error = new Error(errorMessage);
336
354
  if (logLevel >= 2) console.log(`[lowlander] incoming error requestId=${requestId} message=${errorMessage}`);
337
355
 
338
356
  } else if (type === SERVER_MESSAGES.response || type === SERVER_MESSAGES.response_proxy) {
339
- request.resultProxy.value = pack.read();
357
+ request.$result.value = pack.read();
340
358
  request.virtualSocketIds = pack.read() as number[] | undefined;
341
359
  request.hasServerProxy = type === SERVER_MESSAGES.response_proxy;
342
360
  if (logLevel >= 2) console.log(`[lowlander] incoming response requestId=${requestId} value=${result.value} virtualSocketIds=${request.virtualSocketIds} hasServerProxy=${request.hasServerProxy}`);
@@ -349,9 +367,9 @@ export class Connection<T> {
349
367
  request.hasServerProxy = type === SERVER_MESSAGES.response_proxy_model;
350
368
  if (logLevel >= 2) console.log(`[lowlander] incoming ${request.hasServerProxy ? 'response_proxy_model' : 'response_model'} requestId=${requestId} dbKey=${dbKey} cacheMs=${cacheMs} obj=${obj}`);
351
369
  if (obj) {
352
- request.resultProxy.value = A.proxy(obj);
370
+ request.$result.value = A.proxy(obj);
353
371
  } else {
354
- request.resultProxy.error = new Error('Unknown database key ' + dbKey);
372
+ request.$result.error = new Error('Unknown database key ' + dbKey);
355
373
  }
356
374
  if (cacheMs !== undefined && request.cacheKey && !this.streamCache.has(request.cacheKey)) {
357
375
  this.streamCache.set(request.cacheKey, {
@@ -370,10 +388,10 @@ export class Connection<T> {
370
388
  }
371
389
 
372
390
  if (!request.hasServerProxy) {
373
- delete (request.resultProxy as any).serverProxy;
391
+ delete (request.$result as any).serverProxy;
374
392
  }
375
393
 
376
- request.resultProxy.busy = false;
394
+ request.$result.busy = false;
377
395
 
378
396
  if (request.resolve) {
379
397
  // This does not happen on reconnect
@@ -392,7 +410,7 @@ export class Connection<T> {
392
410
  private reconnect() {
393
411
  this.ws = undefined;
394
412
 
395
- this.onlineProxy.value = false;
413
+ this.$state.online = false;
396
414
 
397
415
  if (typeof this.url !== 'string') return; // No reconnect in test mode
398
416
 
@@ -438,12 +456,12 @@ export class Connection<T> {
438
456
  if (cached.refCount <= 0) this.startLinger(cached);
439
457
  });
440
458
 
441
- return cached.request.resultProxy;
459
+ return cached.request.$result;
442
460
  }
443
461
  }
444
462
 
445
463
  const result = {busy: true} as PromiseProxy<any> & {promise: Promise<any>} & {serverProxy: any};
446
- const resultProxy = A.proxy(result);
464
+ const $result = A.proxy(result);
447
465
 
448
466
  const requestId = ++this.requestCounter;
449
467
 
@@ -463,7 +481,7 @@ export class Connection<T> {
463
481
  }
464
482
  });
465
483
 
466
- const request: ActiveRequest = { resultProxy, requestBuffer: pack.toUint8Array(true), requestId, connection: this, callbacks, cacheKey };
484
+ const request: ActiveRequest = { $result, requestBuffer: pack.toUint8Array(true), requestId, connection: this, callbacks, cacheKey };
467
485
  result.serverProxy = new Proxy(request, proxyHandlers);
468
486
  result.promise = new Promise((resolve, reject) => {
469
487
  request.resolve = resolve;
@@ -489,7 +507,7 @@ export class Connection<T> {
489
507
  this.cancelRequest(request);
490
508
  });
491
509
 
492
- return resultProxy;
510
+ return $result;
493
511
  }
494
512
  }
495
513
 
@@ -520,7 +538,12 @@ export class Connection<T> {
520
538
  }
521
539
 
522
540
  const proxyHandlers: ProxyHandler<any> = {
523
- get(target: ProxyTargetType, prop: string) {
541
+ get(target: ProxyTargetType, prop: string | symbol) {
542
+ if (typeof prop === 'symbol') {
543
+ // Expose OPAQUE=true so Aberdeen stores us as-is without re-proxying.
544
+ // Also, don't dive into object args
545
+ return prop === A.OPAQUE ? true : prop === A.CUSTOM_DUMP ? `<ServerProxy for #${target.requestId}>` : undefined;
546
+ }
524
547
  if (target.serverProxyCache?.has(prop)) {
525
548
  return target.serverProxyCache.get(prop);
526
549
  }
@@ -548,7 +571,7 @@ interface ProxyTargetType {
548
571
  }
549
572
 
550
573
  interface ActiveRequest extends ProxyTargetType {
551
- resultProxy: PromiseProxy<any>;
574
+ $result: PromiseProxy<any>;
552
575
  callbacks?: ((...args: any[]) => void)[];
553
576
  virtualSocketIds?: number[];
554
577
  database?: Map<number, Record<any,any>>; // For model streams
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // Bundles dashboard/client/main.ts into build/dashboard/dashboard.html as a
3
+ // single self-contained file.
4
+ import { readFileSync, writeFileSync, mkdirSync } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, resolve } from 'path';
7
+ import * as esbuild from 'esbuild';
8
+
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const root = resolve(here, '..');
11
+ const outDir = resolve(root, 'build/dashboard');
12
+ mkdirSync(outDir, { recursive: true });
13
+
14
+ const debug = process.argv.includes('--debug');
15
+ const result = await esbuild.build({
16
+ entryPoints: [resolve(here, 'client/main.ts')],
17
+ bundle: true,
18
+ platform: 'browser',
19
+ format: 'esm',
20
+ minify: !debug,
21
+ sourcemap: debug ? 'inline' : false,
22
+ write: false,
23
+ tsconfig: resolve(root, 'tsconfig.client.json'),
24
+ });
25
+ if (debug) {
26
+ const dbgJs = result.outputFiles[0].text;
27
+ console.log('Symbol("target") occurrences (>1 means duplicate Aberdeen):',
28
+ (dbgJs.match(/Symbol\("target"\)/g) || []).length);
29
+ }
30
+ if (result.errors.length) {
31
+ for (const e of result.errors) console.error(e.text);
32
+ process.exit(1);
33
+ }
34
+ const js = result.outputFiles[0].text;
35
+
36
+ const template = readFileSync(resolve(here, 'client/index.html'), 'utf8');
37
+ const scriptTag = `<script type="module">${js.replace(/<\/script>/gi, '<\\/script>')}</script>`;
38
+ // Use a function replacer so $& / $' / $` in the bundle are never treated as
39
+ // special replacement patterns by String.replace.
40
+ const html = template.replace('<!--LOWLANDER_DASHBOARD_SCRIPT-->', () => scriptTag);
41
+
42
+ const outFile = resolve(outDir, 'dashboard.html');
43
+ writeFileSync(outFile, html);
44
+ console.log(`Dashboard bundled: ${outFile} (${(html.length / 1024).toFixed(1)} KB)`);