esoul-sdk 0.16.0 → 0.18.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/api-reference.md +1227 -10
- package/bin/esoul-device-exec.mjs +99 -0
- package/dist/db/client-core.d.ts +10 -0
- package/dist/db/client-core.js +17 -0
- package/dist/db/compile-rules.d.ts +31 -0
- package/dist/db/compile-rules.js +67 -0
- package/dist/db/memory-client.d.ts +2 -0
- package/dist/db/memory-client.js +8 -6
- package/dist/db/schema-gen.d.ts +13 -3
- package/dist/db/schema-gen.js +59 -13
- package/dist/device.d.ts +136 -0
- package/dist/device.js +15 -0
- package/dist/failed-requests.d.ts +77 -0
- package/dist/failed-requests.js +127 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/machine/capabilities.d.ts +81 -0
- package/dist/machine/capabilities.js +211 -0
- package/dist/machine/cli.d.ts +2 -0
- package/dist/machine/cli.js +232 -0
- package/dist/machine/client.d.ts +46 -0
- package/dist/machine/client.js +91 -0
- package/dist/machine/commands.d.ts +136 -0
- package/dist/machine/commands.js +229 -0
- package/dist/machine/digest.d.ts +14 -0
- package/dist/machine/digest.js +42 -0
- package/dist/machine/files.d.ts +188 -0
- package/dist/machine/files.js +511 -0
- package/dist/machine/grant.d.ts +49 -0
- package/dist/machine/grant.js +69 -0
- package/dist/machine/index.d.ts +19 -0
- package/dist/machine/index.js +19 -0
- package/dist/machine/protocol.d.ts +52 -0
- package/dist/machine/protocol.js +88 -0
- package/dist/machine/runner.d.ts +43 -0
- package/dist/machine/runner.js +255 -0
- package/dist/machine/service.d.ts +30 -0
- package/dist/machine/service.js +189 -0
- package/dist/machine/shell.d.ts +65 -0
- package/dist/machine/shell.js +474 -0
- package/dist/machine/state.d.ts +50 -0
- package/dist/machine/state.js +96 -0
- package/dist/machine/supervisor.d.ts +54 -0
- package/dist/machine/supervisor.js +259 -0
- package/dist/machine/wake.d.ts +31 -0
- package/dist/machine/wake.js +105 -0
- package/dist/manifest.d.ts +444 -0
- package/dist/manifest.js +51 -0
- package/dist/react.d.ts +110 -0
- package/dist/react.js +42 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +20 -0
- package/docs/03-events-and-state.md +9 -4
- package/docs/05-ui.md +65 -0
- package/docs/14-database.md +21 -3
- package/docs/18-your-computer.md +235 -0
- package/llms-full.txt +333 -6
- package/llms.txt +1 -0
- package/package.json +10 -4
- package/schemas/plugin.schema.json +242 -0
- package/scripts/build-api-reference.mjs +1 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The process that OWNS one run. The runtime starts it detached, so it — and
|
|
3
|
+
// the command under it — outlive a runtime restart or upgrade; the runtime
|
|
4
|
+
// reattaches by reading the files it leaves in the run directory.
|
|
5
|
+
//
|
|
6
|
+
// meta.json (in) { argv, cwd, env, timeoutSeconds }
|
|
7
|
+
// out.log (out) stdout and stderr, interleaved, appended as they come
|
|
8
|
+
// pid.json (out) { wrapperPid, childPid, startedAt, bootId }
|
|
9
|
+
// exit.json (out) { exitCode, signal, reason, startedAt, endedAt }
|
|
10
|
+
// cancel (in) present = stop: SIGTERM the command's group, SIGKILL after 10 s
|
|
11
|
+
//
|
|
12
|
+
// Plain JavaScript with no imports beyond node: — it must run from any copy
|
|
13
|
+
// of the package, including the one the service runs, with nothing built.
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync, openSync, readFileSync, writeFileSync, renameSync, watch } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const dir = process.argv[2];
|
|
19
|
+
if (!dir) {
|
|
20
|
+
console.error("usage: esoul-device-exec <run-dir>");
|
|
21
|
+
process.exit(2);
|
|
22
|
+
}
|
|
23
|
+
const meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf8"));
|
|
24
|
+
const write = (name, value) => {
|
|
25
|
+
const p = join(dir, name);
|
|
26
|
+
writeFileSync(`${p}.tmp`, JSON.stringify(value));
|
|
27
|
+
renameSync(`${p}.tmp`, p);
|
|
28
|
+
};
|
|
29
|
+
const bootId = () => {
|
|
30
|
+
try {
|
|
31
|
+
return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const out = openSync(join(dir, "out.log"), "a");
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
let child;
|
|
40
|
+
try {
|
|
41
|
+
child = spawn(meta.argv[0], meta.argv.slice(1), {
|
|
42
|
+
cwd: meta.cwd || undefined,
|
|
43
|
+
env: meta.env,
|
|
44
|
+
stdio: ["ignore", out, out],
|
|
45
|
+
detached: true, // its own process group, so a stop reaches grandchildren too
|
|
46
|
+
});
|
|
47
|
+
} catch (e) {
|
|
48
|
+
write("exit.json", { exitCode: null, signal: null, reason: `could not start: ${e.message}`, startedAt, endedAt: Date.now() });
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let reason = null;
|
|
53
|
+
let finished = false;
|
|
54
|
+
child.on("error", (e) => {
|
|
55
|
+
if (finished) return;
|
|
56
|
+
finished = true;
|
|
57
|
+
write("exit.json", { exitCode: null, signal: null, reason: e.code === "ENOENT" ? `program not found: ${meta.argv[0]}` : `could not start: ${e.message}`, startedAt, endedAt: Date.now() });
|
|
58
|
+
process.exit(0);
|
|
59
|
+
});
|
|
60
|
+
child.on("spawn", () => write("pid.json", { wrapperPid: process.pid, childPid: child.pid, startedAt, bootId: bootId() }));
|
|
61
|
+
|
|
62
|
+
const stop = (why) => {
|
|
63
|
+
if (reason || finished) return;
|
|
64
|
+
reason = why;
|
|
65
|
+
try {
|
|
66
|
+
process.kill(-child.pid, "SIGTERM");
|
|
67
|
+
} catch {}
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(-child.pid, "SIGKILL");
|
|
71
|
+
} catch {}
|
|
72
|
+
}, 10_000).unref();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (meta.timeoutSeconds > 0) setTimeout(() => stop(`timed out after ${meta.timeoutSeconds} s`), meta.timeoutSeconds * 1000).unref();
|
|
76
|
+
const cancelFile = join(dir, "cancel");
|
|
77
|
+
// The cancel file's text is the reason ("cancelled", or "cancelled: <why>").
|
|
78
|
+
const checkCancel = () => {
|
|
79
|
+
if (!existsSync(cancelFile)) return;
|
|
80
|
+
let why = "cancelled";
|
|
81
|
+
try {
|
|
82
|
+
why = readFileSync(cancelFile, "utf8").trim() || why;
|
|
83
|
+
} catch {}
|
|
84
|
+
stop(why);
|
|
85
|
+
};
|
|
86
|
+
checkCancel();
|
|
87
|
+
try {
|
|
88
|
+
watch(dir, () => checkCancel()).unref();
|
|
89
|
+
} catch {}
|
|
90
|
+
const poll = setInterval(checkCancel, 1000);
|
|
91
|
+
poll.unref();
|
|
92
|
+
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(`runtime sent ${sig}`));
|
|
93
|
+
|
|
94
|
+
child.on("exit", (code, signal) => {
|
|
95
|
+
if (finished) return;
|
|
96
|
+
finished = true;
|
|
97
|
+
write("exit.json", { exitCode: code, signal, reason, startedAt, endedAt: Date.now() });
|
|
98
|
+
process.exit(0);
|
|
99
|
+
});
|
package/dist/db/client-core.d.ts
CHANGED
|
@@ -92,9 +92,19 @@ export declare function assertFilterShape(c: Core, where: Record<string, unknown
|
|
|
92
92
|
export declare function insensitiveContains(where: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
|
|
93
93
|
export type OrderBy = Record<string, "asc" | "desc"> | Record<string, "asc" | "desc">[];
|
|
94
94
|
export declare function orderTerms(c: Core, orderBy: OrderBy | undefined): Record<string, "asc" | "desc">[];
|
|
95
|
+
/**
|
|
96
|
+
* The order a page is read in, made TOTAL: the author's terms, then `id`. A cursor
|
|
97
|
+
* names a row by id, so the rows after it are well defined only when no two rows
|
|
98
|
+
* tie — ordering by `date` alone let Postgres return rows sharing the cursor's date
|
|
99
|
+
* twice or not at all (Achievement Network's demo seed read an achievement twice on
|
|
100
|
+
* its second page, 2026-09-29). Without an orderBy and without a cursor the order is
|
|
101
|
+
* left alone (insertion order in memory, the database's own otherwise).
|
|
102
|
+
*/
|
|
103
|
+
export declare function totalOrder(terms: Record<string, "asc" | "desc">[], cursor: unknown): Record<string, "asc" | "desc">[];
|
|
95
104
|
export declare function pageWindow(c: Core, args: {
|
|
96
105
|
take?: number;
|
|
97
106
|
skip?: number;
|
|
107
|
+
cursor?: unknown;
|
|
98
108
|
} | undefined): {
|
|
99
109
|
take: number;
|
|
100
110
|
skip: number;
|
package/dist/db/client-core.js
CHANGED
|
@@ -142,6 +142,19 @@ export function orderTerms(c, orderBy) {
|
|
|
142
142
|
assertFilterable(c, Object.keys(t), "orderBy");
|
|
143
143
|
return terms;
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* The order a page is read in, made TOTAL: the author's terms, then `id`. A cursor
|
|
147
|
+
* names a row by id, so the rows after it are well defined only when no two rows
|
|
148
|
+
* tie — ordering by `date` alone let Postgres return rows sharing the cursor's date
|
|
149
|
+
* twice or not at all (Achievement Network's demo seed read an achievement twice on
|
|
150
|
+
* its second page, 2026-09-29). Without an orderBy and without a cursor the order is
|
|
151
|
+
* left alone (insertion order in memory, the database's own otherwise).
|
|
152
|
+
*/
|
|
153
|
+
export function totalOrder(terms, cursor) {
|
|
154
|
+
if (!terms.length && !cursor)
|
|
155
|
+
return terms;
|
|
156
|
+
return terms.some((t) => "id" in t) ? terms : [...terms, { id: "asc" }];
|
|
157
|
+
}
|
|
145
158
|
export function pageWindow(c, args) {
|
|
146
159
|
const take = args?.take ?? DEFAULT_TAKE;
|
|
147
160
|
if (take > MAX_TAKE)
|
|
@@ -151,6 +164,10 @@ export function pageWindow(c, args) {
|
|
|
151
164
|
const skip = args?.skip ?? 0;
|
|
152
165
|
if (skip < 0)
|
|
153
166
|
bad(c, "skip must not be negative");
|
|
167
|
+
// Prisma's idiom is `cursor` + `skip: 1` (its cursor row is included); here the rows
|
|
168
|
+
// AFTER the cursor come back, so that `skip: 1` silently dropped a row per page.
|
|
169
|
+
if (args?.cursor && skip)
|
|
170
|
+
bad(c, "skip with cursor: the cursor's own row is already left out — drop skip");
|
|
154
171
|
return { take, skip };
|
|
155
172
|
}
|
|
156
173
|
/** Seeing soft-deleted rows is the owner's (or the app's own task's). */
|
|
@@ -189,6 +189,13 @@ export interface ManifestDbModel {
|
|
|
189
189
|
}
|
|
190
190
|
export interface CompileInput {
|
|
191
191
|
pluginId: string;
|
|
192
|
+
/**
|
|
193
|
+
* The app's `applicationType` — the prefix of every table it owns. Optional
|
|
194
|
+
* because an author's tests may compile a bare `db` block; when it is known,
|
|
195
|
+
* the compiler refuses a table name Postgres would truncate (and the
|
|
196
|
+
* generator, which always knows it, refuses the same).
|
|
197
|
+
*/
|
|
198
|
+
applicationType?: string;
|
|
192
199
|
db: Record<string, ManifestDbModel>;
|
|
193
200
|
/** From `roles.vocabulary`; a rule may only name a role this app declares. */
|
|
194
201
|
vocabulary?: string[];
|
|
@@ -216,6 +223,7 @@ export interface CompileInput {
|
|
|
216
223
|
*/
|
|
217
224
|
export interface ManifestForRules {
|
|
218
225
|
id?: string;
|
|
226
|
+
applicationType?: string;
|
|
219
227
|
db?: Record<string, ManifestDbModel> | Record<string, never> | null;
|
|
220
228
|
roles?: {
|
|
221
229
|
vocabulary?: string[];
|
|
@@ -238,6 +246,29 @@ export declare class RuleCompileError extends Error {
|
|
|
238
246
|
readonly keyPath: string;
|
|
239
247
|
constructor(keyPath: string, message: string);
|
|
240
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* An app's tables, columns and indexes are Postgres identifiers in ONE shared
|
|
251
|
+
* schema. Postgres silently TRUNCATES an identifier past 63 bytes
|
|
252
|
+
* (NAMEDATALEN − 1) — so a table name past the limit would be created under a
|
|
253
|
+
* name nobody asked for, and every later lookup by the full name would miss it.
|
|
254
|
+
* Such names are refused here, at compile, with the model named; index and
|
|
255
|
+
* primary-key names are derived, so the generator caps those instead
|
|
256
|
+
* (`schema-gen.ts` `capIdentifier`).
|
|
257
|
+
*/
|
|
258
|
+
export declare const PG_IDENTIFIER_MAX = 63;
|
|
259
|
+
/** A declared field (a column): camelCase, a-z first, then ASCII letters and digits. */
|
|
260
|
+
export declare const FIELD_NAME_RE: RegExp;
|
|
261
|
+
export declare function identifierBytes(s: string): number;
|
|
262
|
+
/** `ShipAddress` → `ship_address`; `Order` → `order`. */
|
|
263
|
+
export declare function snakeCase(name: string): string;
|
|
264
|
+
/**
|
|
265
|
+
* The table names an app's models become, checked: no two models of one app
|
|
266
|
+
* on the same table (`HTTPLog` and `HttpLog` are both `http_log`), an
|
|
267
|
+
* application type whose tables cannot be mistaken for another app's (no `__`
|
|
268
|
+
* inside it, no trailing `_` — `plugin_x__y__order` must be `plugin_x__y`'s,
|
|
269
|
+
* never `plugin_x`'s), and no table name past 63 bytes.
|
|
270
|
+
*/
|
|
271
|
+
export declare function checkTableNames(modelNames: string[], applicationType?: string): void;
|
|
241
272
|
export declare function camelKey(model: string): string;
|
|
242
273
|
/**
|
|
243
274
|
* Compile a manifest's `db` block. Throws `RuleCompileError` with the key path
|
package/dist/db/compile-rules.js
CHANGED
|
@@ -47,6 +47,7 @@ const surfaceNamesOf = (x) => (Array.isArray(x) ? x.map(String) : x && typeof x
|
|
|
47
47
|
export function rulesInputFromManifest(m, pluginId) {
|
|
48
48
|
return {
|
|
49
49
|
pluginId: pluginId ?? m.id ?? "app",
|
|
50
|
+
applicationType: m.applicationType,
|
|
50
51
|
db: (m.db ?? {}),
|
|
51
52
|
vocabulary: m.roles?.vocabulary,
|
|
52
53
|
ownerRole: m.roles?.default?.owner,
|
|
@@ -68,6 +69,65 @@ export class RuleCompileError extends Error {
|
|
|
68
69
|
this.name = "RuleCompileError";
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
/* ───────────────────────────── identifiers ───────────────────────────── */
|
|
73
|
+
/**
|
|
74
|
+
* An app's tables, columns and indexes are Postgres identifiers in ONE shared
|
|
75
|
+
* schema. Postgres silently TRUNCATES an identifier past 63 bytes
|
|
76
|
+
* (NAMEDATALEN − 1) — so a table name past the limit would be created under a
|
|
77
|
+
* name nobody asked for, and every later lookup by the full name would miss it.
|
|
78
|
+
* Such names are refused here, at compile, with the model named; index and
|
|
79
|
+
* primary-key names are derived, so the generator caps those instead
|
|
80
|
+
* (`schema-gen.ts` `capIdentifier`).
|
|
81
|
+
*/
|
|
82
|
+
export const PG_IDENTIFIER_MAX = 63;
|
|
83
|
+
/** A declared field (a column): camelCase, a-z first, then ASCII letters and digits. */
|
|
84
|
+
export const FIELD_NAME_RE = /^[a-z][A-Za-z0-9]*$/;
|
|
85
|
+
export function identifierBytes(s) {
|
|
86
|
+
let n = 0;
|
|
87
|
+
for (const ch of s) {
|
|
88
|
+
const c = ch.codePointAt(0);
|
|
89
|
+
n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4;
|
|
90
|
+
}
|
|
91
|
+
return n;
|
|
92
|
+
}
|
|
93
|
+
/** `ShipAddress` → `ship_address`; `Order` → `order`. */
|
|
94
|
+
export function snakeCase(name) {
|
|
95
|
+
return name
|
|
96
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
97
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
|
|
98
|
+
.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The table names an app's models become, checked: no two models of one app
|
|
102
|
+
* on the same table (`HTTPLog` and `HttpLog` are both `http_log`), an
|
|
103
|
+
* application type whose tables cannot be mistaken for another app's (no `__`
|
|
104
|
+
* inside it, no trailing `_` — `plugin_x__y__order` must be `plugin_x__y`'s,
|
|
105
|
+
* never `plugin_x`'s), and no table name past 63 bytes.
|
|
106
|
+
*/
|
|
107
|
+
export function checkTableNames(modelNames, applicationType) {
|
|
108
|
+
const seen = new Map();
|
|
109
|
+
for (const m of modelNames) {
|
|
110
|
+
const snake = snakeCase(m);
|
|
111
|
+
const prev = seen.get(snake);
|
|
112
|
+
if (prev !== undefined) {
|
|
113
|
+
throw new RuleCompileError(`db.${m}`, `models "${prev}" and "${m}" would both be the table "…__${snake}" — rename one of them`);
|
|
114
|
+
}
|
|
115
|
+
seen.set(snake, m);
|
|
116
|
+
}
|
|
117
|
+
if (applicationType === undefined || !modelNames.length)
|
|
118
|
+
return;
|
|
119
|
+
if (applicationType.includes("__") || applicationType.endsWith("_")) {
|
|
120
|
+
throw new RuleCompileError("applicationType", `"${applicationType}" has "__" in it or ends with "_" — an app with tables names them "<applicationType>__<model>", and that name must not be readable as another app's table. Use single underscores (e.g. plugin_my_app).`);
|
|
121
|
+
}
|
|
122
|
+
for (const m of modelNames) {
|
|
123
|
+
const table = `${applicationType}__${snakeCase(m)}`;
|
|
124
|
+
const n = identifierBytes(table);
|
|
125
|
+
if (n > PG_IDENTIFIER_MAX) {
|
|
126
|
+
const room = PG_IDENTIFIER_MAX - identifierBytes(applicationType) - 2;
|
|
127
|
+
throw new RuleCompileError(`db.${m}`, `the table "${table}" is ${n} bytes; Postgres names are at most ${PG_IDENTIFIER_MAX} — shorten the model name so "${snakeCase(m)}" is at most ${Math.max(room, 0)} characters (or shorten the applicationType)`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
71
131
|
const FIELD_TYPES = ["string", "text", "int", "float", "boolean", "datetime", "json"];
|
|
72
132
|
// Positional groups, not named ones: the repo-wide tsc targets below ES2018.
|
|
73
133
|
// [1] base type, [2] "[]" for a list, [3] "?" for optional, [4] the default.
|
|
@@ -267,6 +327,12 @@ export function compileRules(input) {
|
|
|
267
327
|
throw new RuleCompileError(`${at}.fields`, "a model declares at least one field");
|
|
268
328
|
const fields = {};
|
|
269
329
|
for (const f of fieldNames) {
|
|
330
|
+
if (!FIELD_NAME_RE.test(f)) {
|
|
331
|
+
throw new RuleCompileError(`${at}.fields.${f}`, "a field name is camelCase: a lowercase letter a-z first, then ASCII letters and digits");
|
|
332
|
+
}
|
|
333
|
+
if (identifierBytes(f) > PG_IDENTIFIER_MAX) {
|
|
334
|
+
throw new RuleCompileError(`${at}.fields.${f}`, `a field name is a column name, at most ${PG_IDENTIFIER_MAX} characters — this one is ${identifierBytes(f)}`);
|
|
335
|
+
}
|
|
270
336
|
if (PLATFORM_COLUMNS.includes(f)) {
|
|
271
337
|
throw new RuleCompileError(`${at}.fields.${f}`, `"${f}" is a column the platform owns and stamps — it may not be declared. Platform columns: ${PLATFORM_COLUMNS.join(", ")}`);
|
|
272
338
|
}
|
|
@@ -373,6 +439,7 @@ export function compileRules(input) {
|
|
|
373
439
|
rules,
|
|
374
440
|
};
|
|
375
441
|
}
|
|
442
|
+
checkTableNames(modelNames, input.applicationType);
|
|
376
443
|
const custom = compileEnvelope(input.custom, Object.fromEntries(Object.entries(models).map(([name, m]) => [name, { fields: Object.keys(m.fields), filterable: m.filterable }])), input.surfaces ?? [], attributes);
|
|
377
444
|
return { version: 1, pluginId: input.pluginId, models, custom, attributes };
|
|
378
445
|
}
|
|
@@ -82,6 +82,8 @@ export interface PluginDbClient {
|
|
|
82
82
|
export interface MemoryStore {
|
|
83
83
|
rows: Record<string, Row[]>;
|
|
84
84
|
seq: number;
|
|
85
|
+
/** Rows ever minted in this store — the ids' ordinal, shared by every client over it. */
|
|
86
|
+
minted?: number;
|
|
85
87
|
}
|
|
86
88
|
export declare function createStore(rules: CompiledRules): MemoryStore;
|
|
87
89
|
export interface MemoryDbOptions {
|
package/dist/db/memory-client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { camelKey } from "./compile-rules.js";
|
|
2
|
-
import { assertFilterShape, assertFilterable, assertIncludeDeleted, assertNoInjectedKeys, assertRefTargets, assertTransition, assertUnique, checkWritableData, hideFields, narrowMatches, withinScope, declaredValues, defaultRefuse, mayUnseal, orderTerms, ownerFilter, pageWindow, readPlan, scopeMatches, stampNewRow, writeGate, } from "./client-core.js";
|
|
2
|
+
import { assertFilterShape, assertFilterable, assertIncludeDeleted, assertNoInjectedKeys, assertRefTargets, assertTransition, assertUnique, checkWritableData, hideFields, narrowMatches, withinScope, declaredValues, defaultRefuse, mayUnseal, orderTerms, totalOrder, ownerFilter, pageWindow, readPlan, scopeMatches, stampNewRow, writeGate, } from "./client-core.js";
|
|
3
3
|
export function createStore(rules) {
|
|
4
4
|
const rows = {};
|
|
5
5
|
for (const name of Object.keys(rules.models))
|
|
@@ -11,8 +11,10 @@ export function createMemoryDb(options) {
|
|
|
11
11
|
const { rules, viewer, scope, across, ownedWorkspaceIds = [], viaBinding = false, refuse = defaultRefuse, } = options;
|
|
12
12
|
const store = options.store ?? createStore(rules);
|
|
13
13
|
const now = options.now ?? (() => new Date());
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Zero-padded and counted on the STORE (every viewer's client shares it), so id order
|
|
15
|
+
// is insertion order: a page breaks ties by id (totalOrder).
|
|
16
|
+
const newId = options.newId ??
|
|
17
|
+
(() => `row_${String((store.minted = (store.minted ?? 0) + 1)).padStart(9, "0")}_${Math.random().toString(36).slice(2, 8)}`);
|
|
16
18
|
const db = {};
|
|
17
19
|
for (const model of Object.values(rules.models)) {
|
|
18
20
|
db[model.key] = makeCollection(model);
|
|
@@ -126,8 +128,8 @@ export function createMemoryDb(options) {
|
|
|
126
128
|
rows = rows.filter((r) => Object.entries(where).every(([k, v]) => matchOne(r[k], v)));
|
|
127
129
|
return rows;
|
|
128
130
|
}
|
|
129
|
-
function sortRows(c, rows, orderBy) {
|
|
130
|
-
const terms = orderTerms(c, orderBy);
|
|
131
|
+
function sortRows(c, rows, orderBy, cursor) {
|
|
132
|
+
const terms = totalOrder(orderTerms(c, orderBy), cursor);
|
|
131
133
|
if (!terms.length)
|
|
132
134
|
return rows;
|
|
133
135
|
return [...rows].sort((a, b) => {
|
|
@@ -151,7 +153,7 @@ export function createMemoryDb(options) {
|
|
|
151
153
|
}
|
|
152
154
|
function page(c, rows, args) {
|
|
153
155
|
const { take, skip } = pageWindow(c, args);
|
|
154
|
-
let out = sortRows(c, rows, args?.orderBy);
|
|
156
|
+
let out = sortRows(c, rows, args?.orderBy, args?.cursor);
|
|
155
157
|
if (args?.cursor) {
|
|
156
158
|
const at = out.findIndex((r) => r.id === args.cursor.id);
|
|
157
159
|
out = at >= 0 ? out.slice(at + 1) : [];
|
package/dist/db/schema-gen.d.ts
CHANGED
|
@@ -35,8 +35,16 @@
|
|
|
35
35
|
* the platform actually runs.
|
|
36
36
|
*/
|
|
37
37
|
import type { CompiledRules, IndexKind } from "./compile-rules.js";
|
|
38
|
-
|
|
39
|
-
export
|
|
38
|
+
import { snakeCase } from "./compile-rules.js";
|
|
39
|
+
export { snakeCase };
|
|
40
|
+
/**
|
|
41
|
+
* A derived identifier (an index or a primary key) that fits Postgres's 63
|
|
42
|
+
* bytes. A name that already fits is returned UNCHANGED — every table, index
|
|
43
|
+
* and key already in production keeps its name. One that does not becomes
|
|
44
|
+
* `<first N chars>_<8-hex hash of the full name><suffix>`: deterministic, and
|
|
45
|
+
* two long names differing only past the cut still differ.
|
|
46
|
+
*/
|
|
47
|
+
export declare function capIdentifier(full: string, suffix: string): string;
|
|
40
48
|
/** The Prisma model name: `plugin_shop_min__Order`. */
|
|
41
49
|
export declare function prismaModelName(applicationType: string, model: string): string;
|
|
42
50
|
/** The table it maps to: `plugin_shop_min__order`. */
|
|
@@ -96,8 +104,10 @@ export interface TableSpec {
|
|
|
96
104
|
columns: TableColumn[];
|
|
97
105
|
indexes: TableIndex[];
|
|
98
106
|
}
|
|
99
|
-
/** Prisma's index name
|
|
107
|
+
/** Prisma's index name, `<table>_<col>_<col>_idx` — capped (`capIdentifier`) only when it would pass 63 bytes. */
|
|
100
108
|
export declare function indexName(table: string, columns: string[]): string;
|
|
109
|
+
/** Prisma's primary-key name, `<table>_pkey` — capped only when it would pass 63 bytes. */
|
|
110
|
+
export declare function pkeyName(table: string): string;
|
|
101
111
|
/** Every table an app declares, as columns and indexes — the input to the planner and the emitter. */
|
|
102
112
|
export declare function tableSpecs(rules: CompiledRules, opts: {
|
|
103
113
|
applicationType: string;
|
package/dist/db/schema-gen.js
CHANGED
|
@@ -1,10 +1,37 @@
|
|
|
1
|
+
import { checkTableNames, identifierBytes, PG_IDENTIFIER_MAX, snakeCase } from "./compile-rules.js";
|
|
1
2
|
/* ───────────────────────────── naming ─────────────────────────────────── */
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
// One implementation, in the compiler (which refuses colliding and over-long
|
|
4
|
+
// table names at compile); re-exported so every caller of the generator keeps
|
|
5
|
+
// its import.
|
|
6
|
+
export { snakeCase };
|
|
7
|
+
/** FNV-1a, 32 bits, as 8 hex digits — pure and stable, so a capped name is the same on every run. */
|
|
8
|
+
function hash8(s) {
|
|
9
|
+
let h = 0x811c9dc5;
|
|
10
|
+
for (let i = 0; i < s.length; i++) {
|
|
11
|
+
h ^= s.charCodeAt(i);
|
|
12
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
13
|
+
}
|
|
14
|
+
return h.toString(16).padStart(8, "0");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A derived identifier (an index or a primary key) that fits Postgres's 63
|
|
18
|
+
* bytes. A name that already fits is returned UNCHANGED — every table, index
|
|
19
|
+
* and key already in production keeps its name. One that does not becomes
|
|
20
|
+
* `<first N chars>_<8-hex hash of the full name><suffix>`: deterministic, and
|
|
21
|
+
* two long names differing only past the cut still differ.
|
|
22
|
+
*/
|
|
23
|
+
export function capIdentifier(full, suffix) {
|
|
24
|
+
if (identifierBytes(full) <= PG_IDENTIFIER_MAX)
|
|
25
|
+
return full;
|
|
26
|
+
const stem = full.endsWith(suffix) ? full.slice(0, full.length - suffix.length) : full;
|
|
27
|
+
const room = PG_IDENTIFIER_MAX - 1 - 8 - identifierBytes(suffix);
|
|
28
|
+
let cut = "";
|
|
29
|
+
for (const ch of stem) {
|
|
30
|
+
if (identifierBytes(cut + ch) > room)
|
|
31
|
+
break;
|
|
32
|
+
cut += ch;
|
|
33
|
+
}
|
|
34
|
+
return `${cut}_${hash8(full)}${suffix}`;
|
|
8
35
|
}
|
|
9
36
|
/** The Prisma model name: `plugin_shop_min__Order`. */
|
|
10
37
|
export function prismaModelName(applicationType, model) {
|
|
@@ -116,8 +143,14 @@ function prismaIndexArgs(g) {
|
|
|
116
143
|
return `[${g.fields.join(", ")}]`;
|
|
117
144
|
}
|
|
118
145
|
function renderModel(model, applicationType) {
|
|
146
|
+
const table = prismaTableName(applicationType, model.name);
|
|
147
|
+
const pkey = pkeyName(table);
|
|
148
|
+
// Prisma names a key `<table>_pkey` and an index `<table>_<cols>_idx`; only
|
|
149
|
+
// a name the cap CHANGED is spelled out with `map:`, so a fragment whose
|
|
150
|
+
// names fit is byte-for-byte what it always was.
|
|
151
|
+
const platform = platformColumns(model.scope).map((c) => c.name === "id" && pkey !== `${table}_pkey` ? { ...c, attrs: c.attrs.replace("@id", `@id(map: ${JSON.stringify(pkey)})`) } : c);
|
|
119
152
|
const cols = [
|
|
120
|
-
...
|
|
153
|
+
...platform,
|
|
121
154
|
...Object.keys(model.fields).map((name) => declaredColumn(name, model.fields[name], model.sealed.includes(name))),
|
|
122
155
|
];
|
|
123
156
|
const nameW = Math.max(...cols.map((c) => c.name.length));
|
|
@@ -126,13 +159,17 @@ function renderModel(model, applicationType) {
|
|
|
126
159
|
const head = ` ${c.name.padEnd(nameW)} ${c.type.padEnd(typeW)}`;
|
|
127
160
|
return (c.attrs ? `${head} ${c.attrs}` : head).trimEnd();
|
|
128
161
|
});
|
|
129
|
-
const idx = indexGroups(model).map((g) =>
|
|
162
|
+
const idx = indexGroups(model).map((g) => {
|
|
163
|
+
const name = indexName(table, g.fields);
|
|
164
|
+
const map = name === `${table}_${g.fields.join("_")}_idx` ? "" : `, map: ${JSON.stringify(name)}`;
|
|
165
|
+
return ` @@index(${prismaIndexArgs(g)}${map})`;
|
|
166
|
+
});
|
|
130
167
|
return [
|
|
131
168
|
`model ${prismaModelName(applicationType, model.name)} {`,
|
|
132
169
|
...lines,
|
|
133
170
|
"",
|
|
134
171
|
...idx,
|
|
135
|
-
` @@map(${JSON.stringify(
|
|
172
|
+
` @@map(${JSON.stringify(table)})`,
|
|
136
173
|
"}",
|
|
137
174
|
].join("\n");
|
|
138
175
|
}
|
|
@@ -142,6 +179,7 @@ function renderModel(model, applicationType) {
|
|
|
142
179
|
* generator: this is a FRAGMENT that joins the platform's schema folder.
|
|
143
180
|
*/
|
|
144
181
|
export function prismaFragment(rules, opts) {
|
|
182
|
+
checkTableNames(Object.keys(rules.models), opts.applicationType);
|
|
145
183
|
const models = Object.keys(rules.models).map((name) => rules.models[name]);
|
|
146
184
|
const body = models.map((m) => renderModel(m, opts.applicationType)).join("\n\n");
|
|
147
185
|
return [
|
|
@@ -314,12 +352,19 @@ function sqlColumn(c) {
|
|
|
314
352
|
// or the planner would read a difference against the live table forever.
|
|
315
353
|
return { name: c.name, type: list ? `${sql}[]` : sql, nullable: optional || list, default: def };
|
|
316
354
|
}
|
|
317
|
-
/** Prisma's index name
|
|
355
|
+
/** Prisma's index name, `<table>_<col>_<col>_idx` — capped (`capIdentifier`) only when it would pass 63 bytes. */
|
|
318
356
|
export function indexName(table, columns) {
|
|
319
|
-
return `${table}_${columns.join("_")}_idx
|
|
357
|
+
return capIdentifier(`${table}_${columns.join("_")}_idx`, "_idx");
|
|
358
|
+
}
|
|
359
|
+
/** Prisma's primary-key name, `<table>_pkey` — capped only when it would pass 63 bytes. */
|
|
360
|
+
export function pkeyName(table) {
|
|
361
|
+
return capIdentifier(`${table}_pkey`, "_pkey");
|
|
320
362
|
}
|
|
321
363
|
/** Every table an app declares, as columns and indexes — the input to the planner and the emitter. */
|
|
322
364
|
export function tableSpecs(rules, opts) {
|
|
365
|
+
// A compiled artefact may come from a compile that did not know the type
|
|
366
|
+
// (an author's bare `db`): the generator always does, so it checks again.
|
|
367
|
+
checkTableNames(Object.keys(rules.models), opts.applicationType);
|
|
323
368
|
return Object.keys(rules.models).map((name) => {
|
|
324
369
|
const model = rules.models[name];
|
|
325
370
|
const table = prismaTableName(opts.applicationType, model.name);
|
|
@@ -334,12 +379,13 @@ export function tableSpecs(rules, opts) {
|
|
|
334
379
|
};
|
|
335
380
|
});
|
|
336
381
|
}
|
|
337
|
-
|
|
382
|
+
/** A quoted identifier; a `"` inside one is doubled, as SQL spells it. */
|
|
383
|
+
const q = (ident) => `"${ident.replace(/"/g, '""')}"`;
|
|
338
384
|
function columnDef(c) {
|
|
339
385
|
return `${q(c.name)} ${c.type}${c.nullable ? "" : " NOT NULL"}${c.default !== null ? ` DEFAULT ${c.default}` : ""}`;
|
|
340
386
|
}
|
|
341
387
|
export function createTableSql(t) {
|
|
342
|
-
return [`CREATE TABLE ${q(t.name)} (`, ...t.columns.map((c) => ` ${columnDef(c)},`), "", ` CONSTRAINT ${q(
|
|
388
|
+
return [`CREATE TABLE ${q(t.name)} (`, ...t.columns.map((c) => ` ${columnDef(c)},`), "", ` CONSTRAINT ${q(pkeyName(t.name))} PRIMARY KEY ("id")`, ");"].join("\n");
|
|
343
389
|
}
|
|
344
390
|
export function createIndexSql(table, idx) {
|
|
345
391
|
if (idx.kind === "text")
|
package/dist/device.d.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for `devices(ctx)` — parts of an app that run on the person's own
|
|
3
|
+
* computer (plugin-device-arm.md §10–§11). The functions live in server.ts
|
|
4
|
+
* (host-only); the hooks and <ConnectComputer/> in react.ts.
|
|
5
|
+
*
|
|
6
|
+
* The flow an app is built around:
|
|
7
|
+
* 1. its config page renders <ConnectComputer workspaceId nodeId /> — one
|
|
8
|
+
* command to paste, a code to check, an Approve button;
|
|
9
|
+
* 2. its server code runs a DECLARED command (plugin.json `device.commands`):
|
|
10
|
+
* const d = devices(ctx);
|
|
11
|
+
* const { commandId } = await d.run("train", { epochs: 3 });
|
|
12
|
+
* 3. its UI shows the run live with useDeviceRun({ workspaceId, nodeId, commandId }).
|
|
13
|
+
*/
|
|
14
|
+
export type DeviceRunStatus = "queued" | "claimed" | "running" | "done" | "expired" | "cancelled" | "lost";
|
|
15
|
+
/** What an app's agents may reach on a computer through the shared command line. */
|
|
16
|
+
export type WorkspaceGrant = {
|
|
17
|
+
scope: "folder";
|
|
18
|
+
root: string;
|
|
19
|
+
} | {
|
|
20
|
+
scope: "computer";
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* One line of the shared command line, answered (esoul-sdk/machine shell.ts):
|
|
24
|
+
* `text` is what a terminal would print (what an agent reads), `data` the same
|
|
25
|
+
* answer structured, `opId` the journal entry to `undo`.
|
|
26
|
+
*/
|
|
27
|
+
export interface ShellResult {
|
|
28
|
+
ok: boolean;
|
|
29
|
+
text: string;
|
|
30
|
+
data?: unknown;
|
|
31
|
+
exitCode?: number | null;
|
|
32
|
+
error?: string;
|
|
33
|
+
opId?: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
}
|
|
36
|
+
/** One computer connected to this app instance. */
|
|
37
|
+
export interface DeviceSummary {
|
|
38
|
+
linkId: string;
|
|
39
|
+
status: "active" | "suspended";
|
|
40
|
+
reason: string | null;
|
|
41
|
+
/** The declared commands this computer will run (the ones the owner approved). */
|
|
42
|
+
commands: string[];
|
|
43
|
+
/** Commands an app update added or changed since approval; they wait for the owner. */
|
|
44
|
+
pendingApproval: {
|
|
45
|
+
added: string[];
|
|
46
|
+
changed: string[];
|
|
47
|
+
};
|
|
48
|
+
machine: {
|
|
49
|
+
machineId: string;
|
|
50
|
+
hostname: string;
|
|
51
|
+
os: string;
|
|
52
|
+
arch: string | null;
|
|
53
|
+
runtimeVersion: string | null;
|
|
54
|
+
lastSeenAt: number | null;
|
|
55
|
+
online: boolean;
|
|
56
|
+
} | null;
|
|
57
|
+
/** What this app's agents may reach on that computer (null = declared commands only). */
|
|
58
|
+
workspace?: WorkspaceGrant | null;
|
|
59
|
+
}
|
|
60
|
+
export interface DeviceRunStarted {
|
|
61
|
+
commandId: string;
|
|
62
|
+
status: DeviceRunStatus;
|
|
63
|
+
/** Whether the computer answered in the last minute. A run for an offline computer waits (until `expiresAt`). */
|
|
64
|
+
machineOnline: boolean;
|
|
65
|
+
expiresAt: number;
|
|
66
|
+
}
|
|
67
|
+
export interface DeviceRunView {
|
|
68
|
+
commandId: string;
|
|
69
|
+
linkId: string;
|
|
70
|
+
machineId: string;
|
|
71
|
+
/** "commands" (a declared command) or "workspace" (the shared command line). */
|
|
72
|
+
capability?: string;
|
|
73
|
+
/** The named agent, for workspace jobs. */
|
|
74
|
+
agent?: string | null;
|
|
75
|
+
name: string;
|
|
76
|
+
params: Record<string, unknown>;
|
|
77
|
+
argv: string[];
|
|
78
|
+
status: DeviceRunStatus;
|
|
79
|
+
exitCode: number | null;
|
|
80
|
+
signal: string | null;
|
|
81
|
+
/** Why it ended when it did not simply exit: "machine_restarted", "cancelled: …", "timed out after …", a refusal. */
|
|
82
|
+
reason: string | null;
|
|
83
|
+
/** The first 1 MB of output (stdout and stderr interleaved). */
|
|
84
|
+
output?: string;
|
|
85
|
+
/** When `truncated` (or the command keeps only the tail), the last 64 KB. */
|
|
86
|
+
tail?: string;
|
|
87
|
+
/** The command's parsed answer, when it declared `result: "json"` and ended well. */
|
|
88
|
+
result?: unknown;
|
|
89
|
+
/** Why `result` is missing: not JSON, or larger than the stored head. */
|
|
90
|
+
resultError?: string | null;
|
|
91
|
+
/** How its output travelled: live, batched or final (the command's `output.mode`). */
|
|
92
|
+
outputMode?: "live" | "batched" | "final";
|
|
93
|
+
outputBytes: number;
|
|
94
|
+
outputSeq: number;
|
|
95
|
+
truncated: boolean;
|
|
96
|
+
createdAt: number;
|
|
97
|
+
startedAt: number | null;
|
|
98
|
+
endedAt: number | null;
|
|
99
|
+
expiresAt: number;
|
|
100
|
+
}
|
|
101
|
+
export interface DeviceRunOptions {
|
|
102
|
+
/** Which computer. Optional when exactly one active computer is connected. */
|
|
103
|
+
linkId?: string;
|
|
104
|
+
/** How long the run may wait for an offline computer before it expires. Default 600 s. */
|
|
105
|
+
expiresInSeconds?: number;
|
|
106
|
+
}
|
|
107
|
+
export interface Devices {
|
|
108
|
+
list(): Promise<DeviceSummary[]>;
|
|
109
|
+
run(name: string, params?: Record<string, unknown>, opts?: DeviceRunOptions): Promise<DeviceRunStarted>;
|
|
110
|
+
get(commandId: string): Promise<DeviceRunView>;
|
|
111
|
+
/** Wait for a run to end, up to `timeoutSeconds` (≤ 55 in an op; use a task for longer). Returns the run as it is then. */
|
|
112
|
+
wait(commandId: string, opts?: {
|
|
113
|
+
timeoutSeconds?: number;
|
|
114
|
+
}): Promise<DeviceRunView>;
|
|
115
|
+
cancel(commandId: string): Promise<{
|
|
116
|
+
status: string;
|
|
117
|
+
}>;
|
|
118
|
+
/**
|
|
119
|
+
* One line of the shared command line on the computer, as a NAMED agent —
|
|
120
|
+
* `ls`, `tree`, `cat -n f --lines 10:40`, `write f` / `edit f` / `patch f`
|
|
121
|
+
* (content on `stdin`), `run npm test`, `term new dev npm run dev`, `undo <op>`
|
|
122
|
+
* (`help` lists them). Needs a workspace grant (a folder or the computer).
|
|
123
|
+
* Waits up to `waitSeconds` (default 30, ≤ 55) and returns the answer.
|
|
124
|
+
*/
|
|
125
|
+
sh(line: string, opts: {
|
|
126
|
+
agent: string;
|
|
127
|
+
stdin?: string;
|
|
128
|
+
linkId?: string;
|
|
129
|
+
waitSeconds?: number;
|
|
130
|
+
}): Promise<ShellResult & {
|
|
131
|
+
commandId: string;
|
|
132
|
+
status: DeviceRunStatus;
|
|
133
|
+
}>;
|
|
134
|
+
}
|
|
135
|
+
/** The statuses a run ends in: done, expired, cancelled, lost. */
|
|
136
|
+
export declare const DEVICE_RUN_TERMINAL: readonly DeviceRunStatus[];
|
package/dist/device.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for `devices(ctx)` — parts of an app that run on the person's own
|
|
3
|
+
* computer (plugin-device-arm.md §10–§11). The functions live in server.ts
|
|
4
|
+
* (host-only); the hooks and <ConnectComputer/> in react.ts.
|
|
5
|
+
*
|
|
6
|
+
* The flow an app is built around:
|
|
7
|
+
* 1. its config page renders <ConnectComputer workspaceId nodeId /> — one
|
|
8
|
+
* command to paste, a code to check, an Approve button;
|
|
9
|
+
* 2. its server code runs a DECLARED command (plugin.json `device.commands`):
|
|
10
|
+
* const d = devices(ctx);
|
|
11
|
+
* const { commandId } = await d.run("train", { epochs: 3 });
|
|
12
|
+
* 3. its UI shows the run live with useDeviceRun({ workspaceId, nodeId, commandId }).
|
|
13
|
+
*/
|
|
14
|
+
/** The statuses a run ends in: done, expired, cancelled, lost. */
|
|
15
|
+
export const DEVICE_RUN_TERMINAL = ["done", "expired", "cancelled", "lost"];
|