lowlander 0.5.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.
- package/README.md +43 -67
- package/build/client/client.d.ts +8 -1
- package/build/client/client.js +39 -22
- package/build/client/client.js.map +1 -1
- package/build/dashboard/client/crud.d.ts +16 -0
- package/build/dashboard/client/crud.js +525 -0
- package/build/dashboard/client/crud.js.map +1 -0
- package/build/dashboard/client/main.js +238 -246
- package/build/dashboard/client/main.js.map +1 -1
- package/build/dashboard/dashboard.html +8 -8
- package/build/dashboard/server.d.ts +20 -9
- package/build/dashboard/server.d.ts.map +1 -1
- package/build/dashboard/server.js +139 -3
- package/build/dashboard/server.js.map +1 -1
- package/build/examples/helloworld/.edinburgh/commit_worker.log +1765 -0
- package/build/examples/helloworld/.edinburgh/data.mdb +0 -0
- package/build/examples/helloworld/.edinburgh/lock.mdb +0 -0
- package/build/examples/helloworld/client/assets/style.css +0 -45
- package/build/examples/helloworld/client/index.html +2 -13
- package/build/examples/helloworld/client/js/base.d.ts +1 -4
- package/build/examples/helloworld/client/js/base.js +8 -217
- package/build/examples/helloworld/client/js/base.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +4 -0
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +10 -0
- package/build/examples/helloworld/server/api.js.map +1 -1
- package/build/server/server.d.ts +3 -3
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +44 -5
- package/build/server/server.js.map +1 -1
- package/build/tsconfig.client.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/client/client.ts +41 -23
- package/dashboard/build-bundle.ts +8 -2
- package/dashboard/client/crud.ts +634 -0
- package/dashboard/client/main.ts +234 -246
- package/dashboard/server.ts +149 -5
- package/package.json +9 -5
- package/server/server.ts +43 -8
- package/skill/SKILL.md +30 -47
- package/skill/Connection_pruneCommitIds.md +0 -8
package/dashboard/server.ts
CHANGED
|
@@ -18,13 +18,111 @@ function passwordOk(provided: string): boolean {
|
|
|
18
18
|
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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",
|
|
@@ -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
|
|
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.
|
|
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.
|
|
59
|
+
"aberdeen": "^1.17.1",
|
|
58
60
|
"esbuild": "^0.28.0",
|
|
59
|
-
"readme-tsdoc": "^1.1.
|
|
61
|
+
"readme-tsdoc": "^1.1.8",
|
|
62
|
+
"shotest": "^1.6.1",
|
|
60
63
|
"sirv": "^3.0.2",
|
|
64
|
+
"staffa": "0.3.1",
|
|
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]:
|
|
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
|
-
//
|
|
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,
|
|
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,
|
|
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 (
|
|
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,
|
|
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
|
+

|
|
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
|
-
|
|
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 |
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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:** `(
|
|
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
|
|