ignotum 0.0.14 → 0.0.16

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 (127) hide show
  1. package/README.md +6 -6
  2. package/dist/cli/bin.mjs +6042 -4693
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/cli/emscripten-module-D5GzfUNv.mjs +533 -0
  5. package/dist/cli/emscripten-module-D5GzfUNv.mjs.map +1 -0
  6. package/dist/cli/ffi-7IElU4Co.mjs +148 -0
  7. package/dist/cli/ffi-7IElU4Co.mjs.map +1 -0
  8. package/dist/cli/managed-release-WHYLZwaS.mjs +10902 -0
  9. package/dist/cli/managed-release-WHYLZwaS.mjs.map +1 -0
  10. package/dist/cli/managed-release.d.mts +1 -0
  11. package/dist/cli/managed-release.mjs +14 -0
  12. package/dist/cli/managed-release.mjs.map +1 -0
  13. package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs +1289 -0
  14. package/dist/cli/module-ES6BEMUI-D36I4NVn.mjs.map +1 -0
  15. package/dist/runtime/{api-Cv3hMbzo.d.ts → api-B4fwp5a2.d.ts} +4 -4
  16. package/dist/runtime/bootstrap-BISQSn_O.js +916 -0
  17. package/dist/runtime/bootstrap-BISQSn_O.js.map +1 -0
  18. package/dist/runtime/bootstrap-BTFScKAc.d.ts +545 -0
  19. package/dist/runtime/client.d.ts +50 -15
  20. package/dist/runtime/client.js +7 -1158
  21. package/dist/runtime/client.js.map +1 -1
  22. package/dist/runtime/{descriptor-DCCz5AHd-_nhlnu8U.js → descriptor-CTY4mtwS-cS43EbwS.js} +3 -32
  23. package/dist/runtime/descriptor-CTY4mtwS-cS43EbwS.js.map +1 -0
  24. package/dist/runtime/{id-Bt9XWRGL.js → id--1m0NuUL.js} +4 -4
  25. package/dist/runtime/id--1m0NuUL.js.map +1 -0
  26. package/dist/runtime/{id-BzFHf3Wo-DGPCjgrf.d.ts → id-7an3nxTu-DZJSM5xh.d.ts} +2 -2
  27. package/dist/runtime/id-CBOt2kDo.d.ts +1 -0
  28. package/dist/runtime/identity-QjtJRxBD.d.ts +2 -0
  29. package/dist/runtime/image-Djtjy4Z8.js +19 -0
  30. package/dist/runtime/image-Djtjy4Z8.js.map +1 -0
  31. package/dist/runtime/{index-DWU-7TIh.d.ts → index-D4seNHlX.d.ts} +3 -4
  32. package/dist/runtime/internal/api.d.ts +1 -1
  33. package/dist/runtime/internal/api.js +30 -1
  34. package/dist/runtime/internal/api.js.map +1 -0
  35. package/dist/runtime/internal/bootstrap.d.ts +2 -0
  36. package/dist/runtime/internal/bootstrap.js +2 -0
  37. package/dist/runtime/internal/client.d.ts +2 -0
  38. package/dist/runtime/internal/client.js +75 -0
  39. package/dist/runtime/internal/client.js.map +1 -0
  40. package/dist/runtime/internal/host.d.ts +6 -7
  41. package/dist/runtime/internal/host.js +5 -4
  42. package/dist/runtime/internal/host.js.map +1 -1
  43. package/dist/runtime/internal/routes.d.ts +2 -0
  44. package/dist/runtime/internal/routes.js +2 -0
  45. package/dist/runtime/internal/server.d.ts +1 -1
  46. package/dist/runtime/internal/server.js +1 -1
  47. package/dist/runtime/internal/types.d.ts +2 -2
  48. package/dist/runtime/internal/types.js +1 -1
  49. package/dist/runtime/pagination-CBOt2kDo.d.ts +1 -0
  50. package/dist/runtime/pagination-D3qd6s9N.js +33 -0
  51. package/dist/runtime/pagination-D3qd6s9N.js.map +1 -0
  52. package/dist/runtime/result-BkziOG1L.d.ts +1 -0
  53. package/dist/runtime/router-BSBI1oN1.js +2377 -0
  54. package/dist/runtime/router-BSBI1oN1.js.map +1 -0
  55. package/dist/runtime/routes-Ce8TVP-T.js +690 -0
  56. package/dist/runtime/routes-Ce8TVP-T.js.map +1 -0
  57. package/dist/runtime/{schema-DqogEydG.js → schema-D1wOqiNH.js} +4 -3
  58. package/dist/runtime/schema-D1wOqiNH.js.map +1 -0
  59. package/dist/runtime/server.d.ts +5 -6
  60. package/dist/runtime/server.js +2 -2
  61. package/dist/runtime/{sync-Bs8J3fIr.d.ts → sync-D-GK2sv9.d.ts} +3 -2
  62. package/dist/runtime/{api-CAgKDij7.js → sync-DzUwA8W9.js} +22 -41
  63. package/dist/runtime/sync-DzUwA8W9.js.map +1 -0
  64. package/dist/runtime/{types-C1iDP2Yf-BnkfBOQf.d.ts → types-DeCCyqLU-BIm7rU-0.d.ts} +123 -6
  65. package/package.json +30 -5
  66. package/src/cli/agent-files.ts +10 -8
  67. package/src/cli/build/managed-client.ts +411 -0
  68. package/src/cli/build/managed-server.ts +294 -0
  69. package/src/cli/build/managed.ts +67 -0
  70. package/src/cli/build/output.ts +47 -0
  71. package/src/cli/build/server.ts +49 -247
  72. package/src/cli/client-config.ts +1 -140
  73. package/src/cli/client-styles.ts +1 -0
  74. package/src/cli/codegen.ts +9 -4
  75. package/src/cli/command.ts +9 -9
  76. package/src/cli/control-client.ts +88 -103
  77. package/src/cli/deploy.ts +120 -184
  78. package/src/cli/dev.ts +506 -107
  79. package/src/cli/image-assets.ts +499 -0
  80. package/src/cli/managed-client-boundaries.ts +103 -0
  81. package/src/cli/managed-dev-platform.ts +118 -0
  82. package/src/cli/managed-exports.ts +219 -0
  83. package/src/cli/managed-release-bin.ts +13 -0
  84. package/src/cli/managed-release.ts +260 -0
  85. package/src/cli/module-boundaries.ts +11 -1
  86. package/src/cli/new-app.ts +111 -87
  87. package/src/cli/route-codegen.ts +311 -0
  88. package/src/cli/route-static.ts +710 -0
  89. package/src/client/bootstrap.ts +110 -0
  90. package/src/client/errors.ts +13 -7
  91. package/src/client/files.ts +64 -0
  92. package/src/client/id.ts +9 -5
  93. package/src/client/image.ts +28 -0
  94. package/src/client/index.ts +25 -2
  95. package/src/client/managed-client.ts +729 -0
  96. package/src/client/managed-upgrade.ts +283 -0
  97. package/src/client/recovery-journal.ts +195 -0
  98. package/src/client/route-boundaries.ts +184 -0
  99. package/src/client/router-history.ts +152 -0
  100. package/src/client/router-store.ts +644 -0
  101. package/src/client/router.ts +324 -0
  102. package/src/client/routes.ts +28 -0
  103. package/src/client/sync.ts +65 -591
  104. package/src/dev-runtime/database.ts +2 -2
  105. package/src/dev-runtime/functions.ts +3 -3
  106. package/src/dev-runtime/managed-functions.ts +255 -0
  107. package/src/dev-runtime/managed-sync.ts +263 -0
  108. package/src/dev-runtime/managed-websocket.ts +71 -0
  109. package/src/dev-runtime/migrations.ts +11 -0
  110. package/src/dev-runtime/sync.ts +236 -236
  111. package/src/image-assets.d.ts +36 -0
  112. package/src/internal/client.ts +186 -0
  113. package/src/internal/http-paths.ts +0 -3
  114. package/src/internal/routes.ts +45 -0
  115. package/dist/runtime/api-CAgKDij7.js.map +0 -1
  116. package/dist/runtime/descriptor-DCCz5AHd-_nhlnu8U.js.map +0 -1
  117. package/dist/runtime/id-Bt9XWRGL.js.map +0 -1
  118. package/dist/runtime/id-E7JKqQ9r.d.ts +0 -1
  119. package/dist/runtime/pagination-CpYCaRYD-C2Rpp4Cw.d.ts +0 -122
  120. package/dist/runtime/pagination-E7JKqQ9r.d.ts +0 -1
  121. package/dist/runtime/result-DKAA4gpS.d.ts +0 -1
  122. package/dist/runtime/schema-DqogEydG.js.map +0 -1
  123. package/src/cli/build/client.ts +0 -119
  124. package/src/cli/build/public.ts +0 -186
  125. package/src/cli/client-entry.ts +0 -152
  126. package/src/cli/client-plugin.ts +0 -136
  127. package/src/client/app.ts +0 -15
@@ -50,8 +50,8 @@ import {
50
50
  SchemaDefinitionTypeId,
51
51
  type ValueDescriptor,
52
52
  } from "@ignotum/contracts/schema";
53
- import { SchemaSnapshot } from "@ignotum/contracts/deployment";
54
- import { encodeCanonical } from "@ignotum/deployment";
53
+ import { SchemaSnapshot } from "@ignotum/contracts/version";
54
+ import { encodeCanonical } from "@ignotum/versions";
55
55
  import type { FileValue } from "@ignotum/contracts/schema/file";
56
56
 
57
57
  import { DevelopmentDatabase } from "./migrations.js";
@@ -68,7 +68,7 @@ export type RuntimeHandlerContext = (LocalMutationContext | LocalQueryContext) &
68
68
  export type RuntimeFunctionDefinition = InternalRuntimeFunctionDefinition<RuntimeHandlerContext>;
69
69
 
70
70
  const loadDevelopmentEnvironment = Effect.fn("DevelopmentEnvironment.load")(function* (
71
- server: ViteDevServer,
71
+ server: Pick<ViteDevServer, "ssrLoadModule">,
72
72
  appDirectory: string,
73
73
  ) {
74
74
  const fileSystem = yield* FileSystem.FileSystem;
@@ -100,7 +100,7 @@ export class DevelopmentEnvironment extends Context.Service<
100
100
  readonly snapshot: Effect.Effect<LoadedEnvironment, unknown>;
101
101
  }
102
102
  >()("ignotum/dev-runtime/functions/DevelopmentEnvironment") {
103
- static layer(server: ViteDevServer, appDirectory: string) {
103
+ static layer(server: Pick<ViteDevServer, "ssrLoadModule">, appDirectory: string) {
104
104
  return Layer.effect(
105
105
  DevelopmentEnvironment,
106
106
  Effect.gen(function* () {
@@ -151,7 +151,7 @@ export class FunctionRegistry extends Context.Service<
151
151
  ) => Effect.Effect<ResolvedFunction, FunctionResolutionError>;
152
152
  }
153
153
  >()("ignotum/dev-runtime/functions/FunctionRegistry") {
154
- static layer(server: ViteDevServer, appDirectory: string) {
154
+ static layer(server: Pick<ViteDevServer, "ssrLoadModule">, appDirectory: string) {
155
155
  return Layer.effect(
156
156
  FunctionRegistry,
157
157
  Effect.gen(function* () {
@@ -0,0 +1,255 @@
1
+ import { fileURLToPath, pathToFileURL } from "node:url";
2
+ import { Crypto, Effect, Encoding, FileSystem, Path, Predicate, Schema } from "effect";
3
+ import type { ViteDevServer } from "vite";
4
+ import { parseSync } from "oxc-parser";
5
+ import { prepareManagedExecution, type ManagedExecution } from "@ignotum/runtime/quickjs";
6
+ import { apiFunctionParts } from "@ignotum/contracts/runtime/sync";
7
+ import { getFunctionEnvironment } from "@ignotum/contracts/runtime/schema";
8
+ import * as serverNamespace from "../server/index.js";
9
+ import * as internalServerNamespace from "../internal/server.js";
10
+ import * as apiNamespace from "../internal/api.js";
11
+ import { validateManagedReleaseGraph } from "@ignotum/versions/managed-release";
12
+ import { descriptorCodec } from "@ignotum/runtime/schema";
13
+ import { SchemaSnapshotField } from "@ignotum/contracts/version";
14
+ import { attachValueDescriptor, defineEnv, emptyEnv } from "@ignotum/contracts/schema";
15
+ import type { ManagedServerBuildResult } from "../cli/build/managed-server.js";
16
+ import type { buildManagedReleaseGraph } from "../cli/managed-release.js";
17
+
18
+ type DevelopmentModule = Awaited<ReturnType<ViteDevServer["ssrLoadModule"]>>;
19
+ const literal = Schema.encodeSync(Schema.fromJsonString(Schema.String));
20
+
21
+ export class ManagedFunctionLoadFailed extends Schema.TaggedError<ManagedFunctionLoadFailed>()(
22
+ "ManagedFunctionLoadFailed",
23
+ { cause: Schema.Defect() },
24
+ ) {}
25
+
26
+ export interface ManagedFunctionBuild extends Pick<
27
+ ManagedServerBuildResult,
28
+ "server" | "artifacts" | "bytes"
29
+ > {
30
+ readonly environment?: {
31
+ readonly declaration: Pick<ManagedServerBuildResult["environment"]["declaration"], "fields">;
32
+ };
33
+ }
34
+
35
+ export interface ManagedFunctionLoader {
36
+ readonly reload: (build: ManagedFunctionBuild) => Effect.Effect<void, ManagedFunctionLoadFailed>;
37
+ readonly invalidate: (cause: unknown) => void;
38
+ readonly ssrLoadModule: (filename: string) => Promise<DevelopmentModule>;
39
+ }
40
+
41
+ export interface ManagedFunctionSelection {
42
+ readonly guest: Effect.Success<ReturnType<typeof buildManagedReleaseGraph>>;
43
+ readonly release: ManagedExecution["release"];
44
+ readonly requirements: ManagedExecution["requirements"];
45
+ }
46
+
47
+ /** Verify the deployed graph contract, then run app code with the local executor's SDK bindings. */
48
+ export const createManagedFunctionLoader = Effect.fn("ManagedFunctions.createLoader")(function* (
49
+ appDirectory: string,
50
+ selection: ManagedFunctionSelection,
51
+ ) {
52
+ const fs = yield* FileSystem.FileSystem;
53
+ const crypto = yield* Crypto.Crypto;
54
+ const path = yield* Path.Path;
55
+ const directory = yield* fs.makeTempDirectoryScoped({ prefix: "ignotum-managed-functions-" });
56
+ const modules = new Map<string, Promise<DevelopmentModule>>();
57
+ let current = new Map<string, ReadonlyArray<{ readonly name: string; readonly url: string }>>();
58
+ let environment: Promise<DevelopmentModule> = Promise.resolve({ default: emptyEnv });
59
+ let failure: unknown;
60
+ const load = (url: string): Promise<DevelopmentModule> => {
61
+ let loaded = modules.get(url);
62
+ if (loaded === undefined) {
63
+ loaded = import(/* @vite-ignore */ url);
64
+ modules.set(url, loaded);
65
+ }
66
+ return loaded;
67
+ };
68
+ const namespaces = new Map<string, DevelopmentModule>([
69
+ ["ignotum/server", serverNamespace],
70
+ ["ignotum/internal/server", internalServerNamespace],
71
+ ["ignotum/internal/api", apiNamespace],
72
+ ]);
73
+ const guest = yield* validateManagedReleaseGraph(
74
+ selection.guest.manifestBytes,
75
+ [...selection.guest.files].map(([path, bytes]) => ({ path, bytes })),
76
+ );
77
+ const bridges = new Map<string, string>();
78
+ for (const [specifier, namespace] of namespaces) {
79
+ const entry = guest.manifest.modules.find(
80
+ (module) => module.path === guest.manifest.entries[specifier],
81
+ );
82
+ if (entry === undefined || entry.exports.some((name) => !Object.hasOwn(namespace, name))) {
83
+ throw new Error(`The local SDK cannot supply managed entry ${specifier}.`);
84
+ }
85
+ const filename = path.join(directory, `${encodeURIComponent(specifier)}.mjs`);
86
+ const key = Symbol.for(filename);
87
+ // A facade captures the existing SDK namespace so schemas and codecs retain their owner.
88
+ // The handoff exists only while this closed, generated facade is initialized.
89
+ yield* Effect.acquireUseRelease(
90
+ Effect.sync(() => {
91
+ Reflect.set(globalThis, key, namespace);
92
+ }),
93
+ () =>
94
+ Effect.gen(function* () {
95
+ const source =
96
+ `const namespace = globalThis[Symbol.for(${literal(filename)})];\n` +
97
+ entry.exports
98
+ .map(
99
+ (name, index) =>
100
+ `const binding${index} = namespace[${literal(name)}]; export { binding${index} as ${name} };`,
101
+ )
102
+ .join("\n");
103
+ yield* fs.writeFileString(filename, source);
104
+ const url = pathToFileURL(filename).href;
105
+ yield* Effect.tryPromise({
106
+ try: () => load(url),
107
+ catch: (cause) => ManagedFunctionLoadFailed.make({ cause }),
108
+ });
109
+ bridges.set(specifier, url);
110
+ }),
111
+ () =>
112
+ Effect.sync(() => {
113
+ Reflect.deleteProperty(globalThis, key);
114
+ }),
115
+ );
116
+ }
117
+ const reload = Effect.fn("ManagedFunctions.reload")(
118
+ function* (build: ManagedFunctionBuild) {
119
+ const prepared = yield* prepareManagedExecution(
120
+ {
121
+ graph: build.server.graph,
122
+ artifacts: build.artifacts,
123
+ bytes: build.bytes,
124
+ entry: build.server.schema.entry,
125
+ release: selection.release,
126
+ requirements: selection.requirements,
127
+ guest: {
128
+ manifestBytes: selection.guest.manifestBytes,
129
+ files: [...selection.guest.files].map(([path, bytes]) => ({ path, bytes })),
130
+ },
131
+ },
132
+ "schema",
133
+ ).pipe(Effect.provideService(Crypto.Crypto, crypto));
134
+ const urls = new Map(
135
+ prepared.modules
136
+ .filter((module) => module.owner === "App")
137
+ .map((module) => [
138
+ module.id,
139
+ pathToFileURL(path.join(directory, `${encodeURIComponent(module.id)}.mjs`)).href,
140
+ ]),
141
+ );
142
+ for (const module of prepared.modules.filter((module) => module.owner === "App")) {
143
+ const source = prepared.load(module.id);
144
+ const parsed = parseSync(`${module.id}.mjs`, source, { sourceType: "module" });
145
+ if (parsed.module.dynamicImports.length > 0)
146
+ throw new Error("Managed local modules cannot use dynamic imports.");
147
+ if (parsed.errors.length > 0)
148
+ throw new Error(`Could not parse managed module ${module.id}.`);
149
+ const replacements: Array<{ start: number; end: number; value: string }> = [];
150
+ for (const statement of parsed.program.body) {
151
+ if (
152
+ statement.type !== "ImportDeclaration" &&
153
+ statement.type !== "ExportNamedDeclaration" &&
154
+ statement.type !== "ExportAllDeclaration"
155
+ )
156
+ continue;
157
+ if (statement.source === null) continue;
158
+ const targetId = prepared.normalize(module.id, statement.source.value);
159
+ const target = bridges.get(statement.source.value) ?? urls.get(targetId);
160
+ if (target === undefined) throw new Error("Missing verified managed module target.");
161
+ replacements.push({
162
+ start: statement.source.start,
163
+ end: statement.source.end,
164
+ value: literal(target),
165
+ });
166
+ }
167
+ let rewritten = source;
168
+ for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
169
+ rewritten =
170
+ rewritten.slice(0, replacement.start) +
171
+ replacement.value +
172
+ rewritten.slice(replacement.end);
173
+ }
174
+ if (module.sourceMap !== undefined) {
175
+ rewritten = rewritten.replace(/^\/\/# sourceMappingURL=.*$/gm, "");
176
+ rewritten += `\n//# sourceMappingURL=data:application/json;base64,${Encoding.encodeBase64(module.sourceMap.bytes)}\n`;
177
+ }
178
+ yield* fs.writeFileString(fileURLToPath(urls.get(module.id)!), rewritten);
179
+ }
180
+ const next = new Map<string, Array<{ name: string; url: string }>>();
181
+ next.set(path.resolve(appDirectory, "server/schema.ts"), [
182
+ { name: "default", url: urls.get(build.server.schema.entry.module)! },
183
+ ]);
184
+ for (const fn of build.server.functions) {
185
+ const { moduleName, functionName } = apiFunctionParts(fn.address);
186
+ const filename = path.resolve(appDirectory, "server", `${moduleName}.ts`);
187
+ const entries = next.get(filename) ?? [];
188
+ entries.push({ name: functionName, url: urls.get(fn.entry.module)! });
189
+ next.set(filename, entries);
190
+ }
191
+ const first = build.server.functions[0];
192
+ const fields = yield* Schema.decodeEffect(Schema.Array(SchemaSnapshotField))(
193
+ build.environment?.declaration.fields ?? [],
194
+ );
195
+ const declaredEnvironment =
196
+ fields.length === 0
197
+ ? emptyEnv
198
+ : defineEnv(() =>
199
+ Object.fromEntries(
200
+ fields.map((field) => [
201
+ field.name,
202
+ attachValueDescriptor(
203
+ Schema.make<Schema.Codec<unknown>>(descriptorCodec(field.value).ast),
204
+ field.value,
205
+ ),
206
+ ]),
207
+ ),
208
+ );
209
+
210
+ environment =
211
+ first === undefined
212
+ ? Promise.resolve({ default: declaredEnvironment })
213
+ : load(urls.get(first.entry.module)!).then((loaded) => {
214
+ const candidate = loaded[first.entry.exportName];
215
+ return {
216
+ default: Predicate.isObject(candidate)
217
+ ? getFunctionEnvironment(candidate)
218
+ : undefined,
219
+ };
220
+ });
221
+ yield* Effect.tryPromise({
222
+ try: () => environment,
223
+ catch: (cause) => ManagedFunctionLoadFailed.make({ cause }),
224
+ });
225
+ current = next;
226
+ failure = undefined;
227
+ },
228
+ Effect.tapCause((cause) =>
229
+ Effect.sync(() => {
230
+ failure = cause;
231
+ }),
232
+ ),
233
+ Effect.mapError((cause) => ManagedFunctionLoadFailed.make({ cause })),
234
+ );
235
+ const loader: ManagedFunctionLoader = {
236
+ reload,
237
+ invalidate: (cause: unknown): void => {
238
+ failure = cause ?? new Error("Managed server compilation failed.");
239
+ },
240
+ ssrLoadModule: async (filename: string): Promise<DevelopmentModule> => {
241
+ if (failure !== undefined) throw failure;
242
+ const normalized = path.resolve(filename);
243
+ if (normalized === path.resolve(appDirectory, "server/env.ts")) return environment;
244
+ const entries = current.get(normalized);
245
+ if (entries === undefined)
246
+ throw new Error(`Module is outside the managed server graph: ${filename}`);
247
+ return Object.fromEntries(
248
+ await Promise.all(
249
+ entries.map(async (entry) => [entry.name, (await load(entry.url)).default]),
250
+ ),
251
+ );
252
+ },
253
+ };
254
+ return loader;
255
+ });
@@ -0,0 +1,263 @@
1
+ import { canonicalInvocationInput } from "@ignotum/contracts/runtime/hosted";
2
+ import { ManagedClientMessage, ManagedServerMessage } from "@ignotum/contracts/managed-sync";
3
+ import {
4
+ sameExecutionSelection,
5
+ ExecutionSelection,
6
+ type RecoveryIdentity,
7
+ type RecoverySessionId,
8
+ } from "@ignotum/contracts/platform";
9
+ import { idViewKey, type SessionState } from "@ignotum/contracts/id";
10
+ import type { ViteDevServer } from "vite";
11
+ import { Clock, Deferred, Effect, PubSub, Schema, Semaphore } from "effect";
12
+ import { SqlClient } from "effect/unstable/sql";
13
+ import * as Socket from "effect/unstable/socket/Socket";
14
+ import { LocalApplicationFiles } from "./files.js";
15
+ import { MutationReplay, QueryInvalidation, createLocalSession } from "./sync.js";
16
+
17
+ export interface ManagedSyncOptions {
18
+ readonly selection: () => ExecutionSelection;
19
+ readonly loadModule?: ViteDevServer["ssrLoadModule"];
20
+ readonly isServerDependency?: (file: string) => boolean;
21
+ readonly beforeReload?: (file: string, event: "add" | "change" | "unlink") => Promise<void>;
22
+ }
23
+
24
+ /** Native managed protocol framing, selection fencing, and durable invocation ownership. */
25
+ export const runManagedSession = Effect.fn("ManagedLocalSync.runSession")(function* (
26
+ socket: Socket.Socket,
27
+ options: ManagedSyncOptions,
28
+ identity: SessionState,
29
+ refreshIdentity?: Effect.Effect<SessionState>,
30
+ ) {
31
+ const sql = yield* SqlClient.SqlClient;
32
+ const files = yield* LocalApplicationFiles;
33
+ const preparations = new Set<import("@ignotum/contracts/runtime/hosted").InvocationId>();
34
+ const shutdown = yield* Deferred.make<void>();
35
+ const replays = yield* MutationReplay;
36
+ const invalidation = yield* QueryInvalidation;
37
+ const write = yield* socket.writer;
38
+ const writes = yield* Semaphore.make(1);
39
+ const messages = yield* Semaphore.make(1);
40
+ let selection: ExecutionSelection | undefined;
41
+ let session: Effect.Success<ReturnType<typeof createLocalSession>> | undefined;
42
+ let clientSessionId: RecoverySessionId | undefined;
43
+ let expired = false;
44
+ const send = (message: ManagedServerMessage) =>
45
+ writes.withPermits(1)(
46
+ Schema.encodeEffect(Schema.fromJsonString(ManagedServerMessage))(message).pipe(
47
+ Effect.orDie,
48
+ Effect.flatMap(write),
49
+ ),
50
+ );
51
+ const error = (
52
+ code:
53
+ | "InvalidMessage"
54
+ | "SessionExpired"
55
+ | "InvocationIdConflict"
56
+ | "Unavailable"
57
+ | "InvalidArguments",
58
+ message: string,
59
+ id?: string,
60
+ ) =>
61
+ send(
62
+ id === undefined ? { type: "Error", code, message } : { type: "Error", code, message, id },
63
+ );
64
+ const close = Effect.gen(function* () {
65
+ expired = true;
66
+ yield* write(new Socket.CloseEvent(1000, "Local managed session ended")).pipe(
67
+ Effect.ensuring(Deferred.succeed(shutdown, undefined)),
68
+ );
69
+ });
70
+ const checkSelection = Effect.fn("ManagedLocalSync.checkSelection")(function* () {
71
+ if (expired) return false;
72
+ if (selection === undefined || sameExecutionSelection(selection, options.selection()))
73
+ return true;
74
+ expired = true;
75
+ yield* send({ type: "UpgradeRequired", selection: options.selection() });
76
+ yield* close;
77
+ return false;
78
+ });
79
+ const checkIdentity = Effect.fn("ManagedLocalSync.checkIdentity")(function* () {
80
+ if (expired) return false;
81
+ const current = refreshIdentity === undefined ? identity : yield* refreshIdentity;
82
+ if (
83
+ current.sessionEpoch === identity.sessionEpoch &&
84
+ idViewKey(current) === idViewKey(identity) &&
85
+ current.validUntil > (yield* Clock.currentTimeMillis)
86
+ )
87
+ return true;
88
+ expired = true;
89
+ yield* error("SessionExpired", "The local ID session changed or expired.");
90
+ yield* close;
91
+ return false;
92
+ });
93
+ const events = yield* invalidation.subscribe;
94
+ yield* PubSub.take(events).pipe(
95
+ Effect.andThen(checkSelection()),
96
+ Effect.flatMap((current) => (current ? checkIdentity() : Effect.succeed(false))),
97
+ Effect.forever,
98
+ Effect.forkScoped,
99
+ );
100
+ const ownerRows = (invocationId: string) =>
101
+ sql<{
102
+ input: string;
103
+ prepared: number;
104
+ clientSessionId: string;
105
+ sessionEpoch: string;
106
+ selection: string;
107
+ }>`SELECT input, prepared, clientSessionId, sessionEpoch, selection FROM managed_invocation_owners WHERE id = ${invocationId}`;
108
+ const owns = (
109
+ row: { clientSessionId: string; sessionEpoch: string; selection: string },
110
+ original: ExecutionSelection,
111
+ ) =>
112
+ row.clientSessionId === clientSessionId &&
113
+ row.sessionEpoch === identity.sessionEpoch &&
114
+ sameExecutionSelection(
115
+ Schema.decodeSync(Schema.fromJsonString(ExecutionSelection))(row.selection),
116
+ original,
117
+ );
118
+ const outcome = Effect.fn("ManagedLocalSync.outcome")(function* (
119
+ id: string,
120
+ recovery: RecoveryIdentity,
121
+ ) {
122
+ if (
123
+ selection === undefined ||
124
+ recovery.originalSelection.appId !== selection.appId ||
125
+ recovery.originalSelection.dataHistoryId !== selection.dataHistoryId
126
+ ) {
127
+ yield* error(
128
+ "Unavailable",
129
+ "This recovery record belongs to another application data history.",
130
+ id,
131
+ );
132
+ return;
133
+ }
134
+ const rows = yield* ownerRows(recovery.invocationId).pipe(Effect.orDie);
135
+ if (rows[0] === undefined || !owns(rows[0], recovery.originalSelection)) {
136
+ yield* error("SessionExpired", "This session does not own the mutation recovery record.", id);
137
+ return;
138
+ }
139
+ const result = yield* replays.lookup(recovery.invocationId).pipe(Effect.orDie);
140
+ yield* send({
141
+ type: "MutationOutcome",
142
+ id,
143
+ identity: recovery,
144
+ outcome: result === undefined ? { type: "Uncertain" } : { type: "Settled", result },
145
+ });
146
+ });
147
+ const deliver = Effect.fn("ManagedLocalSync.deliver")(function* (message: ManagedServerMessage) {
148
+ if (!(yield* checkSelection()) || !(yield* checkIdentity())) return;
149
+ yield* send(message);
150
+ });
151
+ yield* Effect.addFinalizer(() =>
152
+ Effect.forEach(preparations, (id) => files.releasePreparation(id)),
153
+ );
154
+ yield* socket
155
+ .runString((text) =>
156
+ messages.withPermits(1)(
157
+ Effect.gen(function* () {
158
+ const message = yield* Schema.decodeEffect(Schema.fromJsonString(ManagedClientMessage))(
159
+ text,
160
+ ).pipe(
161
+ Effect.catch(() =>
162
+ error("InvalidMessage", "The frame is not valid managed sync JSON."),
163
+ ),
164
+ );
165
+ if (message === undefined) return;
166
+ if (message.type === "Connect") {
167
+ if (selection !== undefined)
168
+ return yield* error("InvalidMessage", "Connect has already been accepted.");
169
+ const current = options.selection();
170
+ if (
171
+ message.protocolVersion !== 1 ||
172
+ !sameExecutionSelection(message.selection, current)
173
+ ) {
174
+ yield* send({ type: "UpgradeRequired", selection: current });
175
+ return;
176
+ }
177
+ if (!(yield* checkIdentity())) return;
178
+ selection = current;
179
+ clientSessionId = message.clientSessionId;
180
+ session = yield* createLocalSession(current, identity, deliver);
181
+ yield* send({
182
+ type: "Handshake",
183
+ protocolVersion: 1,
184
+ selection,
185
+ session: identity,
186
+ serverTime: yield* Clock.currentTimeMillis,
187
+ });
188
+ return;
189
+ }
190
+ if (selection === undefined || session === undefined)
191
+ return yield* error("InvalidMessage", "Connect must be accepted first.");
192
+ if (expired || !(yield* checkSelection())) return;
193
+ if (!(yield* checkIdentity())) return;
194
+ if (message.type === "RecoverMutation")
195
+ return yield* outcome(message.id, message.identity);
196
+ if (message.type === "Invoke" || message.type === "Prepare") {
197
+ const input = canonicalInvocationInput(
198
+ selection.versionId,
199
+ "Mutation",
200
+ message.function,
201
+ message.args,
202
+ identity.sessionEpoch,
203
+ );
204
+ const rows = yield* ownerRows(message.invocationId).pipe(Effect.orDie);
205
+ if (rows[0] !== undefined && (!owns(rows[0], selection) || rows[0].input !== input))
206
+ return yield* error(
207
+ "InvocationIdConflict",
208
+ "This invocation belongs to another input, selection or session.",
209
+ message.invocationId,
210
+ );
211
+ if (
212
+ rows[0] === undefined &&
213
+ (yield* replays.lookup(message.invocationId).pipe(Effect.orDie)) !== undefined
214
+ )
215
+ return yield* error(
216
+ "InvocationIdConflict",
217
+ "This invocation already exists outside this managed session.",
218
+ message.invocationId,
219
+ );
220
+ const encodedSelection = yield* Schema.encodeEffect(
221
+ Schema.fromJsonString(ExecutionSelection),
222
+ )(selection).pipe(Effect.orDie);
223
+ const inserted =
224
+ yield* sql`INSERT OR IGNORE INTO managed_invocation_owners (id, input, prepared, clientSessionId, sessionEpoch, selection) VALUES (${message.invocationId}, ${input}, ${message.type === "Prepare" ? 1 : 0}, ${clientSessionId!}, ${identity.sessionEpoch}, ${encodedSelection}) RETURNING id`.pipe(
225
+ Effect.orDie,
226
+ );
227
+ const admitted = yield* ownerRows(message.invocationId).pipe(Effect.orDie);
228
+ if (
229
+ admitted[0] === undefined ||
230
+ admitted[0].input !== input ||
231
+ !owns(admitted[0], selection)
232
+ )
233
+ return yield* error(
234
+ "InvocationIdConflict",
235
+ "This invocation belongs to another session.",
236
+ message.invocationId,
237
+ );
238
+ if (inserted.length === 0) {
239
+ if (admitted[0].prepared === 0)
240
+ return yield* outcome(message.invocationId, {
241
+ invocationId: message.invocationId,
242
+ originalSelection: selection,
243
+ });
244
+ if (message.type === "Invoke") {
245
+ const claimed =
246
+ yield* sql`UPDATE managed_invocation_owners SET prepared = 0 WHERE id = ${message.invocationId} AND prepared = 1 RETURNING id`.pipe(
247
+ Effect.orDie,
248
+ );
249
+ if (claimed.length === 0)
250
+ return yield* outcome(message.invocationId, {
251
+ invocationId: message.invocationId,
252
+ originalSelection: selection,
253
+ });
254
+ }
255
+ }
256
+ preparations.add(message.invocationId);
257
+ }
258
+ yield* session.handle(message);
259
+ }),
260
+ ),
261
+ )
262
+ .pipe(Effect.raceFirst(Deferred.await(shutdown)));
263
+ }, Effect.scoped);
@@ -0,0 +1,71 @@
1
+ // @effect-diagnostics-next-line nodeBuiltinImport:off Vite supplies Node HTTP upgrade requests.
2
+ import type { IncomingMessage } from "node:http";
3
+ // @effect-diagnostics-next-line nodeBuiltinImport:off Vite supplies the raw Node upgrade socket.
4
+ import type { Duplex } from "node:stream";
5
+ import { NodeSocket } from "@effect/platform-node";
6
+ import { Cause, Context, Effect, FiberSet, Function, Layer } from "effect";
7
+ import * as Socket from "effect/unstable/socket/Socket";
8
+
9
+ /** Owns upgraded sessions without sending an HTTP response when a session ends. */
10
+ const makeManagedWebSocketUpgradeHandler = Effect.fn("ManagedWebSocket.makeUpgradeHandler")(
11
+ function* <E, R>(
12
+ server: NodeSocket.NodeWS.WebSocketServer,
13
+ handle: (socket: Socket.Socket, request: IncomingMessage) => Effect.Effect<void, E, R>,
14
+ ) {
15
+ const run = yield* FiberSet.makeRuntime<R>();
16
+ return (request: IncomingMessage, raw: Duplex, head: Buffer): void => {
17
+ const session = Effect.gen(function* () {
18
+ const socket = yield* Socket.fromWebSocket(
19
+ Effect.acquireRelease(
20
+ Effect.callback<globalThis.WebSocket>((resume) => {
21
+ server.handleUpgrade(request, raw, head, (webSocket) => {
22
+ // SAFETY: Effect's NodeSocket layer uses this same NodeWS/DOM bridge. Socket.fromWebSocket consumes compatible events, send and close methods.
23
+ // eslint-disable-next-line anti-slop/no-chained-type-assertions
24
+ resume(Effect.succeed(webSocket as unknown as globalThis.WebSocket));
25
+ });
26
+ }),
27
+ (webSocket) => Effect.sync(() => webSocket.close()),
28
+ ),
29
+ { closeCodeIsError: Function.constFalse },
30
+ );
31
+ yield* handle(socket, request);
32
+ }).pipe(
33
+ Effect.scoped,
34
+ Effect.catchCause((cause) =>
35
+ Cause.hasInterruptsOnly(cause)
36
+ ? Effect.void
37
+ : Effect.logError("A managed local WebSocket session failed.").pipe(
38
+ Effect.annotateLogs({ cause }),
39
+ ),
40
+ ),
41
+ );
42
+ const fiber = run(session);
43
+ const onClose = () => fiber.interruptUnsafe();
44
+ raw.on("error", Function.constVoid);
45
+ raw.once("close", onClose);
46
+ fiber.addObserver(() => {
47
+ raw.off("error", Function.constVoid);
48
+ raw.off("close", onClose);
49
+ });
50
+ };
51
+ },
52
+ );
53
+
54
+ export class ManagedWebSocket extends Context.Service<
55
+ ManagedWebSocket,
56
+ {
57
+ readonly upgrade: (request: IncomingMessage, socket: Duplex, head: Buffer) => void;
58
+ }
59
+ >()("ignotum/dev-runtime/managed-websocket/ManagedWebSocket") {
60
+ static layer<E, R>(
61
+ server: NodeSocket.NodeWS.WebSocketServer,
62
+ handle: (socket: Socket.Socket, request: IncomingMessage) => Effect.Effect<void, E, R>,
63
+ ) {
64
+ return Layer.effect(
65
+ ManagedWebSocket,
66
+ makeManagedWebSocketUpgradeHandler(server, handle).pipe(
67
+ Effect.map((upgrade) => ManagedWebSocket.of({ upgrade })),
68
+ ),
69
+ );
70
+ }
71
+ }
@@ -101,6 +101,17 @@ export const developmentMigrationLoader = Migrator.fromRecord({
101
101
  "0002_application_indexes": addDevelopmentIndexes,
102
102
  "0003_application_files": addApplicationFiles,
103
103
  "0004_schema_state": addSchemaState,
104
+ "0005_managed_invocation_owners": Effect.gen(function* () {
105
+ const sql = yield* SqlClient.SqlClient;
106
+ yield* sql`CREATE TABLE managed_invocation_owners (
107
+ id TEXT PRIMARY KEY NOT NULL,
108
+ input TEXT NOT NULL,
109
+ prepared INTEGER NOT NULL DEFAULT 0 CHECK (prepared IN (0, 1)),
110
+ clientSessionId TEXT NOT NULL,
111
+ sessionEpoch TEXT NOT NULL,
112
+ selection TEXT NOT NULL CHECK (json_valid(selection))
113
+ ) STRICT`;
114
+ }),
104
115
  });
105
116
 
106
117
  interface DevelopmentDatabaseService {