lowlander 0.2.4 → 0.4.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,33 @@ 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) {
99
111
  let proxies = socketProxies.get(socketId);
100
112
  if (!proxies) socketProxies.set(socketId, proxies = new Map());
101
113
  if (logLevel >= 3) console.log('[lowlander] Setting proxy id', requestId, 'for socket', socketId);
102
114
  proxies.set(requestId, response.api);
103
-
104
- pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response_proxy, response.value, virtualSocketIds);
115
+
116
+ if (response.value instanceof StreamTypeBase) {
117
+ const StreamType = response.value.constructor as typeof StreamTypeBase<any>;
118
+ const instance = response.value._instance;
119
+
120
+ const virtualSocketId = warpsocket.createVirtualSocket(socketId, DataPack.createUint8Array(requestId, SERVER_MESSAGES.model_data));
121
+ virtualSocketIds.push(virtualSocketId);
122
+ pushModel(virtualSocketId, instance, 0, StreamType, 1);
123
+
124
+ const cacheMs = StreamType.cache !== undefined ? StreamType.cache * 1000 : undefined;
125
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
126
+ } else {
127
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_proxy, response.value, virtualSocketIds);
128
+ }
105
129
 
106
130
  } else if (response instanceof StreamTypeBase) {
107
131
  const StreamType = response.constructor as typeof StreamTypeBase<any>;
@@ -116,14 +140,14 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
116
140
 
117
141
  // Then respond, indicating which row should be top level
118
142
  const cacheMs = StreamType.cache !== undefined ? StreamType.cache * 1000 : undefined;
119
- pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
143
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response_model, virtualSocketIds, instance.getPrimaryKeyHash() + StreamType.id, cacheMs);
120
144
  } else {
121
145
  // A regular result
122
- pendingSend = () => send(socketId, requestId, SERVER_MESSAGES.response, response, virtualSocketIds);
146
+ pendingPacket = DataPack.createUint8Array(requestId, SERVER_MESSAGES.response, response, virtualSocketIds);
123
147
  }
124
148
  });
125
149
  // Send response after transaction has committed
126
- pendingSend!();
150
+ warpsocket.send(socketId, pendingPacket!);
127
151
  } catch (error: any) {
128
152
  console.error('RPC error', error);
129
153
  sendError(socketId, requestId, error.message || 'Internal error');
@@ -133,7 +157,22 @@ export async function handleBinaryMessage(message: Uint8Array, socketId: number)
133
157
  }
134
158
  }
135
159
 
136
- export function handleClose(socketId: number) {
160
+ export async function handleClose(socketId: number) {
137
161
  if (logLevel >= 1) console.log('[lowlander] Client disconnected', socketId);
162
+ const proxies = socketProxies.get(socketId);
163
+ if (!proxies) return;
138
164
  socketProxies.delete(socketId);
165
+ if (!proxies.values().some(p => typeof p.onDrop === 'function')) return;
166
+
167
+ await E.transact(async () => {
168
+ for (const proxy of proxies.values()) {
169
+ if (typeof proxy?.onDrop === 'function') {
170
+ try {
171
+ await proxy.onDrop();
172
+ } catch (err: any) {
173
+ console.error('handleClose onDrop error', err);
174
+ }
175
+ }
176
+ }
177
+ });
139
178
  }
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,7 +125,7 @@ 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
  ```
@@ -157,13 +155,14 @@ After a stream with caching goes out of scope, the server keeps it alive for tha
157
155
  Wrap a class instance to expose per-connection stateful methods:
158
156
 
159
157
  ```ts
158
+ // server-side api
160
159
  import { ServerProxy } from 'lowlander/server';
161
160
 
162
161
  class UserAPI {
163
162
  constructor(public userName: string) {}
164
163
 
165
164
  get user(): Person {
166
- return Person.byName.get(this.userName)!;
165
+ return Person.getBy('name', this.userName)!;
167
166
  }
168
167
 
169
168
  getBio() {
@@ -172,7 +171,7 @@ class UserAPI {
172
171
  }
173
172
 
174
173
  export async function authenticate(token: string) {
175
- const user = Person.byName.get(token);
174
+ const user = Person.getBy('name', token);
176
175
  if (!user) throw new Error('User not found');
177
176
  return new ServerProxy(new UserAPI(token), 'secret-value');
178
177
  }
@@ -180,6 +179,28 @@ export async function authenticate(token: string) {
180
179
 
181
180
  The client receives `'secret-value'` as `.value` and can call `UserAPI` methods via `.serverProxy`.
182
181
 
182
+ You can also pass a stream type instance as the value — the client's `.value` will then be reactive and update live whenever the model changes:
183
+
184
+ ```ts
185
+ const PersonStream = createStreamType(Person, { name: true, age: true });
186
+
187
+ export async function authenticate(token: string) {
188
+ const user = Person.getBy('name', token);
189
+ if (!user) throw new Error('User not found');
190
+ return new ServerProxy(new UserAPI(token), new PersonStream(user));
191
+ }
192
+ ```
193
+
194
+ The client gets both `.serverProxy` (for calling `UserAPI` methods) and a live-updating `.value`.
195
+
196
+ 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.
197
+
198
+ ```ts
199
+ // client-side
200
+ const auth = api.authenticate('Alice');
201
+ dump(auth.serverProxy.getBio());
202
+ ```
203
+
183
204
  ### Socket Callbacks
184
205
 
185
206
  Use `Socket<T>` parameters for server-push streaming. On the client, these become callback functions:
@@ -288,6 +309,25 @@ Reconnection is automatic with exponential backoff.
288
309
 
289
310
  Aberdeen's `clean()` handles RPC lifecycle. When a reactive scope is destroyed, active requests and subscriptions are cancelled automatically.
290
311
 
312
+
313
+ #### Named Client-Side Types
314
+
315
+ Use `ClientProxyObject<T>` to get the fully-typed client API shape, which is useful for deriving types from stream methods without duplicating field selections:
316
+
317
+ ```ts
318
+ import type { ClientProxyObject } from 'lowlander/client';
319
+ import type * as API from './server/api.js';
320
+
321
+ type APIClient = ClientProxyObject<typeof API>;
322
+ const api: APIClient = new Connection<typeof API>('ws://localhost:8080/').api;
323
+
324
+ type SomethingType = ReturnType<APIClient['streamSomething']>;
325
+ const something: SomethingType = api.streamSomething();
326
+ ```
327
+
328
+ `ClientProxyObject` maps server return types to their client-side equivalents. Stream methods return `PromiseProxy<ProjectedData>`, plain values return `PromiseProxy<T>`, and `ServerProxy<API, R>` methods return a proxy with a `.serverProxy` of type `ClientProxyObject<SubAPI>`.
329
+
330
+
291
331
  ### Logging
292
332
 
293
333
  Set the `LOWLANDER_LOG_LEVEL` environment variable to a number from 0 to 3:
@@ -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>, options?: { cache?: number; }) => typeof StreamType`
8
+ **Signature:** `<T, S extends FieldSelection<T>>(Model: object & ModelClassRuntime<any, readonly any[], any, any> & (new (initial?: Partial<any>, txn?: Transaction) => any) & (new (...args: any[]) => T), selection: S & ValidateSelection<...>, options?: { ...; }) => { ...; }`
9
9
 
10
10
  **Type Parameters:**
11
11
 
@@ -14,7 +14,7 @@ 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
19
  - `options?: { cache?: number }` - - Optional settings
20
20
 
@@ -23,13 +23,12 @@ Supports nested linked models and type-safe field selection.
23
23
  **Examples:**
24
24
 
25
25
  ```ts
26
- ⁣@E.registerModel
27
- class Person extends Model {
28
- name = field(string);
29
- age = field(number);
30
- password = field(string);
31
- friends = field(array(link(Person)));
32
- }
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' });
33
32
 
34
33
  // Exclude password, include friends' names; cache 30s
35
34
  const PersonStream = createStreamType(Person, {
@@ -39,7 +38,7 @@ const PersonStream = createStreamType(Person, {
39
38
  }, { cache: 30 });
40
39
 
41
40
  export function streamPerson() {
42
- const person = Person.byName.get('Alice')!;
41
+ const person = Person.get('Alice')!;
43
42
  return new PersonStream(person);
44
43
  }
45
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