@nylorun/runtime 0.10.0-beta → 0.12.0-beta

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 (194) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +53 -51
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/sandbox/virtual.js +1 -1
  6. package/dist/adapters/streams/s2.d.ts +18 -0
  7. package/dist/adapters/streams/s2.js +361 -0
  8. package/dist/core/executors.d.ts +4 -1
  9. package/dist/core/executors.js +20 -1
  10. package/dist/core/flow-host.d.ts +93 -132
  11. package/dist/core/flow-host.js +282 -547
  12. package/dist/core/sandbox-routes.d.ts +13 -4
  13. package/dist/core/sandbox-routes.js +14 -12
  14. package/dist/execution/memory.d.ts +54 -0
  15. package/dist/execution/memory.js +217 -0
  16. package/dist/execution/types.d.ts +125 -0
  17. package/dist/execution/types.js +52 -0
  18. package/dist/host/config-for.d.ts +2 -1
  19. package/dist/host/config-for.js +3 -21
  20. package/dist/host/config.d.ts +0 -8
  21. package/dist/host/create-host.d.ts +36 -1
  22. package/dist/host/create-host.js +51 -24
  23. package/dist/host/execution.d.ts +61 -0
  24. package/dist/host/execution.js +69 -0
  25. package/dist/host/http.d.ts +4 -0
  26. package/dist/host/http.js +6 -0
  27. package/dist/host/index.d.ts +3 -1
  28. package/dist/host/index.js +2 -0
  29. package/dist/host/main.js +106 -47
  30. package/dist/host/stack-config.d.ts +72 -0
  31. package/dist/host/stack-config.js +193 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/infra/database.d.ts +16 -0
  34. package/dist/infra/database.js +31 -0
  35. package/dist/infra/execution.d.ts +39 -0
  36. package/dist/infra/execution.js +68 -0
  37. package/dist/infra/index.d.ts +42 -0
  38. package/dist/infra/index.js +56 -0
  39. package/dist/infra/readiness.d.ts +33 -0
  40. package/dist/infra/readiness.js +53 -0
  41. package/dist/infra/streams.d.ts +17 -0
  42. package/dist/infra/streams.js +33 -0
  43. package/dist/infra/workers.d.ts +39 -0
  44. package/dist/infra/workers.js +31 -0
  45. package/dist/model/pi-model.d.ts +2 -2
  46. package/dist/model/pi-model.js +5 -5
  47. package/dist/sandbox/manager.d.ts +26 -8
  48. package/dist/sandbox/manager.js +95 -47
  49. package/dist/sandbox/select.d.ts +1 -1
  50. package/dist/sandbox/select.js +4 -5
  51. package/dist/sandbox/types.d.ts +2 -2
  52. package/dist/store/canonical.d.ts +5 -0
  53. package/dist/store/canonical.js +17 -0
  54. package/dist/store/cursor.d.ts +9 -0
  55. package/dist/store/cursor.js +18 -0
  56. package/dist/store/memory.d.ts +57 -0
  57. package/dist/store/memory.js +645 -0
  58. package/dist/store/ownership.d.ts +19 -0
  59. package/dist/store/ownership.js +33 -0
  60. package/dist/store/postgres/connect.d.ts +25 -0
  61. package/dist/store/postgres/connect.js +25 -0
  62. package/dist/store/postgres/locking.d.ts +16 -0
  63. package/dist/store/postgres/locking.js +22 -0
  64. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  65. package/dist/store/postgres/migrations/001_initial.js +196 -0
  66. package/dist/store/postgres/migrations/002_session_owner.d.ts +6 -0
  67. package/dist/store/postgres/migrations/002_session_owner.js +13 -0
  68. package/dist/store/postgres/migrations/index.d.ts +49 -0
  69. package/dist/store/postgres/migrations/index.js +76 -0
  70. package/dist/store/postgres/names.d.ts +11 -0
  71. package/dist/store/postgres/names.js +42 -0
  72. package/dist/store/postgres/store.d.ts +11 -0
  73. package/dist/store/postgres/store.js +797 -0
  74. package/dist/store/postgres/tenants.d.ts +90 -0
  75. package/dist/store/postgres/tenants.js +214 -0
  76. package/dist/store/types.d.ts +442 -0
  77. package/dist/store/types.js +10 -0
  78. package/dist/streams/basin.d.ts +23 -0
  79. package/dist/streams/basin.js +51 -0
  80. package/dist/streams/memory.d.ts +25 -0
  81. package/dist/streams/memory.js +124 -0
  82. package/dist/streams/relay.d.ts +37 -0
  83. package/dist/streams/relay.js +254 -0
  84. package/dist/streams/types.d.ts +115 -0
  85. package/dist/streams/types.js +86 -0
  86. package/dist/tenant/actions.d.ts +153 -0
  87. package/dist/tenant/actions.js +191 -0
  88. package/dist/tenant/advance.d.ts +15 -0
  89. package/dist/tenant/advance.js +439 -0
  90. package/dist/tenant/auth.d.ts +32 -0
  91. package/dist/tenant/auth.js +169 -0
  92. package/dist/tenant/commands.d.ts +3 -0
  93. package/dist/tenant/commands.js +393 -0
  94. package/dist/tenant/context.d.ts +147 -0
  95. package/dist/tenant/context.js +48 -0
  96. package/dist/tenant/effects.d.ts +20 -0
  97. package/dist/tenant/effects.js +564 -0
  98. package/dist/tenant/envelope.d.ts +0 -3
  99. package/dist/tenant/envelope.js +2 -34
  100. package/dist/tenant/ephemeral.d.ts +15 -4
  101. package/dist/tenant/ephemeral.js +106 -38
  102. package/dist/tenant/http.d.ts +38 -0
  103. package/dist/tenant/http.js +53 -0
  104. package/dist/tenant/live.d.ts +92 -0
  105. package/dist/tenant/live.js +290 -0
  106. package/dist/tenant/model-setting.d.ts +16 -0
  107. package/dist/tenant/model-setting.js +12 -0
  108. package/dist/tenant/module.d.ts +29 -8
  109. package/dist/tenant/module.js +165 -158
  110. package/dist/tenant/paths.d.ts +12 -8
  111. package/dist/tenant/paths.js +38 -12
  112. package/dist/tenant/principals.d.ts +12 -13
  113. package/dist/tenant/principals.js +50 -38
  114. package/dist/tenant/quarantine-error.d.ts +1 -1
  115. package/dist/tenant/quarantine-error.js +2 -8
  116. package/dist/tenant/quarantine.d.ts +0 -8
  117. package/dist/tenant/quarantine.js +8 -30
  118. package/dist/tenant/reset.d.ts +7 -6
  119. package/dist/tenant/reset.js +7 -51
  120. package/dist/tenant/routes-tenant.d.ts +12 -0
  121. package/dist/tenant/routes-tenant.js +137 -0
  122. package/dist/tenant/routes.d.ts +9 -0
  123. package/dist/tenant/routes.js +151 -0
  124. package/dist/tenant/runtime.d.ts +59 -58
  125. package/dist/tenant/runtime.js +243 -2201
  126. package/dist/tenant/scheduler.d.ts +25 -0
  127. package/dist/tenant/scheduler.js +84 -0
  128. package/dist/tenant/session.d.ts +60 -0
  129. package/dist/tenant/session.js +94 -0
  130. package/dist/tenant/sessions.d.ts +40 -0
  131. package/dist/tenant/sessions.js +163 -0
  132. package/dist/tenant/status.d.ts +13 -4
  133. package/dist/tenant/status.js +76 -41
  134. package/dist/tenant/store-memory.d.ts +9 -12
  135. package/dist/tenant/store-memory.js +79 -68
  136. package/dist/tenant/store-pg.d.ts +15 -0
  137. package/dist/tenant/store-pg.js +140 -0
  138. package/dist/tenant/streams.d.ts +126 -0
  139. package/dist/tenant/streams.js +419 -0
  140. package/dist/tenant/sweep.d.ts +15 -0
  141. package/dist/tenant/sweep.js +122 -0
  142. package/dist/tenant/transcript.d.ts +104 -0
  143. package/dist/tenant/transcript.js +58 -0
  144. package/dist/tenant/types.d.ts +101 -15
  145. package/dist/tenant/types.js +23 -1
  146. package/dist/tenant/worker.d.ts +84 -0
  147. package/dist/tenant/worker.js +189 -0
  148. package/dist/vault/service.d.ts +44 -31
  149. package/dist/vault/service.js +454 -360
  150. package/dist/version.d.ts +1 -1
  151. package/dist/version.js +1 -1
  152. package/package.json +15 -14
  153. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  154. package/dist/adapters/sandbox/microsandbox.js +0 -238
  155. package/dist/core/store.d.ts +0 -32
  156. package/dist/core/store.js +0 -143
  157. package/dist/launcher/commands.d.ts +0 -24
  158. package/dist/launcher/commands.js +0 -260
  159. package/dist/launcher/ensure-config.d.ts +0 -16
  160. package/dist/launcher/ensure-config.js +0 -79
  161. package/dist/launcher/errors.d.ts +0 -12
  162. package/dist/launcher/errors.js +0 -22
  163. package/dist/launcher/host-config.d.ts +0 -17
  164. package/dist/launcher/host-config.js +0 -64
  165. package/dist/launcher/host-state.d.ts +0 -17
  166. package/dist/launcher/host-state.js +0 -61
  167. package/dist/launcher/lifecycle.d.ts +0 -66
  168. package/dist/launcher/lifecycle.js +0 -407
  169. package/dist/launcher/locks.d.ts +0 -26
  170. package/dist/launcher/locks.js +0 -117
  171. package/dist/launcher/logs.d.ts +0 -18
  172. package/dist/launcher/logs.js +0 -117
  173. package/dist/launcher/main.d.ts +0 -2
  174. package/dist/launcher/main.js +0 -66
  175. package/dist/launcher/output.d.ts +0 -11
  176. package/dist/launcher/output.js +0 -68
  177. package/dist/launcher/paths.d.ts +0 -20
  178. package/dist/launcher/paths.js +0 -36
  179. package/dist/launcher/probe.d.ts +0 -23
  180. package/dist/launcher/probe.js +0 -72
  181. package/dist/launcher/protocol.d.ts +0 -62
  182. package/dist/launcher/protocol.js +0 -1
  183. package/dist/launcher/schema-guard.d.ts +0 -6
  184. package/dist/launcher/schema-guard.js +0 -36
  185. package/dist/launcher/spawn.d.ts +0 -22
  186. package/dist/launcher/spawn.js +0 -85
  187. package/dist/launcher/status.d.ts +0 -6
  188. package/dist/launcher/status.js +0 -65
  189. package/dist/tenant/migration.d.ts +0 -16
  190. package/dist/tenant/migration.js +0 -116
  191. package/dist/tenant/schema.d.ts +0 -23
  192. package/dist/tenant/schema.js +0 -151
  193. package/dist/tenant/store-fs.d.ts +0 -16
  194. package/dist/tenant/store-fs.js +0 -205
@@ -0,0 +1,12 @@
1
+ export const FIXTURE_MODEL_SETTING = "model.fixture";
2
+ /** Whether the Tenant uses the fixture model. */
3
+ export async function usesFixtureModel(t) {
4
+ return (await t.getSetting(FIXTURE_MODEL_SETTING)) === "true";
5
+ }
6
+ /** Seeds the setting if absent. Returns whether it was inserted. */
7
+ export async function seedFixtureModel(t) {
8
+ if ((await t.getSetting(FIXTURE_MODEL_SETTING)) !== undefined)
9
+ return false;
10
+ await t.putSetting(FIXTURE_MODEL_SETTING, "true");
11
+ return true;
12
+ }
@@ -1,17 +1,38 @@
1
1
  import { isQuarantineError, quarantine, TenantBusyError, TenantConflictError } from "./quarantine.js";
2
- import type { Logger, OpenTenantRuntime, TenantConfig, TenantModule, TenantStore } from "./types.js";
2
+ import { TenantNotFoundError, TenantUnavailableError, type Logger, type OpenTenantRuntime, type TenantConfig, type TenantModule, type TenantStore } from "./types.js";
3
3
  export interface CreateTenantModuleOptions {
4
- hostRoot: string;
5
4
  store: TenantStore;
6
- openRuntime: OpenTenantRuntime;
7
- configFor: (tenantId: string) => TenantConfig;
5
+ /** Unused: the store derives paths. Accepted so callers can pass the store's options. */
6
+ hostRoot?: string;
7
+ /** Unused: the store opens Tenants. Accepted so callers can pass the store's options. */
8
+ openRuntime?: OpenTenantRuntime;
9
+ /** Unused; see `openRuntime`. */
10
+ configFor?: (tenantId: string) => TenantConfig;
8
11
  logger: Logger;
9
- openConcurrency?: number;
12
+ /** Bound on one Tenant open; a slower open quarantines it with `open-timeout`. */
10
13
  openTimeoutMs?: number;
14
+ /**
15
+ * Runs after a Tenant is created in the store, before it opens: creates what lives outside
16
+ * it (its Durable Streams basin). A failure is logged; opening the Tenant repairs it.
17
+ */
18
+ onCreated?: (tenantId: string) => Promise<void>;
19
+ /**
20
+ * Runs after a Tenant is removed from the store: removes what lives outside it (its
21
+ * Durable Streams basin, its armed sweep). A failure is logged; the Tenant stays deleted.
22
+ */
23
+ onDeleted?: (tenantId: string) => Promise<void>;
11
24
  }
12
25
  /**
13
- * Deep Tenant module (§8). Discovery, create, resolve, list, delete and
14
- * summarize. Storage and Tenant Runtime are injected so WS-B tests without WS-A.
26
+ * Deep Tenant module (§8): discovery, create, resolve, list, delete and summarize over an
27
+ * injected `TenantStore`.
28
+ *
29
+ * Handles open on demand and are cached (architecture §8.2). The first call that needs a
30
+ * Tenant (`resolve`, `status`, `worker`, `delete`) opens it once, even when several ask at
31
+ * the same time; a failed open is cached as a quarantine, and a missing Tenant is not
32
+ * cached. A failure outside the Tenant (`TenantUnavailableError`: the database is down, its
33
+ * sweep could not be armed) is thrown to the caller and not cached either, so the next use
34
+ * tries again. A Tenant being deleted resolves as not found, so a late request or Worker
35
+ * call cannot reopen it while its storage is removed.
15
36
  */
16
37
  export declare function createTenantModule(options: CreateTenantModuleOptions): TenantModule;
17
- export { TenantBusyError, TenantConflictError, isQuarantineError, quarantine, };
38
+ export { TenantBusyError, TenantConflictError, TenantNotFoundError, TenantUnavailableError, isQuarantineError, quarantine, };
@@ -1,11 +1,9 @@
1
- import { existsSync } from "node:fs";
2
1
  import { isTenantId } from "@nylorun/core/compatibility";
3
- import { TENANT_SCHEMA_VERSION } from "./schema.js";
2
+ import { POSTGRES_SCHEMA_VERSION } from "../store/postgres/migrations/index.js";
4
3
  import { envelopeNow } from "./envelope.js";
5
- import { tenantPaths } from "./paths.js";
6
- import { mapPool, TimeoutError, withTimeout } from "./pool.js";
4
+ import { TimeoutError, withTimeout } from "./pool.js";
7
5
  import { asQuarantine, isQuarantineError, quarantine, TenantBusyError, TenantConflictError, } from "./quarantine.js";
8
- const OPEN_CONCURRENCY = 4;
6
+ import { TenantNotFoundError, TenantUnavailableError, } from "./types.js";
9
7
  const OPEN_TIMEOUT_MS = 30_000;
10
8
  function hasLiveWork(summary) {
11
9
  return (summary.runningSessions > 0 ||
@@ -13,15 +11,7 @@ function hasLiveWork(summary) {
13
11
  summary.pendingActions > 0);
14
12
  }
15
13
  function toAdmin(id, entry, envelope) {
16
- if (!entry) {
17
- return {
18
- id,
19
- name: envelope?.name ?? null,
20
- state: "quarantined",
21
- envelope,
22
- };
23
- }
24
- if (entry.kind === "open") {
14
+ if (entry?.kind === "open") {
25
15
  return {
26
16
  id,
27
17
  name: entry.handle.envelope.name,
@@ -32,69 +22,98 @@ function toAdmin(id, entry, envelope) {
32
22
  return {
33
23
  id,
34
24
  name: envelope?.name ?? null,
35
- state: "quarantined",
25
+ state: entry ? "quarantined" : "open",
36
26
  envelope,
37
27
  };
38
28
  }
29
+ /** The quarantine an open failure carries, from either quarantine error type. */
30
+ function quarantineOf(error) {
31
+ const carried = error?.quarantine;
32
+ if (carried && typeof carried === "object" && "code" in carried)
33
+ return carried;
34
+ return asQuarantine(error);
35
+ }
39
36
  /**
40
- * Deep Tenant module (§8). Discovery, create, resolve, list, delete and
41
- * summarize. Storage and Tenant Runtime are injected so WS-B tests without WS-A.
37
+ * Deep Tenant module (§8): discovery, create, resolve, list, delete and summarize over an
38
+ * injected `TenantStore`.
39
+ *
40
+ * Handles open on demand and are cached (architecture §8.2). The first call that needs a
41
+ * Tenant (`resolve`, `status`, `worker`, `delete`) opens it once, even when several ask at
42
+ * the same time; a failed open is cached as a quarantine, and a missing Tenant is not
43
+ * cached. A failure outside the Tenant (`TenantUnavailableError`: the database is down, its
44
+ * sweep could not be armed) is thrown to the caller and not cached either, so the next use
45
+ * tries again. A Tenant being deleted resolves as not found, so a late request or Worker
46
+ * call cannot reopen it while its storage is removed.
42
47
  */
43
48
  export function createTenantModule(options) {
44
- const { hostRoot, store, logger, openConcurrency = OPEN_CONCURRENCY, openTimeoutMs = OPEN_TIMEOUT_MS, } = options;
45
- // openRuntime / configFor are consumed by the store; kept on options for B1.
46
- void options.openRuntime;
47
- void options.configFor;
49
+ const { store, logger, openTimeoutMs = OPEN_TIMEOUT_MS } = options;
48
50
  const entries = new Map();
51
+ const opening = new Map();
52
+ const deleting = new Set();
49
53
  let started = false;
50
54
  let closed = false;
51
- function rememberOpen(handle) {
52
- entries.set(handle.envelope.id, { kind: "open", handle });
53
- }
54
55
  function rememberQuarantine(id, q) {
55
- entries.set(id, { kind: "quarantined", quarantine: q });
56
+ const entry = { kind: "quarantined", quarantine: q };
57
+ entries.set(id, entry);
56
58
  logger.warn("tenant quarantined", {
57
59
  tenantId: id,
58
60
  code: q.code,
59
61
  repair: q.repair,
60
62
  });
63
+ return entry;
61
64
  }
62
- async function openOne(id) {
63
- const existing = entries.get(id);
64
- if (existing?.kind === "open")
65
- return;
65
+ async function openEntry(id) {
66
+ let handle;
66
67
  try {
67
- const handle = await withTimeout(store.open(id), openTimeoutMs, (late) => {
68
- void late
69
- .then((h) => h.close())
70
- .catch(() => undefined);
68
+ handle = await withTimeout(store.open(id), openTimeoutMs, (late) => {
69
+ void late.then((h) => h.close()).catch(() => undefined);
71
70
  });
72
- rememberOpen(handle);
73
71
  }
74
72
  catch (error) {
75
- if (error instanceof TimeoutError) {
76
- rememberQuarantine(id, quarantine("open-timeout", `open timed out after ${openTimeoutMs}ms`, {
73
+ if (error instanceof TenantNotFoundError)
74
+ return undefined;
75
+ if (closed || deleting.has(id))
76
+ return undefined;
77
+ if (error instanceof TenantUnavailableError) {
78
+ // Not the Tenant's fault: not cached, so the next use opens it again.
79
+ logger.warn("tenant unavailable", {
77
80
  tenantId: id,
78
- }).toQuarantine());
79
- return;
80
- }
81
- const q = asQuarantine(error);
82
- if (q) {
83
- rememberQuarantine(id, q);
84
- return;
81
+ message: error.cause instanceof Error ? error.cause.message : String(error.cause),
82
+ });
83
+ throw error;
85
84
  }
86
- rememberQuarantine(id, quarantine("open-failed", error instanceof Error ? error.message : "open failed", { tenantId: id }).toQuarantine());
85
+ if (error instanceof TimeoutError)
86
+ return rememberQuarantine(id, quarantine("open-timeout", `open timed out after ${openTimeoutMs}ms`, {
87
+ tenantId: id,
88
+ }).toQuarantine());
89
+ return rememberQuarantine(id, quarantineOf(error) ??
90
+ quarantine("open-failed", error instanceof Error ? error.message : "open failed", { tenantId: id }).toQuarantine());
87
91
  }
92
+ if (closed || deleting.has(id)) {
93
+ await handle.close().catch(() => undefined);
94
+ return undefined;
95
+ }
96
+ const entry = { kind: "open", handle };
97
+ entries.set(id, entry);
98
+ return entry;
88
99
  }
89
- async function discoverId(id) {
90
- if (entries.has(id))
91
- return entries.get(id);
92
- const paths = tenantPaths(hostRoot, id);
93
- if (!existsSync(paths.root))
100
+ /** The cached entry, or the result of opening the Tenant once. */
101
+ async function load(id) {
102
+ if (deleting.has(id))
94
103
  return undefined;
95
- await openOne(id);
96
- return entries.get(id);
104
+ const cached = entries.get(id);
105
+ if (cached)
106
+ return cached;
107
+ if (closed)
108
+ return undefined;
109
+ let pending = opening.get(id);
110
+ if (!pending) {
111
+ pending = openEntry(id).finally(() => opening.delete(id));
112
+ opening.set(id, pending);
113
+ }
114
+ return pending;
97
115
  }
116
+ const openEntries = () => [...entries.values()].filter((e) => e.kind === "open");
98
117
  const module = {
99
118
  get started() {
100
119
  return started;
@@ -103,38 +122,25 @@ export function createTenantModule(options) {
103
122
  if (closed)
104
123
  throw new Error("Tenant module is closed");
105
124
  const ids = await store.enumerate();
106
- await mapPool(ids, openConcurrency, async (id) => {
107
- await openOne(id);
108
- });
109
125
  started = true;
110
- logger.info("tenant module started", {
111
- tenants: ids.length,
112
- open: [...entries.values()].filter((e) => e.kind === "open").length,
113
- quarantined: [...entries.values()].filter((e) => e.kind === "quarantined").length,
114
- });
126
+ logger.info("tenant module started", { tenants: ids.length });
115
127
  },
116
- resolve(id) {
128
+ async resolve(id) {
117
129
  if (!isTenantId(id))
118
130
  return { kind: "not-found" };
119
- const entry = entries.get(id);
120
- if (!entry) {
121
- // Sync miss: existence check only (disk I/O allowed on miss). Full open
122
- // happens on list()/status()/create(); Host sees opaque not-found until then.
123
- try {
124
- const paths = tenantPaths(hostRoot, id);
125
- if (!existsSync(paths.root))
126
- return { kind: "not-found" };
127
- }
128
- catch {
129
- return { kind: "not-found" };
130
- }
131
+ const entry = await load(id);
132
+ if (!entry)
131
133
  return { kind: "not-found" };
132
- }
133
- if (entry.kind === "open") {
134
+ if (entry.kind === "open")
134
135
  return { kind: "open", handle: entry.handle };
135
- }
136
136
  return { kind: "quarantined", quarantine: entry.quarantine };
137
137
  },
138
+ async worker(id) {
139
+ if (!isTenantId(id))
140
+ return undefined;
141
+ const entry = await load(id);
142
+ return entry?.kind === "open" ? entry.handle.worker : undefined;
143
+ },
138
144
  async create(input) {
139
145
  if (!isTenantId(input.tenantId)) {
140
146
  throw new Error(`Invalid tenant id: ${input.tenantId}`);
@@ -143,11 +149,18 @@ export function createTenantModule(options) {
143
149
  principalId: input.principalId,
144
150
  credentialHash: input.credentialHash,
145
151
  idempotencyKey: input.idempotencyKey,
152
+ ...(input.studioCredentialHash
153
+ ? { studioCredentialHash: input.studioCredentialHash }
154
+ : {}),
155
+ ...(input.derivedPrincipals?.length
156
+ ? { derivedPrincipals: input.derivedPrincipals }
157
+ : {}),
146
158
  };
159
+ // The store records its own schema version.
147
160
  const envelope = envelopeNow({
148
161
  id: input.tenantId,
149
162
  name: input.name,
150
- schemaVersion: TENANT_SCHEMA_VERSION,
163
+ schemaVersion: POSTGRES_SCHEMA_VERSION,
151
164
  });
152
165
  let outcome;
153
166
  try {
@@ -162,53 +175,63 @@ export function createTenantModule(options) {
162
175
  if (!matches)
163
176
  throw new TenantConflictError();
164
177
  const existing = await store.readEnvelope(input.tenantId);
165
- const entry = entries.get(input.tenantId);
166
- if (!entry || entry.kind !== "open") {
167
- await openOne(input.tenantId);
168
- }
178
+ await load(input.tenantId).catch(() => undefined);
169
179
  return { envelope: existing, created: false };
170
180
  }
171
- await openOne(input.tenantId);
172
- const opened = entries.get(input.tenantId);
181
+ await options.onCreated?.(input.tenantId).catch((error) => logger.warn("tenant create step failed; open repairs it", {
182
+ tenantId: input.tenantId,
183
+ message: error instanceof Error ? error.message : String(error),
184
+ }));
185
+ const opened = await load(input.tenantId).catch(() => undefined);
173
186
  if (opened?.kind === "open") {
174
187
  return { envelope: opened.handle.envelope, created: true };
175
188
  }
176
- // Created on disk but failed to open — still report the written envelope.
177
- return { envelope, created: true };
189
+ // Created but failed to open: report the stored envelope.
190
+ return {
191
+ envelope: await store
192
+ .readEnvelope(input.tenantId)
193
+ .catch(() => envelope),
194
+ created: true,
195
+ };
178
196
  },
179
197
  async list() {
180
198
  const ids = new Set(await store.enumerate());
181
199
  for (const id of entries.keys())
182
200
  ids.add(id);
183
201
  const result = [];
184
- for (const id of ids) {
185
- if (!isTenantId(id))
202
+ for (const id of [...ids].sort()) {
203
+ if (!isTenantId(id) || deleting.has(id))
186
204
  continue;
187
205
  let entry = entries.get(id);
188
- if (!entry) {
189
- await openOne(id);
190
- entry = entries.get(id);
206
+ if (entry?.kind === "open") {
207
+ result.push(toAdmin(id, entry, null));
208
+ continue;
191
209
  }
192
210
  let envelope = null;
193
211
  try {
194
212
  envelope = await store.readEnvelope(id);
195
213
  if (envelope.id !== id) {
196
- rememberQuarantine(id, quarantine("envelope-invalid", `tenant.json id ${envelope.id} does not match directory ${id}`, { tenantId: id }).toQuarantine());
197
- entry = entries.get(id);
214
+ entry = {
215
+ kind: "quarantined",
216
+ quarantine: quarantine("envelope-invalid", `Tenant envelope id ${envelope.id} does not match ${id}`, { tenantId: id }).toQuarantine(),
217
+ };
198
218
  envelope = null;
199
219
  }
200
220
  }
201
221
  catch (error) {
202
- const q = asQuarantine(error);
203
- if (q) {
204
- rememberQuarantine(id, q);
205
- entry = entries.get(id);
206
- }
207
- else {
208
- rememberQuarantine(id, quarantine("envelope-invalid", "tenant.json is unreadable", { tenantId: id }).toQuarantine());
209
- entry = entries.get(id);
210
- }
211
- envelope = null;
222
+ // Deleted since it was enumerated.
223
+ if (error instanceof TenantNotFoundError)
224
+ continue;
225
+ if (error instanceof TenantUnavailableError)
226
+ throw error;
227
+ // Listed as quarantined; only opening it caches a quarantine.
228
+ entry ??= {
229
+ kind: "quarantined",
230
+ quarantine: quarantineOf(error) ??
231
+ quarantine("envelope-invalid", "Tenant envelope is unreadable", {
232
+ tenantId: id,
233
+ }).toQuarantine(),
234
+ };
212
235
  }
213
236
  result.push(toAdmin(id, entry, envelope));
214
237
  }
@@ -217,99 +240,83 @@ export function createTenantModule(options) {
217
240
  async status(id) {
218
241
  if (!isTenantId(id))
219
242
  return undefined;
220
- let entry = entries.get(id);
221
- if (!entry) {
222
- entry = await discoverId(id);
223
- if (!entry) {
224
- // Not in memory and not on disk.
225
- const known = (await store.enumerate()).includes(id);
226
- if (!known)
227
- return undefined;
228
- await openOne(id);
229
- entry = entries.get(id);
230
- }
231
- }
243
+ const entry = await load(id);
232
244
  if (!entry)
233
245
  return undefined;
234
- let envelope = null;
235
- try {
236
- envelope =
237
- entry.kind === "open"
238
- ? entry.handle.envelope
239
- : await store.readEnvelope(id);
240
- }
241
- catch {
242
- envelope = null;
243
- }
244
- const base = toAdmin(id, entry, envelope);
245
- if (entry.kind === "quarantined") {
246
- return { ...base, quarantine: entry.quarantine };
247
- }
248
- return base;
246
+ if (entry.kind === "open")
247
+ return toAdmin(id, entry, null);
248
+ const envelope = await store.readEnvelope(id).catch(() => null);
249
+ return { ...toAdmin(id, entry, envelope), quarantine: entry.quarantine };
249
250
  },
250
251
  async delete(id, activeWork) {
251
252
  if (!isTenantId(id))
252
253
  throw new Error(`Invalid tenant id: ${id}`);
253
- let entry = entries.get(id);
254
- if (!entry) {
255
- await discoverId(id);
256
- entry = entries.get(id);
257
- }
258
- if (!entry) {
259
- // Still on disk without an entry?
260
- const ids = await store.enumerate();
261
- if (!ids.includes(id)) {
262
- throw quarantine("open-failed", `Tenant ${id} not found`, {
263
- tenantId: id,
264
- });
265
- }
266
- await openOne(id);
267
- entry = entries.get(id);
268
- }
269
- if (entry?.kind === "open") {
270
- const summary = entry.handle.summary();
254
+ if (deleting.has(id))
255
+ throw new TenantBusyError("Tenant is being deleted");
256
+ const entry = await load(id);
257
+ if (!entry)
258
+ throw new TenantNotFoundError(id);
259
+ if (entry.kind === "open") {
260
+ const summary = await entry.handle.summary();
271
261
  if (activeWork === "refuse" && hasLiveWork(summary)) {
272
262
  throw new TenantBusyError();
273
263
  }
274
264
  if (activeWork === "drain" || activeWork === "cancel") {
275
265
  await entry.handle.drain(activeWork);
276
266
  }
277
- // Sandbox prefix cleanup is owned by TenantHandle.close() (WS-A).
278
- await entry.handle.close();
279
267
  }
280
- await store.trash(id, new Date());
281
- entries.delete(id);
268
+ deleting.add(id);
269
+ try {
270
+ entries.delete(id);
271
+ // Sandbox prefix cleanup is owned by TenantHandle.close().
272
+ if (entry.kind === "open")
273
+ await entry.handle.close();
274
+ await store.trash(id, new Date());
275
+ }
276
+ finally {
277
+ deleting.delete(id);
278
+ }
282
279
  logger.info("tenant deleted", { tenantId: id, activeWork });
280
+ await options.onDeleted?.(id).catch((error) => logger.warn("tenant delete cleanup failed", {
281
+ tenantId: id,
282
+ message: error instanceof Error ? error.message : String(error),
283
+ }));
283
284
  },
284
- summarize() {
285
+ async summarize() {
285
286
  let runningSessions = 0;
286
287
  let connectedExecutors = 0;
287
288
  let pendingActions = 0;
288
289
  let uncertainEffects = 0;
289
- for (const entry of entries.values()) {
290
- if (entry.kind !== "open")
291
- continue;
292
- const s = entry.handle.summary();
290
+ let outboxDepth;
291
+ let relayLagMs;
292
+ for (const s of await Promise.all(openEntries().map((e) => e.handle.summary()))) {
293
293
  runningSessions += s.runningSessions;
294
294
  connectedExecutors += s.connectedExecutors;
295
295
  pendingActions += s.pendingActions;
296
296
  uncertainEffects += s.uncertainEffects;
297
+ if (s.outboxDepth !== undefined)
298
+ outboxDepth = (outboxDepth ?? 0) + s.outboxDepth;
299
+ if (s.relayLagMs !== undefined)
300
+ relayLagMs = Math.max(relayLagMs ?? 0, s.relayLagMs);
297
301
  }
298
302
  return {
299
303
  runningSessions,
300
304
  connectedExecutors,
301
305
  pendingActions,
302
306
  uncertainEffects,
307
+ ...(outboxDepth !== undefined ? { outboxDepth } : {}),
308
+ ...(relayLagMs !== undefined ? { relayLagMs } : {}),
303
309
  };
304
310
  },
305
311
  async close() {
306
312
  closed = true;
307
313
  started = false;
308
- const open = [...entries.values()].filter((e) => e.kind === "open");
309
- await Promise.all(open.map((e) => e.handle.close().catch(() => undefined)));
314
+ await Promise.allSettled([...opening.values()]);
315
+ const open = openEntries();
310
316
  entries.clear();
317
+ await Promise.all(open.map((e) => e.handle.close().catch(() => undefined)));
311
318
  },
312
319
  };
313
320
  return module;
314
321
  }
315
- export { TenantBusyError, TenantConflictError, isQuarantineError, quarantine, };
322
+ export { TenantBusyError, TenantConflictError, TenantNotFoundError, TenantUnavailableError, isQuarantineError, quarantine, };
@@ -1,24 +1,28 @@
1
- import type { TenantPaths } from "./types.js";
1
+ import type { Logger, TenantPaths } from "./types.js";
2
2
  export interface HostPaths {
3
3
  root: string;
4
4
  config: string;
5
- state: string;
6
5
  credentials: string;
7
- log: string;
8
6
  home: string;
9
7
  tmp: string;
10
- runtime: string;
11
8
  tenants: string;
12
9
  trash: string;
13
10
  }
14
11
  /** Host layout under NYLORUN_HOME / ~/.nylorun. */
15
12
  export declare function hostPaths(hostRoot: string): HostPaths;
16
13
  /**
17
- * Absolute Tenant paths under `<hostRoot>/tenants/<tenantId>/`.
14
+ * Absolute Tenant directory paths under `<hostRoot>/tenants/<tenantId>/`.
18
15
  * Validates the id and asserts containment after realpath.
19
16
  *
20
- * Basenames frozen here for Wave 0 (design Rev 3 absent):
21
- * `tenant.json`, `tenant.sqlite`, `.runtime-lock`, `vault-kek`,
22
- * `home/`, `tmp/`, `.migration/`, `sandboxes/`, `plugin-data/`, `logs/tenant.log`.
17
+ * The directory holds what stays on the Host (the Tenant's data is its Postgres schema):
18
+ * `vault-kek`, `home/`, `tmp/`, `sandboxes/`, `plugin-data/`, `logs/tenant.log`.
23
19
  */
24
20
  export declare function tenantPaths(hostRoot: string, tenantId: string): TenantPaths;
21
+ /**
22
+ * Moves every Tenant directory that holds a `tenant.sqlite` to `trash/`, logging each.
23
+ *
24
+ * Those are beta Tenants from the SQLite Runtime. They are not migrated (recreate them); a
25
+ * Postgres Tenant's directory never holds that file. Moving them at Host start keeps a new
26
+ * Postgres Tenant with the same id from sharing the old directory. Returns the moved ids.
27
+ */
28
+ export declare function trashSqliteTenants(hostRoot: string, logger: Logger): string[];
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, realpathSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, } from "node:fs";
2
2
  import { join, resolve, sep } from "node:path";
3
3
  import { isTenantId } from "@nylorun/core/compatibility";
4
4
  function ensureDir(path) {
@@ -17,23 +17,19 @@ export function hostPaths(hostRoot) {
17
17
  return {
18
18
  root,
19
19
  config: join(root, "host.json"),
20
- state: join(root, "host-state.json"),
21
20
  credentials: join(root, "host-credentials.json"),
22
- log: join(root, "runtime.log"),
23
21
  home: join(root, "home"),
24
22
  tmp: join(root, "tmp"),
25
- runtime: join(root, "runtime"),
26
23
  tenants: join(root, "tenants"),
27
24
  trash: join(root, "trash"),
28
25
  };
29
26
  }
30
27
  /**
31
- * Absolute Tenant paths under `<hostRoot>/tenants/<tenantId>/`.
28
+ * Absolute Tenant directory paths under `<hostRoot>/tenants/<tenantId>/`.
32
29
  * Validates the id and asserts containment after realpath.
33
30
  *
34
- * Basenames frozen here for Wave 0 (design Rev 3 absent):
35
- * `tenant.json`, `tenant.sqlite`, `.runtime-lock`, `vault-kek`,
36
- * `home/`, `tmp/`, `.migration/`, `sandboxes/`, `plugin-data/`, `logs/tenant.log`.
31
+ * The directory holds what stays on the Host (the Tenant's data is its Postgres schema):
32
+ * `vault-kek`, `home/`, `tmp/`, `sandboxes/`, `plugin-data/`, `logs/tenant.log`.
37
33
  */
38
34
  export function tenantPaths(hostRoot, tenantId) {
39
35
  if (!isTenantId(tenantId)) {
@@ -50,16 +46,46 @@ export function tenantPaths(hostRoot, tenantId) {
50
46
  }
51
47
  return {
52
48
  root: resolvedRoot,
53
- envelope: join(resolvedRoot, "tenant.json"),
54
- database: join(resolvedRoot, "tenant.sqlite"),
55
- lock: join(resolvedRoot, ".runtime-lock"),
56
49
  kek: join(resolvedRoot, "vault-kek"),
57
50
  home: join(resolvedRoot, "home"),
58
51
  tmp: join(resolvedRoot, "tmp"),
59
- migration: join(resolvedRoot, ".migration"),
60
52
  sandboxes: join(resolvedRoot, "sandboxes"),
61
53
  pluginData: join(resolvedRoot, "plugin-data"),
62
54
  logs: join(resolvedRoot, "logs"),
63
55
  log: join(resolvedRoot, "logs", "tenant.log"),
64
56
  };
65
57
  }
58
+ /** The database file of a Tenant from before the Postgres Session Store. */
59
+ const SQLITE_DATABASE = "tenant.sqlite";
60
+ /**
61
+ * Moves every Tenant directory that holds a `tenant.sqlite` to `trash/`, logging each.
62
+ *
63
+ * Those are beta Tenants from the SQLite Runtime. They are not migrated (recreate them); a
64
+ * Postgres Tenant's directory never holds that file. Moving them at Host start keeps a new
65
+ * Postgres Tenant with the same id from sharing the old directory. Returns the moved ids.
66
+ */
67
+ export function trashSqliteTenants(hostRoot, logger) {
68
+ const host = hostPaths(hostRoot);
69
+ if (!existsSync(host.tenants))
70
+ return [];
71
+ const moved = [];
72
+ const stamp = new Date().toISOString().replaceAll(":", "-");
73
+ for (const entry of readdirSync(host.tenants, { withFileTypes: true })) {
74
+ if (!entry.isDirectory())
75
+ continue;
76
+ const directory = join(host.tenants, entry.name);
77
+ if (!existsSync(join(directory, SQLITE_DATABASE)))
78
+ continue;
79
+ mkdirSync(host.trash, { recursive: true });
80
+ const destination = join(host.trash, `${entry.name}-sqlite-${stamp}`);
81
+ renameSync(directory, destination);
82
+ moved.push(entry.name);
83
+ logger.warn("sqlite_tenant_moved_to_trash", {
84
+ tenant: entry.name,
85
+ from: directory,
86
+ to: destination,
87
+ reason: "SQLite Tenants are not migrated to the Postgres Session Store; recreate the Tenant",
88
+ });
89
+ }
90
+ return moved;
91
+ }