lowlander 0.2.3 → 0.3.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.
@@ -41,7 +41,18 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
41
41
  // Delete server proxy object, if any
42
42
  const cancelRequestId = pack.readPositiveInt();
43
43
  const proxies = socketProxies.get(socketId);
44
- if (proxies) proxies.delete(cancelRequestId);
44
+ if (proxies) {
45
+ /** Call `onDrop()` on a proxy if it has one. Runs in a transaction; errors are logged. */
46
+ const proxy = proxies.get(cancelRequestId);
47
+ proxies.delete(cancelRequestId);
48
+ if (proxy && typeof proxy.onDrop === 'function') {
49
+ try {
50
+ await E.transact(() => proxy.onDrop());
51
+ } catch (err: any) {
52
+ console.error('cancel request onDrop error', err);
53
+ }
54
+ }
55
+ }
45
56
 
46
57
  // Delete any virtual sockets created for this request
47
58
  for(const virtualSocketId of pack.read() || []) {
@@ -88,20 +99,24 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
88
99
  }
89
100
 
90
101
  try {
91
- let pendingSend: (() => void) | undefined;
102
+ let pendingPacket: Uint8Array | undefined;
92
103
  await E.transact(async () => {
93
104
  let response = await func.apply(api, params);
94
105
  if (logLevel >= 2) console.log('[lowlander] Called', methodName, 'with', params, '->', typeof response === 'object' && response ? response.toString() : JSON.stringify(response));
95
106
 
96
- // Result processing/sending should be within the transaction, as it may involve (lazy) loading models
107
+ // Result processing/serialization should be within the transaction, as it may involve (lazy) loading models.
108
+ // The actual socket send remains deferred until after commit.
97
109
 
98
110
  if (response instanceof ServerProxy) {
111
+ if (response.value instanceof StreamTypeBase) {
112
+ throw new Error('ServerProxy values cannot be streamed models; return the stream directly or from a proxy method instead');
113
+ }
99
114
  let proxies = socketProxies.get(socketId);
100
115
  if (!proxies) socketProxies.set(socketId, proxies = new Map());
101
116
  if (logLevel >= 3) console.log('[lowlander] Setting proxy id', requestId, 'for socket', socketId);
102
117
  proxies.set(requestId, response.api);
103
-
104
- pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response_proxy, response.value, virtualSocketIds);
118
+
119
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy, response.value, virtualSocketIds);
105
120
 
106
121
  } else if (response instanceof StreamTypeBase) {
107
122
  const StreamType = response.constructor as typeof StreamTypeBase<any>;
@@ -115,14 +130,15 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
115
130
  pushModel(virtualSocketId, instance, 0, StreamType, 1);
116
131
 
117
132
  // Then respond, indicating which row should be top level
118
- pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id);
133
+ const cacheMs = StreamType.cache !== undefined ? StreamType.cache * 1000 : undefined;
134
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
119
135
  } else {
120
136
  // A regular result
121
- pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response, response, virtualSocketIds);
137
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response, response, virtualSocketIds);
122
138
  }
123
139
  });
124
140
  // Send response after transaction has committed
125
- pendingSend!();
141
+ warpsocket.send(socketId, pendingPacket!);
126
142
  } catch (error: any) {
127
143
  console.error('RPC error', error);
128
144
  sendError(socketId, requestId, error.message || 'Internal error');
@@ -132,7 +148,22 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
132
148
  }
133
149
  }
134
150
 
135
- export function handleClose(socketId: number) {
151
+ export async function handleClose(socketId: number) {
136
152
  if (logLevel >= 1) console.log('[lowlander] Client disconnected', socketId);
153
+ const proxies = socketProxies.get(socketId);
154
+ if (!proxies) return;
137
155
  socketProxies.delete(socketId);
156
+ if (!proxies.values().some(p => typeof p.onDrop === 'function')) return;
157
+
158
+ await E.transact(async () => {
159
+ for (const proxy of proxies.values()) {
160
+ if (typeof proxy?.onDrop === 'function') {
161
+ try {
162
+ await proxy.onDrop();
163
+ } catch (err: any) {
164
+ console.error('handleClose onDrop error', err);
165
+ }
166
+ }
167
+ }
168
+ });
138
169
  }
package/skill/SKILL.md CHANGED
@@ -93,14 +93,12 @@ Define persistent data models using Edinburgh. See [Edinburgh docs](https://gith
93
93
  ```ts
94
94
  import * as E from 'edinburgh';
95
95
 
96
- @E.registerModel
97
- class Person extends E.Model<Person> {
98
- static byName = E.primary(Person, 'name');
96
+ const Person = E.defineModel('Person', class {
99
97
  name = E.field(E.string);
100
98
  age = E.field(E.number);
101
- friends = E.field(E.array(E.link(Person)));
99
+ friends = E.field(E.array(E.link(() => Person)));
102
100
  password = E.field(E.string);
103
- }
101
+ }, { pk: 'name' });
104
102
  ```
105
103
 
106
104
  Models are ACID, and RPC calls automatically run in transactions. When creating a `new Instance()` or updating props on an existing instance, changes are persisted to disk automatically. `E.link` objects are lazy-loaded.
@@ -127,25 +125,44 @@ Use `true` for plain fields. For linked model fields, provide a nested selection
127
125
 
128
126
  ```ts
129
127
  export function streamPerson(name: string) {
130
- const person = Person.byName.get(name)!;
128
+ const person = Person.getBy('name', name)!;
131
129
  return new PersonStream(person);
132
130
  }
133
131
  ```
134
132
 
135
133
  On the client, this returns a reactive Aberdeen proxy that updates live when server data changes.
136
134
 
135
+ ```ts
136
+ // Client-side
137
+ const person = api.streamPerson('Alice');
138
+ // person.value starts as undefined while loading, and
139
+ // then becomes a live-updating reactive proxy object of Alice's data
140
+ A.dump(person);
141
+ ```
142
+
143
+ Lowlander will keep `person.value` up-to-date as long as the Aberdeen scope containing `api.streamPerson` remains active. When the scope is destroyed, the stream subscription is automatically cancelled.
144
+
145
+ It's quite common for the same RPC call to be used to get the same stream multiple times in a short period; when navigating back and forth, or when navigating to a new page that requires some of the same data as the previous page. To optimize for this, `createStreamType` accepts an optional `cache` parameter (in seconds).
146
+
147
+ ```ts
148
+ const PersonStream = createStreamType(Person, fields, { cache: 30 }); // cache for 30s after going out of scope
149
+ ```
150
+
151
+ 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.
152
+
137
153
  ### ServerProxy for Stateful APIs
138
154
 
139
155
  Wrap a class instance to expose per-connection stateful methods:
140
156
 
141
157
  ```ts
158
+ // server-side api
142
159
  import { ServerProxy } from 'lowlander/server';
143
160
 
144
161
  class UserAPI {
145
162
  constructor(public userName: string) {}
146
163
 
147
164
  get user(): Person {
148
- return Person.byName.get(this.userName)!;
165
+ return Person.getBy('name', this.userName)!;
149
166
  }
150
167
 
151
168
  getBio() {
@@ -154,7 +171,7 @@ class UserAPI {
154
171
  }
155
172
 
156
173
  export async function authenticate(token: string) {
157
- const user = Person.byName.get(token);
174
+ const user = Person.getBy('name', token);
158
175
  if (!user) throw new Error('User not found');
159
176
  return new ServerProxy(new UserAPI(token), 'secret-value');
160
177
  }
@@ -162,6 +179,14 @@ export async function authenticate(token: string) {
162
179
 
163
180
  The client receives `'secret-value'` as `.value` and can call `UserAPI` methods via `.serverProxy`.
164
181
 
182
+ When a proxy is dropped, because the request's Aberdeen scope was destroyed or the WebSocket disconnected, Lowlander calls `onDrop()` on the API object if it exists, letting you clean up server-side state.
183
+
184
+ ```ts
185
+ // client-side
186
+ const auth = api.authenticate('Alice');
187
+ dump(auth.serverProxy.getBio());
188
+ ```
189
+
165
190
  ### Socket Callbacks
166
191
 
167
192
  Use `Socket<T>` parameters for server-push streaming. On the client, these become callback functions:
@@ -325,6 +350,10 @@ Starts the Lowlander WebSocket server.
325
350
 
326
351
  **Type:** `number`
327
352
 
353
+ #### StreamTypeBase.cache · static property
354
+
355
+ **Type:** `number`
356
+
328
357
  #### streamTypeBase.toString · method
329
358
 
330
359
  **Signature:** `() => string`
@@ -404,6 +433,10 @@ and reactive updates.
404
433
 
405
434
  **Type:** `ValueRef<boolean>`
406
435
 
436
+ #### connection.streamCache · property
437
+
438
+ **Type:** `Map<string, StreamCacheEntry>`
439
+
407
440
  #### connection.api · property
408
441
 
409
442
  Type-safe proxy to the server-side API. Methods return `PromiseProxy` objects
@@ -428,3 +461,19 @@ Returns the current connection status. Reactive in Aberdeen scopes.
428
461
 
429
462
  #### [connection.pruneCommitIds](Connection_pruneCommitIds.md) · method
430
463
 
464
+ #### connection.cancelRequest · method
465
+
466
+ **Signature:** `(request: ActiveRequest) => void`
467
+
468
+ **Parameters:**
469
+
470
+ - `request: ActiveRequest`
471
+
472
+ #### connection.startLinger · method
473
+
474
+ **Signature:** `(cached: StreamCacheEntry) => void`
475
+
476
+ **Parameters:**
477
+
478
+ - `cached: StreamCacheEntry`
479
+
@@ -3,6 +3,10 @@
3
3
  Wraps a server-side API object to create a stateful, type-safe proxy accessible from clients.
4
4
  Use for authentication, sessions, or any stateful context that persists across RPC calls.
5
5
 
6
+ If the API object has an `onDrop()` method, it is called when the proxy is dropped, either
7
+ because the client cancelled the request (scope cleanup) or the WebSocket disconnected.
8
+ Use this to clean up server-side state kept on behalf of the client.
9
+
6
10
  **Type Parameters:**
7
11
 
8
12
  - `API extends object`
@@ -14,6 +18,7 @@ Use for authentication, sessions, or any stateful context that persists across R
14
18
  export class UserAPI {
15
19
  constructor(public user: User) {}
16
20
  getSecret() { return this.user.secret; }
21
+ onDrop() { console.log('client gone'); }
17
22
  }
18
23
 
19
24
  export async function authenticate(token: string) {
@@ -5,7 +5,7 @@ Creates a stream type for reactive model streaming to clients with automatic upd
5
5
  Specify which fields to include; when they change, updates are pushed to subscribed clients.
6
6
  Supports nested linked models and type-safe field selection.
7
7
 
8
- **Signature:** `<T, S extends FieldSelection<T>>(Model: typeof E.Model<unknown> & (new (...args: any[]) => T), selection: S & ValidateSelection<T, S>) => typeof StreamType`
8
+ **Signature:** `<T, S extends FieldSelection<T>>(Model: (new () => any) & ModelClassRuntime<any, readonly any[], any, any> & (new (initial?: Partial<any>, txn?: Transaction) => any) & (new (...args: any[]) => T), selection: S & ValidateSelection<...>, options?: { ...; }) => typeof StreamType`
9
9
 
10
10
  **Type Parameters:**
11
11
 
@@ -14,31 +14,31 @@ Supports nested linked models and type-safe field selection.
14
14
 
15
15
  **Parameters:**
16
16
 
17
- - `Model: ModelClass & (new (...args: any[]) => T)` - - The Edinburgh model class
17
+ - `Model: E.AnyModelClass & (new (...args: any[]) => T)` - - The Edinburgh model class
18
18
  - `selection: S & ValidateSelection<T, S>` - - Field selection: `true` for simple fields, nested object for linked models
19
+ - `options?: { cache?: number }` - - Optional settings
19
20
 
20
21
  **Returns:** Stream type class to instantiate in API functions
21
22
 
22
23
  **Examples:**
23
24
 
24
25
  ```ts
25
- ⁣@E.registerModel
26
- class Person extends Model {
27
- name = field(string);
28
- age = field(number);
29
- password = field(string);
30
- friends = field(array(link(Person)));
31
- }
32
-
33
- // Exclude password, include friends' names
26
+ const Person = E.defineModel('Person', class {
27
+ name = E.field(E.string);
28
+ age = E.field(E.number);
29
+ password = E.field(E.string);
30
+ friends = E.field(E.array(E.link(() => Person)));
31
+ }, { pk: 'name' });
32
+
33
+ // Exclude password, include friends' names; cache 30s
34
34
  const PersonStream = createStreamType(Person, {
35
35
  name: true,
36
36
  age: true,
37
37
  friends: { name: true }
38
- });
38
+ }, { cache: 30 });
39
39
 
40
40
  export function streamPerson() {
41
- const person = Person.byName.get('Alice')!;
41
+ const person = Person.get('Alice')!;
42
42
  return new PersonStream(person);
43
43
  }
44
44
  ```
@@ -3,7 +3,7 @@
3
3
  Subscribes `target` to this model, and sends initial data.
4
4
  `target` is a virtual socket with a requestId+'d' user prefix, or a channel that subscribes such virtual sockets.
5
5
 
6
- **Signature:** `(target: number | Uint8Array<ArrayBufferLike> | number[], model: Model<any>, commitId: number, SubStreamType: typeof StreamTypeBase<any>, delta: number) => void`
6
+ **Signature:** `(target: number | Uint8Array<ArrayBufferLike> | number[], model: any, commitId: number, SubStreamType: typeof StreamTypeBase<any>, delta: number) => void`
7
7
 
8
8
  **Parameters:**
9
9
 
@@ -3,7 +3,7 @@
3
3
  Sends (updated) data for `model` to `target`.
4
4
  `target` is a virtual socket with a requestId+'d' user prefix, or a channel that subscribes such virtual sockets.
5
5
 
6
- **Signature:** `(target: number | Uint8Array<ArrayBufferLike> | number[], model: Model<any>, commitId: number, StreamType: typeof StreamTypeBase<any>, changed?: Change) => void`
6
+ **Signature:** `(target: number | Uint8Array<ArrayBufferLike> | number[], model: any, commitId: number, StreamType: typeof StreamTypeBase<any>, changed?: Change) => void`
7
7
 
8
8
  **Parameters:**
9
9