@nylorun/runtime 0.10.0-beta → 0.11.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 (190) hide show
  1. package/CHANGELOG.md +46 -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 +8 -3
  13. package/dist/core/sandbox-routes.js +10 -10
  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 +48 -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 +643 -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/index.d.ts +49 -0
  67. package/dist/store/postgres/migrations/index.js +75 -0
  68. package/dist/store/postgres/names.d.ts +11 -0
  69. package/dist/store/postgres/names.js +42 -0
  70. package/dist/store/postgres/store.d.ts +11 -0
  71. package/dist/store/postgres/store.js +790 -0
  72. package/dist/store/postgres/tenants.d.ts +90 -0
  73. package/dist/store/postgres/tenants.js +207 -0
  74. package/dist/store/types.d.ts +440 -0
  75. package/dist/store/types.js +10 -0
  76. package/dist/streams/basin.d.ts +23 -0
  77. package/dist/streams/basin.js +51 -0
  78. package/dist/streams/memory.d.ts +25 -0
  79. package/dist/streams/memory.js +124 -0
  80. package/dist/streams/relay.d.ts +37 -0
  81. package/dist/streams/relay.js +254 -0
  82. package/dist/streams/types.d.ts +115 -0
  83. package/dist/streams/types.js +86 -0
  84. package/dist/tenant/actions.d.ts +153 -0
  85. package/dist/tenant/actions.js +191 -0
  86. package/dist/tenant/advance.d.ts +15 -0
  87. package/dist/tenant/advance.js +439 -0
  88. package/dist/tenant/auth.d.ts +11 -0
  89. package/dist/tenant/auth.js +34 -0
  90. package/dist/tenant/commands.d.ts +3 -0
  91. package/dist/tenant/commands.js +384 -0
  92. package/dist/tenant/context.d.ts +140 -0
  93. package/dist/tenant/context.js +40 -0
  94. package/dist/tenant/effects.d.ts +20 -0
  95. package/dist/tenant/effects.js +539 -0
  96. package/dist/tenant/envelope.d.ts +0 -3
  97. package/dist/tenant/envelope.js +2 -34
  98. package/dist/tenant/ephemeral.d.ts +15 -4
  99. package/dist/tenant/ephemeral.js +106 -38
  100. package/dist/tenant/http.d.ts +32 -0
  101. package/dist/tenant/http.js +51 -0
  102. package/dist/tenant/live.d.ts +92 -0
  103. package/dist/tenant/live.js +290 -0
  104. package/dist/tenant/model-setting.d.ts +16 -0
  105. package/dist/tenant/model-setting.js +12 -0
  106. package/dist/tenant/module.d.ts +29 -8
  107. package/dist/tenant/module.js +162 -158
  108. package/dist/tenant/paths.d.ts +12 -8
  109. package/dist/tenant/paths.js +38 -12
  110. package/dist/tenant/principals.d.ts +9 -13
  111. package/dist/tenant/principals.js +31 -38
  112. package/dist/tenant/quarantine-error.d.ts +1 -1
  113. package/dist/tenant/quarantine-error.js +2 -8
  114. package/dist/tenant/quarantine.d.ts +0 -8
  115. package/dist/tenant/quarantine.js +8 -30
  116. package/dist/tenant/reset.d.ts +7 -6
  117. package/dist/tenant/reset.js +7 -51
  118. package/dist/tenant/routes-tenant.d.ts +12 -0
  119. package/dist/tenant/routes-tenant.js +126 -0
  120. package/dist/tenant/routes.d.ts +9 -0
  121. package/dist/tenant/routes.js +142 -0
  122. package/dist/tenant/runtime.d.ts +59 -58
  123. package/dist/tenant/runtime.js +243 -2201
  124. package/dist/tenant/scheduler.d.ts +25 -0
  125. package/dist/tenant/scheduler.js +84 -0
  126. package/dist/tenant/session.d.ts +60 -0
  127. package/dist/tenant/session.js +94 -0
  128. package/dist/tenant/sessions.d.ts +34 -0
  129. package/dist/tenant/sessions.js +149 -0
  130. package/dist/tenant/status.d.ts +13 -4
  131. package/dist/tenant/status.js +76 -41
  132. package/dist/tenant/store-memory.d.ts +9 -12
  133. package/dist/tenant/store-memory.js +79 -68
  134. package/dist/tenant/store-pg.d.ts +15 -0
  135. package/dist/tenant/store-pg.js +140 -0
  136. package/dist/tenant/streams.d.ts +126 -0
  137. package/dist/tenant/streams.js +419 -0
  138. package/dist/tenant/sweep.d.ts +15 -0
  139. package/dist/tenant/sweep.js +122 -0
  140. package/dist/tenant/types.d.ts +95 -15
  141. package/dist/tenant/types.js +23 -1
  142. package/dist/tenant/worker.d.ts +84 -0
  143. package/dist/tenant/worker.js +189 -0
  144. package/dist/vault/service.d.ts +37 -31
  145. package/dist/vault/service.js +439 -359
  146. package/dist/version.d.ts +1 -1
  147. package/dist/version.js +1 -1
  148. package/package.json +13 -13
  149. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  150. package/dist/adapters/sandbox/microsandbox.js +0 -238
  151. package/dist/core/store.d.ts +0 -32
  152. package/dist/core/store.js +0 -143
  153. package/dist/launcher/commands.d.ts +0 -24
  154. package/dist/launcher/commands.js +0 -260
  155. package/dist/launcher/ensure-config.d.ts +0 -16
  156. package/dist/launcher/ensure-config.js +0 -79
  157. package/dist/launcher/errors.d.ts +0 -12
  158. package/dist/launcher/errors.js +0 -22
  159. package/dist/launcher/host-config.d.ts +0 -17
  160. package/dist/launcher/host-config.js +0 -64
  161. package/dist/launcher/host-state.d.ts +0 -17
  162. package/dist/launcher/host-state.js +0 -61
  163. package/dist/launcher/lifecycle.d.ts +0 -66
  164. package/dist/launcher/lifecycle.js +0 -407
  165. package/dist/launcher/locks.d.ts +0 -26
  166. package/dist/launcher/locks.js +0 -117
  167. package/dist/launcher/logs.d.ts +0 -18
  168. package/dist/launcher/logs.js +0 -117
  169. package/dist/launcher/main.d.ts +0 -2
  170. package/dist/launcher/main.js +0 -66
  171. package/dist/launcher/output.d.ts +0 -11
  172. package/dist/launcher/output.js +0 -68
  173. package/dist/launcher/paths.d.ts +0 -20
  174. package/dist/launcher/paths.js +0 -36
  175. package/dist/launcher/probe.d.ts +0 -23
  176. package/dist/launcher/probe.js +0 -72
  177. package/dist/launcher/protocol.d.ts +0 -62
  178. package/dist/launcher/protocol.js +0 -1
  179. package/dist/launcher/schema-guard.d.ts +0 -6
  180. package/dist/launcher/schema-guard.js +0 -36
  181. package/dist/launcher/spawn.d.ts +0 -22
  182. package/dist/launcher/spawn.js +0 -85
  183. package/dist/launcher/status.d.ts +0 -6
  184. package/dist/launcher/status.js +0 -65
  185. package/dist/tenant/migration.d.ts +0 -16
  186. package/dist/tenant/migration.js +0 -116
  187. package/dist/tenant/schema.d.ts +0 -23
  188. package/dist/tenant/schema.js +0 -151
  189. package/dist/tenant/store-fs.d.ts +0 -16
  190. 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,15 @@ 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
+ : {}),
146
155
  };
156
+ // The store records its own schema version.
147
157
  const envelope = envelopeNow({
148
158
  id: input.tenantId,
149
159
  name: input.name,
150
- schemaVersion: TENANT_SCHEMA_VERSION,
160
+ schemaVersion: POSTGRES_SCHEMA_VERSION,
151
161
  });
152
162
  let outcome;
153
163
  try {
@@ -162,53 +172,63 @@ export function createTenantModule(options) {
162
172
  if (!matches)
163
173
  throw new TenantConflictError();
164
174
  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
- }
175
+ await load(input.tenantId).catch(() => undefined);
169
176
  return { envelope: existing, created: false };
170
177
  }
171
- await openOne(input.tenantId);
172
- const opened = entries.get(input.tenantId);
178
+ await options.onCreated?.(input.tenantId).catch((error) => logger.warn("tenant create step failed; open repairs it", {
179
+ tenantId: input.tenantId,
180
+ message: error instanceof Error ? error.message : String(error),
181
+ }));
182
+ const opened = await load(input.tenantId).catch(() => undefined);
173
183
  if (opened?.kind === "open") {
174
184
  return { envelope: opened.handle.envelope, created: true };
175
185
  }
176
- // Created on disk but failed to open — still report the written envelope.
177
- return { envelope, created: true };
186
+ // Created but failed to open: report the stored envelope.
187
+ return {
188
+ envelope: await store
189
+ .readEnvelope(input.tenantId)
190
+ .catch(() => envelope),
191
+ created: true,
192
+ };
178
193
  },
179
194
  async list() {
180
195
  const ids = new Set(await store.enumerate());
181
196
  for (const id of entries.keys())
182
197
  ids.add(id);
183
198
  const result = [];
184
- for (const id of ids) {
185
- if (!isTenantId(id))
199
+ for (const id of [...ids].sort()) {
200
+ if (!isTenantId(id) || deleting.has(id))
186
201
  continue;
187
202
  let entry = entries.get(id);
188
- if (!entry) {
189
- await openOne(id);
190
- entry = entries.get(id);
203
+ if (entry?.kind === "open") {
204
+ result.push(toAdmin(id, entry, null));
205
+ continue;
191
206
  }
192
207
  let envelope = null;
193
208
  try {
194
209
  envelope = await store.readEnvelope(id);
195
210
  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);
211
+ entry = {
212
+ kind: "quarantined",
213
+ quarantine: quarantine("envelope-invalid", `Tenant envelope id ${envelope.id} does not match ${id}`, { tenantId: id }).toQuarantine(),
214
+ };
198
215
  envelope = null;
199
216
  }
200
217
  }
201
218
  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;
219
+ // Deleted since it was enumerated.
220
+ if (error instanceof TenantNotFoundError)
221
+ continue;
222
+ if (error instanceof TenantUnavailableError)
223
+ throw error;
224
+ // Listed as quarantined; only opening it caches a quarantine.
225
+ entry ??= {
226
+ kind: "quarantined",
227
+ quarantine: quarantineOf(error) ??
228
+ quarantine("envelope-invalid", "Tenant envelope is unreadable", {
229
+ tenantId: id,
230
+ }).toQuarantine(),
231
+ };
212
232
  }
213
233
  result.push(toAdmin(id, entry, envelope));
214
234
  }
@@ -217,99 +237,83 @@ export function createTenantModule(options) {
217
237
  async status(id) {
218
238
  if (!isTenantId(id))
219
239
  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
- }
240
+ const entry = await load(id);
232
241
  if (!entry)
233
242
  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;
243
+ if (entry.kind === "open")
244
+ return toAdmin(id, entry, null);
245
+ const envelope = await store.readEnvelope(id).catch(() => null);
246
+ return { ...toAdmin(id, entry, envelope), quarantine: entry.quarantine };
249
247
  },
250
248
  async delete(id, activeWork) {
251
249
  if (!isTenantId(id))
252
250
  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();
251
+ if (deleting.has(id))
252
+ throw new TenantBusyError("Tenant is being deleted");
253
+ const entry = await load(id);
254
+ if (!entry)
255
+ throw new TenantNotFoundError(id);
256
+ if (entry.kind === "open") {
257
+ const summary = await entry.handle.summary();
271
258
  if (activeWork === "refuse" && hasLiveWork(summary)) {
272
259
  throw new TenantBusyError();
273
260
  }
274
261
  if (activeWork === "drain" || activeWork === "cancel") {
275
262
  await entry.handle.drain(activeWork);
276
263
  }
277
- // Sandbox prefix cleanup is owned by TenantHandle.close() (WS-A).
278
- await entry.handle.close();
279
264
  }
280
- await store.trash(id, new Date());
281
- entries.delete(id);
265
+ deleting.add(id);
266
+ try {
267
+ entries.delete(id);
268
+ // Sandbox prefix cleanup is owned by TenantHandle.close().
269
+ if (entry.kind === "open")
270
+ await entry.handle.close();
271
+ await store.trash(id, new Date());
272
+ }
273
+ finally {
274
+ deleting.delete(id);
275
+ }
282
276
  logger.info("tenant deleted", { tenantId: id, activeWork });
277
+ await options.onDeleted?.(id).catch((error) => logger.warn("tenant delete cleanup failed", {
278
+ tenantId: id,
279
+ message: error instanceof Error ? error.message : String(error),
280
+ }));
283
281
  },
284
- summarize() {
282
+ async summarize() {
285
283
  let runningSessions = 0;
286
284
  let connectedExecutors = 0;
287
285
  let pendingActions = 0;
288
286
  let uncertainEffects = 0;
289
- for (const entry of entries.values()) {
290
- if (entry.kind !== "open")
291
- continue;
292
- const s = entry.handle.summary();
287
+ let outboxDepth;
288
+ let relayLagMs;
289
+ for (const s of await Promise.all(openEntries().map((e) => e.handle.summary()))) {
293
290
  runningSessions += s.runningSessions;
294
291
  connectedExecutors += s.connectedExecutors;
295
292
  pendingActions += s.pendingActions;
296
293
  uncertainEffects += s.uncertainEffects;
294
+ if (s.outboxDepth !== undefined)
295
+ outboxDepth = (outboxDepth ?? 0) + s.outboxDepth;
296
+ if (s.relayLagMs !== undefined)
297
+ relayLagMs = Math.max(relayLagMs ?? 0, s.relayLagMs);
297
298
  }
298
299
  return {
299
300
  runningSessions,
300
301
  connectedExecutors,
301
302
  pendingActions,
302
303
  uncertainEffects,
304
+ ...(outboxDepth !== undefined ? { outboxDepth } : {}),
305
+ ...(relayLagMs !== undefined ? { relayLagMs } : {}),
303
306
  };
304
307
  },
305
308
  async close() {
306
309
  closed = true;
307
310
  started = false;
308
- const open = [...entries.values()].filter((e) => e.kind === "open");
309
- await Promise.all(open.map((e) => e.handle.close().catch(() => undefined)));
311
+ await Promise.allSettled([...opening.values()]);
312
+ const open = openEntries();
310
313
  entries.clear();
314
+ await Promise.all(open.map((e) => e.handle.close().catch(() => undefined)));
311
315
  },
312
316
  };
313
317
  return module;
314
318
  }
315
- export { TenantBusyError, TenantConflictError, isQuarantineError, quarantine, };
319
+ 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
+ }