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.
- package/README.md +108 -78
- package/build/client/client.d.ts +8 -1
- package/build/client/client.js +44 -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.d.ts +1 -0
- package/build/dashboard/client/main.js +615 -0
- package/build/dashboard/client/main.js.map +1 -0
- package/build/dashboard/client/shim-server.d.ts +3 -0
- package/build/dashboard/client/shim-server.js +2 -0
- package/build/dashboard/client/shim-server.js.map +1 -0
- package/build/dashboard/dashboard.html +20 -0
- package/build/dashboard/index.d.ts +18 -0
- package/build/dashboard/index.d.ts.map +1 -0
- package/build/dashboard/index.js +50 -0
- package/build/dashboard/index.js.map +1 -0
- package/build/dashboard/serve.d.ts +18 -0
- package/build/dashboard/serve.d.ts.map +1 -0
- package/build/dashboard/serve.js +53 -0
- package/build/dashboard/serve.js.map +1 -0
- package/build/dashboard/server.d.ts +93 -0
- package/build/dashboard/server.d.ts.map +1 -0
- package/build/dashboard/server.js +384 -0
- package/build/dashboard/server.js.map +1 -0
- package/build/examples/helloworld/.edinburgh/commit_worker.log +4927 -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 +3 -14
- package/build/examples/helloworld/client/js/base.css +1 -0
- package/build/examples/helloworld/client/js/base.d.ts +1 -4
- package/build/examples/helloworld/client/js/base.js +8 -71
- package/build/examples/helloworld/client/js/base.js.map +1 -1
- package/build/examples/helloworld/server/api.d.ts +8 -2
- package/build/examples/helloworld/server/api.d.ts.map +1 -1
- package/build/examples/helloworld/server/api.js +29 -8
- package/build/examples/helloworld/server/api.js.map +1 -1
- package/build/examples/helloworld/server/main.d.ts +1 -1
- package/build/examples/helloworld/server/main.d.ts.map +1 -1
- package/build/examples/helloworld/server/main.js +6 -17
- package/build/examples/helloworld/server/main.js.map +1 -1
- package/build/server/password.d.ts +10 -0
- package/build/server/password.d.ts.map +1 -0
- package/build/server/password.js +38 -0
- package/build/server/password.js.map +1 -0
- package/build/server/server.d.ts +5 -3
- package/build/server/server.d.ts.map +1 -1
- package/build/server/server.js +65 -7
- package/build/server/server.js.map +1 -1
- package/build/server/wshandler.d.ts +7 -1
- package/build/server/wshandler.d.ts.map +1 -1
- package/build/server/wshandler.js +54 -14
- package/build/server/wshandler.js.map +1 -1
- package/build/tsconfig.client.tsbuildinfo +1 -1
- package/build/tsconfig.server.tsbuildinfo +1 -1
- package/client/client.ts +47 -24
- package/dashboard/build-bundle.ts +44 -0
- package/dashboard/client/crud.ts +634 -0
- package/dashboard/client/index.html +12 -0
- package/dashboard/client/main.ts +554 -0
- package/dashboard/client/shim-server.ts +5 -0
- package/dashboard/index.ts +49 -0
- package/dashboard/server.ts +399 -0
- package/package.json +26 -11
- package/server/server.ts +61 -10
- package/server/wshandler.ts +57 -13
- package/skill/SKILL.md +82 -51
- package/skill/getStreamTypesForModel.md +7 -0
- package/skill/Connection_pruneCommitIds.md +0 -8
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import * as E from 'edinburgh';
|
|
2
|
+
import { timingSafeEqual } from 'crypto';
|
|
3
|
+
import { ServerProxy, createStreamType, getStreamTypesForModel, warpsocket } from '../server/server.js';
|
|
4
|
+
import { getMainApi, getPassword } from '../server/wshandler.js';
|
|
5
|
+
|
|
6
|
+
// `modelRegistry` is a documented export of edinburgh's models module but is
|
|
7
|
+
// not re-exported from the package entry. Reach it by URL relative to the
|
|
8
|
+
// resolved package main — works in both Bun (.ts) and Node (built .js).
|
|
9
|
+
const _modelsModule = await import(new URL('./models.js', import.meta.resolve('edinburgh')).href) as {
|
|
10
|
+
modelRegistry: Record<string, E.AnyModelClass>;
|
|
11
|
+
};
|
|
12
|
+
const modelRegistry = _modelsModule.modelRegistry;
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
function passwordOk(provided: string): boolean {
|
|
16
|
+
const expected = getPassword();
|
|
17
|
+
if (typeof provided !== 'string' || provided.length !== expected.length) return false;
|
|
18
|
+
return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
|
19
|
+
}
|
|
20
|
+
|
|
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;
|
|
41
|
+
const linked = type.getLinkedModel();
|
|
42
|
+
|
|
43
|
+
const info: TypeInfo = {
|
|
44
|
+
kind,
|
|
45
|
+
display: type.toString(),
|
|
46
|
+
linkedModel: linked?.tableName,
|
|
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
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function describeIndex(index: any) {
|
|
129
|
+
const fields = Array.from(index._indexFields.keys()) as string[];
|
|
130
|
+
return {
|
|
131
|
+
fields,
|
|
132
|
+
fieldTypes: fields.map(f => describeType(index._indexFields.get(f))),
|
|
133
|
+
computed: !!index._computeFn,
|
|
134
|
+
kind: index._getTypeName() as 'primary' | 'unique' | 'secondary',
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function describeModel(Model: E.AnyModelClass) {
|
|
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 }[] = [];
|
|
142
|
+
for (const [name, cfg] of Object.entries(Model.fields) as [string, E.FieldConfig<unknown>][]) {
|
|
143
|
+
fields.push({
|
|
144
|
+
name,
|
|
145
|
+
type: describeType(cfg.type),
|
|
146
|
+
description: cfg.description,
|
|
147
|
+
hasDefault: cfg.default !== undefined,
|
|
148
|
+
isPk: pkFields.has(name),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const indexes: { name: string; info: ReturnType<typeof describeIndex> }[] = [{
|
|
152
|
+
name: '(primary)',
|
|
153
|
+
info: pkIdx,
|
|
154
|
+
}];
|
|
155
|
+
for (const [name, idx] of Object.entries((Model as any)._secondaries || {})) {
|
|
156
|
+
indexes.push({ name, info: describeIndex(idx) });
|
|
157
|
+
}
|
|
158
|
+
const streamTypes = getStreamTypesForModel(Model).map((ST: any) => ({
|
|
159
|
+
id: ST.id,
|
|
160
|
+
fields: ST.fields,
|
|
161
|
+
cache: ST.cache,
|
|
162
|
+
}));
|
|
163
|
+
return {
|
|
164
|
+
tableName: Model.tableName,
|
|
165
|
+
fields,
|
|
166
|
+
indexes,
|
|
167
|
+
streamTypes,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function serializeValue(v: any, depth = 0): any {
|
|
172
|
+
if (v === null || v === undefined) return v;
|
|
173
|
+
if (v instanceof E.Model) {
|
|
174
|
+
const cls: any = v.constructor;
|
|
175
|
+
const tn = cls.tableName || cls.name;
|
|
176
|
+
const pkBytes = (v as any).getPrimaryKey?.();
|
|
177
|
+
const pkArr = pkBytes && cls._pkToArray ? cls._pkToArray(pkBytes) : null;
|
|
178
|
+
const pk = pkArr ? (pkArr.length === 1 ? pkArr[0] : pkArr) : null;
|
|
179
|
+
return { __ref: tn, pk };
|
|
180
|
+
}
|
|
181
|
+
if (v instanceof Uint8Array) return Array.from(v.slice(0, 64)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
182
|
+
if (v instanceof Date) return v.toISOString();
|
|
183
|
+
if (v instanceof Set) return depth > 2 ? `Set(${v.size})` : Array.from(v).map(x => serializeValue(x, depth + 1));
|
|
184
|
+
if (Array.isArray(v)) return depth > 2 ? `Array(${v.length})` : v.map(x => serializeValue(x, depth + 1));
|
|
185
|
+
if (typeof v === 'object') {
|
|
186
|
+
if (depth > 2) return '...';
|
|
187
|
+
const out: Record<string, any> = {};
|
|
188
|
+
for (const k of Object.keys(v)) out[k] = serializeValue(v[k], depth + 1);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
return v;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function instanceToPlain(instance: any): Record<string, any> {
|
|
195
|
+
const Model = instance.constructor as E.AnyModelClass;
|
|
196
|
+
const out: Record<string, any> = {};
|
|
197
|
+
for (const fieldName of Object.keys(Model.fields)) {
|
|
198
|
+
try {
|
|
199
|
+
out[fieldName] = serializeValue((instance as any)[fieldName]);
|
|
200
|
+
} catch (err: any) {
|
|
201
|
+
out[fieldName] = `<error: ${err?.message || err}>`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function modelByName(name: string): E.AnyModelClass {
|
|
208
|
+
const Model = modelRegistry[name];
|
|
209
|
+
if (!Model) throw new Error(`Unknown model: ${name}`);
|
|
210
|
+
return Model;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function findIndex(Model: E.AnyModelClass, indexName: string): any {
|
|
214
|
+
if (indexName === '(primary)') return Model;
|
|
215
|
+
const idx = (Model as any)._secondaries?.[indexName];
|
|
216
|
+
if (!idx) throw new Error(`Unknown index ${indexName} on ${Model.tableName}`);
|
|
217
|
+
return idx;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// =====================================================================
|
|
221
|
+
|
|
222
|
+
class DashboardAPI {
|
|
223
|
+
listModels() {
|
|
224
|
+
return Object.keys(modelRegistry).sort().map(name => ({
|
|
225
|
+
tableName: name,
|
|
226
|
+
indexCount: 1 + Object.keys((modelRegistry[name] as any)._secondaries || {}).length,
|
|
227
|
+
fieldCount: Object.keys(modelRegistry[name].fields).length,
|
|
228
|
+
streamTypeCount: getStreamTypesForModel(modelRegistry[name]).length,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
getModel(name: string) {
|
|
233
|
+
return describeModel(modelByName(name));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
listApiMethods() {
|
|
237
|
+
const api = getMainApi();
|
|
238
|
+
if (!api) return [];
|
|
239
|
+
const out: { name: string; kind: 'function' | 'value' }[] = [];
|
|
240
|
+
for (const key of Object.keys(api).sort()) {
|
|
241
|
+
if (key === '_dashboard' || key.startsWith('_')) continue;
|
|
242
|
+
const v = (api as any)[key];
|
|
243
|
+
out.push({ name: key, kind: typeof v === 'function' ? 'function' : 'value' });
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
getApiMethodSource(name: string): string | undefined {
|
|
249
|
+
const api = getMainApi();
|
|
250
|
+
const fn = api && (api as any)[name];
|
|
251
|
+
if (typeof fn !== 'function') return undefined;
|
|
252
|
+
try {
|
|
253
|
+
// Bundlers usually preserve enough source for a useful preview
|
|
254
|
+
const s = String(fn);
|
|
255
|
+
return s.length > 4000 ? s.slice(0, 4000) + '\n/* … truncated */' : s;
|
|
256
|
+
} catch {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
findRecords(modelName: string, indexName: string, opts: {
|
|
262
|
+
search?: any;
|
|
263
|
+
reverse?: boolean;
|
|
264
|
+
limit?: number;
|
|
265
|
+
} = {}) {
|
|
266
|
+
const Model = modelByName(modelName);
|
|
267
|
+
const idx = findIndex(Model, indexName);
|
|
268
|
+
const fieldNames = Array.from(idx._indexFields.keys()) as string[];
|
|
269
|
+
|
|
270
|
+
const findOpts: any = {};
|
|
271
|
+
if (opts.search !== undefined && opts.search !== '') {
|
|
272
|
+
findOpts.is = opts.search;
|
|
273
|
+
}
|
|
274
|
+
if (opts.reverse) findOpts.reverse = true;
|
|
275
|
+
|
|
276
|
+
const limit = Math.min(opts.limit ?? 10, 1000);
|
|
277
|
+
let iter: Iterable<any>;
|
|
278
|
+
try { iter = idx.find(findOpts); }
|
|
279
|
+
catch { return { rows: [], indexFields: fieldNames, scanned: 0 }; }
|
|
280
|
+
const results: { pk: any; values: Record<string, any> }[] = [];
|
|
281
|
+
let scanned = 0;
|
|
282
|
+
for (const row of iter) {
|
|
283
|
+
scanned++;
|
|
284
|
+
const plain = instanceToPlain(row);
|
|
285
|
+
const cls: any = (row as any).constructor;
|
|
286
|
+
const pkBytes = (row as any).getPrimaryKey ? (row as any).getPrimaryKey() : null;
|
|
287
|
+
const pkArr = pkBytes && cls._pkToArray ? cls._pkToArray(pkBytes) : null;
|
|
288
|
+
const pk = pkArr ? (pkArr.length === 1 ? pkArr[0] : pkArr) : null;
|
|
289
|
+
results.push({
|
|
290
|
+
pk: serializeValue(pk),
|
|
291
|
+
values: plain,
|
|
292
|
+
});
|
|
293
|
+
if (results.length >= limit) break;
|
|
294
|
+
}
|
|
295
|
+
return {
|
|
296
|
+
rows: results,
|
|
297
|
+
indexFields: fieldNames,
|
|
298
|
+
scanned,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
getRecord(modelName: string, pk: any) {
|
|
303
|
+
const Model = modelByName(modelName);
|
|
304
|
+
const pkArgs = Array.isArray(pk) ? pk : [pk];
|
|
305
|
+
const instance = (Model as any).get(...pkArgs);
|
|
306
|
+
if (!instance) return undefined;
|
|
307
|
+
return instanceToPlain(instance);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
streamRecord(modelName: string, streamTypeId: number, pk: any) {
|
|
311
|
+
const Model = modelByName(modelName);
|
|
312
|
+
const ST = getStreamTypesForModel(Model).find((s: any) => s.id === streamTypeId) as any;
|
|
313
|
+
if (!ST) throw new Error(`No stream type with id ${streamTypeId} for ${modelName}`);
|
|
314
|
+
const pkArgs = Array.isArray(pk) ? pk : [pk];
|
|
315
|
+
const instance = (Model as any).get(...pkArgs);
|
|
316
|
+
if (!instance) throw new Error('Record not found');
|
|
317
|
+
return new ST(instance);
|
|
318
|
+
}
|
|
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
|
+
|
|
363
|
+
getDebugState(mode: 'channels' | 'sockets' | 'workers' | 'kv') {
|
|
364
|
+
return warpsocket.getDebugState(mode as any);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const DashboardProxyValue = 'authenticated';
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* RPC entry point. Re-export this from your top-level api file:
|
|
372
|
+
*
|
|
373
|
+
* ```ts
|
|
374
|
+
* export { _dashboard } from 'lowlander/dashboard';
|
|
375
|
+
* ```
|
|
376
|
+
*
|
|
377
|
+
* On first call, a password is generated (or reused from the
|
|
378
|
+
* `LOWLANDER_DASHBOARD_PASSWORD` env var) and printed to the server console.
|
|
379
|
+
*/
|
|
380
|
+
export function _dashboard(password: string) {
|
|
381
|
+
if (!passwordOk(password)) {
|
|
382
|
+
throw new Error('Invalid dashboard password');
|
|
383
|
+
}
|
|
384
|
+
return new ServerProxy(new DashboardAPI(), DashboardProxyValue);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Re-export so callers don't need a separate import
|
|
388
|
+
export type { DashboardAPI };
|
|
389
|
+
|
|
390
|
+
// Convenience stream type used internally so live record viewing works
|
|
391
|
+
// for arbitrary models without the developer having to pre-create one.
|
|
392
|
+
// (Built lazily per model+selection because createStreamType is keyed on the
|
|
393
|
+
// shape of the selection.)
|
|
394
|
+
const allFieldsStreams = new Map<string, ReturnType<typeof createStreamType>>();
|
|
395
|
+
export function _dashboardAllFieldsStream(modelName: string) {
|
|
396
|
+
// Currently unused; kept for future "view live" feature.
|
|
397
|
+
void allFieldsStreams;
|
|
398
|
+
void modelName;
|
|
399
|
+
}
|
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",
|
|
@@ -21,34 +21,49 @@
|
|
|
21
21
|
"bun": "./server/server.ts",
|
|
22
22
|
"import": "./build/server/server.js",
|
|
23
23
|
"types": "./build/server/server.d.ts"
|
|
24
|
+
},
|
|
25
|
+
"./dashboard": {
|
|
26
|
+
"bun": "./dashboard/index.ts",
|
|
27
|
+
"import": "./build/dashboard/index.js",
|
|
28
|
+
"types": "./build/dashboard/index.d.ts"
|
|
24
29
|
}
|
|
25
30
|
},
|
|
26
31
|
"files": [
|
|
27
32
|
"build/",
|
|
28
33
|
"server/",
|
|
29
34
|
"client/",
|
|
35
|
+
"dashboard/",
|
|
30
36
|
"skill/"
|
|
31
37
|
],
|
|
32
38
|
"scripts": {
|
|
33
|
-
"build": "tsc -b &&
|
|
39
|
+
"build": "tsc -b && node --import=tsx/esm dashboard/build-bundle.ts && node --import=tsx/esm examples/helloworld/build-client.ts && npm run build:docs",
|
|
34
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",
|
|
35
|
-
"typecheck": "
|
|
41
|
+
"typecheck": "npx tsc -b",
|
|
36
42
|
"clean": "rm -rf build dist skill",
|
|
37
|
-
"prepack": "
|
|
38
|
-
"test": "
|
|
43
|
+
"prepack": "npm run build",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:e2e": "shotest test",
|
|
46
|
+
"test:e2e:review": "shotest review",
|
|
47
|
+
"example": "npm run build && (cd examples/helloworld && npm install) && node build/examples/helloworld/server/main.js",
|
|
39
48
|
"postinstall": "[ -f .local-post-install.sh ] && sh .local-post-install.sh || true"
|
|
40
49
|
},
|
|
41
50
|
"dependencies": {
|
|
42
|
-
"warpsocket": "^0.
|
|
51
|
+
"warpsocket": "^0.9.1"
|
|
43
52
|
},
|
|
44
53
|
"peerDependencies": {
|
|
45
|
-
"aberdeen": "^1.
|
|
46
|
-
"edinburgh": "^0.6.
|
|
54
|
+
"aberdeen": "^1.17.1",
|
|
55
|
+
"edinburgh": "^0.6.4"
|
|
47
56
|
},
|
|
48
57
|
"devDependencies": {
|
|
49
|
-
"@types/
|
|
50
|
-
"aberdeen": "^1.
|
|
51
|
-
"
|
|
58
|
+
"@types/node": "^20.19.41",
|
|
59
|
+
"aberdeen": "^1.17.1",
|
|
60
|
+
"esbuild": "^0.28.0",
|
|
61
|
+
"readme-tsdoc": "^1.1.8",
|
|
62
|
+
"shotest": "^1.6.1",
|
|
63
|
+
"sirv": "^3.0.2",
|
|
64
|
+
"staffa": "0.3.1",
|
|
65
|
+
"tsx": "^4.0.0",
|
|
66
|
+
"vitest": "^4.1.7"
|
|
52
67
|
},
|
|
53
68
|
"workspaces": [
|
|
54
69
|
"examples/*"
|
package/server/server.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as E from "edinburgh";
|
|
2
2
|
import DataPack from "edinburgh/datapack";
|
|
3
3
|
import * as realWarpsocket from 'warpsocket';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import { join } from 'path';
|
|
4
7
|
|
|
5
8
|
// Get log level from environment variable
|
|
6
9
|
// 0: no logging (default)
|
|
@@ -22,6 +25,11 @@ const CHANNEL_TYPE_MODEL = 1;
|
|
|
22
25
|
|
|
23
26
|
/** @internal Registry mapping model classes to their stream types */
|
|
24
27
|
const streamTypesPerModel: Map<E.AnyModelClass, typeof StreamTypeBase<unknown>[]> = new Map();
|
|
28
|
+
|
|
29
|
+
/** @internal Used by the dashboard module to inspect registered stream types. */
|
|
30
|
+
export function getStreamTypesForModel(Model: E.AnyModelClass): readonly (typeof StreamTypeBase<unknown>)[] {
|
|
31
|
+
return streamTypesPerModel.get(Model) || [];
|
|
32
|
+
}
|
|
25
33
|
|
|
26
34
|
/**
|
|
27
35
|
* Base class for stream types created by {@link createStreamType}.
|
|
@@ -29,8 +37,8 @@ const streamTypesPerModel: Map<E.AnyModelClass, typeof StreamTypeBase<unknown>[]
|
|
|
29
37
|
* @internal
|
|
30
38
|
*/
|
|
31
39
|
export abstract class StreamTypeBase<T> {
|
|
32
|
-
/** @internal */
|
|
33
|
-
static fields: { [key: string]:
|
|
40
|
+
/** @internal `true`=plain field, number=sub-stream id, `false`=virtual getter */
|
|
41
|
+
static fields: { [key: string]: boolean|number };
|
|
34
42
|
/** @internal */
|
|
35
43
|
static id: number;
|
|
36
44
|
/** @internal */
|
|
@@ -128,7 +136,11 @@ function getIdForData(namespace: string, ...data: any): number {
|
|
|
128
136
|
|
|
129
137
|
const countKey = new DataPack().write(namespace).toUint8Array();
|
|
130
138
|
while(true) {
|
|
131
|
-
//
|
|
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
|
+
}
|
|
132
144
|
const countPack = warpsocket.getKey(countKey);
|
|
133
145
|
const newCount = countPack ? new DataPack(countPack).readNumber() + 1 : 1;
|
|
134
146
|
const newCountPack = new DataPack().write(newCount).toUint8Array();
|
|
@@ -182,25 +194,33 @@ export function createStreamType<T, S extends FieldSelection<T>>(
|
|
|
182
194
|
Model: E.AnyModelClass & (new (...args: any[]) => T),
|
|
183
195
|
selection: S & ValidateSelection<T, S>,
|
|
184
196
|
options?: { cache?: number }
|
|
185
|
-
): { 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 } {
|
|
186
198
|
let streamTypes = streamTypesPerModel.get(Model);
|
|
187
199
|
if (!streamTypes) streamTypesPerModel.set(Model, streamTypes = []);
|
|
188
200
|
|
|
189
201
|
const streamTypeId = getIdForData("streamType", Model.tableName, selection);
|
|
190
202
|
|
|
191
|
-
const fields: Record<string,
|
|
203
|
+
const fields: Record<string, boolean|number> = {};
|
|
192
204
|
for(const prop of Array.from(Object.keys(selection)).sort() as (string & keyof S)[]) {
|
|
205
|
+
const sel = (selection as any)[prop];
|
|
206
|
+
|
|
193
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
|
+
}
|
|
194
214
|
throw new Error(`Property ${prop} does not exist in model ${Model.name}`);
|
|
195
215
|
}
|
|
196
216
|
const LinkedModel = Model.fields[prop].type.getLinkedModel() as E.AnyModelClass | undefined;
|
|
197
217
|
|
|
198
|
-
if (
|
|
218
|
+
if (sel === true) {
|
|
199
219
|
if (LinkedModel) throw new Error(`Property ${prop} is a link; must specify sub-selection`);
|
|
200
220
|
fields[prop] = true; // Include field without tracking links
|
|
201
221
|
} else {
|
|
202
222
|
if (!LinkedModel) throw new Error(`Property ${prop} is not a link; cannot specify sub-selection`);
|
|
203
|
-
const SubStreamType = createStreamType(LinkedModel as any,
|
|
223
|
+
const SubStreamType = createStreamType(LinkedModel as any, sel as any)
|
|
204
224
|
fields[prop] = SubStreamType.id;
|
|
205
225
|
}
|
|
206
226
|
}
|
|
@@ -209,7 +229,9 @@ export function createStreamType<T, S extends FieldSelection<T>>(
|
|
|
209
229
|
static id = streamTypeId;
|
|
210
230
|
static cache = options?.cache;
|
|
211
231
|
}
|
|
212
|
-
streamTypes.
|
|
232
|
+
if (!streamTypes.some(st => (st as any).id === streamTypeId)) {
|
|
233
|
+
streamTypes.push(StreamType);
|
|
234
|
+
}
|
|
213
235
|
return StreamType as any;
|
|
214
236
|
}
|
|
215
237
|
|
|
@@ -251,6 +273,14 @@ function updateLinkDeltas(value: any, linkDeltas: Map<E.Model<unknown>, Map<numb
|
|
|
251
273
|
}
|
|
252
274
|
}
|
|
253
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
|
+
|
|
254
284
|
E.setOnSaveCallback((commitId: number, items: Map<E.Model<any>, E.Change>) => {
|
|
255
285
|
if (logLevel >= 3) console.log('[lowlander] onSave', commitId);
|
|
256
286
|
for(const [model, changed] of items.entries()) {
|
|
@@ -293,10 +323,24 @@ export function sendModel(target: Uint8Array | number | number[], model: E.Model
|
|
|
293
323
|
else { // changed is an object or 'created'
|
|
294
324
|
const linkDeltas = new Map<E.Model<unknown>, Map<number,number>>();
|
|
295
325
|
|
|
326
|
+
let oldClone: any;
|
|
327
|
+
|
|
296
328
|
pack.writeCollection('object', (addRecord) => {
|
|
297
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
|
+
|
|
298
343
|
if (typeof changed === 'object' && !changed.hasOwnProperty(fieldName)) continue;
|
|
299
|
-
let streamIndex = StreamType.fields[fieldName];
|
|
300
344
|
|
|
301
345
|
let fieldValue = (model as any)[fieldName];
|
|
302
346
|
mustSend = true;
|
|
@@ -427,6 +471,7 @@ export class Socket<T> {
|
|
|
427
471
|
return `{Socket id=${this.virtualSocketId}}`;
|
|
428
472
|
}
|
|
429
473
|
|
|
474
|
+
/** @internal */
|
|
430
475
|
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
431
476
|
return this.toString();
|
|
432
477
|
}
|
|
@@ -454,10 +499,16 @@ export async function start(mainApiFile: string, opts: {bind?: string, threads?:
|
|
|
454
499
|
if (opts.injectWarpSocket) {
|
|
455
500
|
warpsocket = opts.injectWarpSocket;
|
|
456
501
|
}
|
|
502
|
+
const pwFile = join(process.cwd(), '.lowlander_dashboard_password');
|
|
503
|
+
if (!existsSync(pwFile)) try { writeFileSync(pwFile, randomBytes(24).toString('base64url'), { flag: 'wx' }); } catch {}
|
|
504
|
+
const password = process.env.LOWLANDER_DASHBOARD_PASSWORD ?? readFileSync(pwFile, 'utf8').trim();
|
|
505
|
+
if (!process.env.LOWLANDER_DASHBOARD_PASSWORD) {
|
|
506
|
+
console.log(`\n${'='.repeat(56)}\n Lowlander dashboard password: ${password}\n${'='.repeat(56)}\n`);
|
|
507
|
+
}
|
|
457
508
|
await warpsocket.start({
|
|
458
509
|
bind: opts.bind || '0.0.0.0:8080',
|
|
459
510
|
threads: opts.threads,
|
|
460
511
|
workerPath: WSHANDLER_FILE,
|
|
461
|
-
workerArg: mainApiFile,
|
|
512
|
+
workerArg: { apiFile: mainApiFile, password },
|
|
462
513
|
});
|
|
463
514
|
}
|