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.
Files changed (61) hide show
  1. package/api-reference.md +1227 -10
  2. package/bin/esoul-device-exec.mjs +99 -0
  3. package/dist/db/client-core.d.ts +10 -0
  4. package/dist/db/client-core.js +17 -0
  5. package/dist/db/compile-rules.d.ts +31 -0
  6. package/dist/db/compile-rules.js +67 -0
  7. package/dist/db/memory-client.d.ts +2 -0
  8. package/dist/db/memory-client.js +8 -6
  9. package/dist/db/schema-gen.d.ts +13 -3
  10. package/dist/db/schema-gen.js +59 -13
  11. package/dist/device.d.ts +136 -0
  12. package/dist/device.js +15 -0
  13. package/dist/failed-requests.d.ts +77 -0
  14. package/dist/failed-requests.js +127 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.js +2 -0
  17. package/dist/machine/capabilities.d.ts +81 -0
  18. package/dist/machine/capabilities.js +211 -0
  19. package/dist/machine/cli.d.ts +2 -0
  20. package/dist/machine/cli.js +232 -0
  21. package/dist/machine/client.d.ts +46 -0
  22. package/dist/machine/client.js +91 -0
  23. package/dist/machine/commands.d.ts +136 -0
  24. package/dist/machine/commands.js +229 -0
  25. package/dist/machine/digest.d.ts +14 -0
  26. package/dist/machine/digest.js +42 -0
  27. package/dist/machine/files.d.ts +188 -0
  28. package/dist/machine/files.js +511 -0
  29. package/dist/machine/grant.d.ts +49 -0
  30. package/dist/machine/grant.js +69 -0
  31. package/dist/machine/index.d.ts +19 -0
  32. package/dist/machine/index.js +19 -0
  33. package/dist/machine/protocol.d.ts +52 -0
  34. package/dist/machine/protocol.js +88 -0
  35. package/dist/machine/runner.d.ts +43 -0
  36. package/dist/machine/runner.js +255 -0
  37. package/dist/machine/service.d.ts +30 -0
  38. package/dist/machine/service.js +189 -0
  39. package/dist/machine/shell.d.ts +65 -0
  40. package/dist/machine/shell.js +474 -0
  41. package/dist/machine/state.d.ts +50 -0
  42. package/dist/machine/state.js +96 -0
  43. package/dist/machine/supervisor.d.ts +54 -0
  44. package/dist/machine/supervisor.js +259 -0
  45. package/dist/machine/wake.d.ts +31 -0
  46. package/dist/machine/wake.js +105 -0
  47. package/dist/manifest.d.ts +444 -0
  48. package/dist/manifest.js +51 -0
  49. package/dist/react.d.ts +110 -0
  50. package/dist/react.js +42 -0
  51. package/dist/server.d.ts +28 -0
  52. package/dist/server.js +20 -0
  53. package/docs/03-events-and-state.md +9 -4
  54. package/docs/05-ui.md +65 -0
  55. package/docs/14-database.md +21 -3
  56. package/docs/18-your-computer.md +235 -0
  57. package/llms-full.txt +333 -6
  58. package/llms.txt +1 -0
  59. package/package.json +10 -4
  60. package/schemas/plugin.schema.json +242 -0
  61. 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
+ });
@@ -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;
@@ -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
@@ -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 {
@@ -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
- let idSeq = 0;
15
- const newId = options.newId ?? (() => `row_${++idSeq}_${Math.random().toString(36).slice(2, 8)}`);
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) : [];
@@ -35,8 +35,16 @@
35
35
  * the platform actually runs.
36
36
  */
37
37
  import type { CompiledRules, IndexKind } from "./compile-rules.js";
38
- /** `ShipAddress` → `ship_address`; `Order` → `order`. */
39
- export declare function snakeCase(name: string): string;
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: `<table>_<col>_<col>_idx`. */
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;
@@ -1,10 +1,37 @@
1
+ import { checkTableNames, identifierBytes, PG_IDENTIFIER_MAX, snakeCase } from "./compile-rules.js";
1
2
  /* ───────────────────────────── naming ─────────────────────────────────── */
2
- /** `ShipAddress` → `ship_address`; `Order` → `order`. */
3
- export function snakeCase(name) {
4
- return name
5
- .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
6
- .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
7
- .toLowerCase();
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
- ...platformColumns(model.scope),
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) => ` @@index(${prismaIndexArgs(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(prismaTableName(applicationType, model.name))})`,
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: `<table>_<col>_<col>_idx`. */
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
- const q = (ident) => `"${ident}"`;
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(`${t.name}_pkey`)} PRIMARY KEY ("id")`, ");"].join("\n");
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")
@@ -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"];