ignotum 0.0.11 → 0.0.12

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 (48) hide show
  1. package/README.md +8 -3
  2. package/dist/cli/bin.mjs +560 -102
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-DR_8vfKg.d.ts → api-5XSrIeqW.d.ts} +4 -3
  5. package/dist/runtime/client.d.ts +6 -5
  6. package/dist/runtime/client.js +170 -75
  7. package/dist/runtime/client.js.map +1 -1
  8. package/dist/runtime/{descriptor-C5VA9qRl-DuQsowaQ.js → descriptor-XzDX2JDw-j3O6YHgW.js} +23 -4
  9. package/dist/runtime/descriptor-XzDX2JDw-j3O6YHgW.js.map +1 -0
  10. package/dist/runtime/{id-Cs82tq9Q-hmYFyqTa.d.ts → id-Cs82tq9Q-CK-maMgN.d.ts} +12 -12
  11. package/dist/runtime/{index-2q9FwJud.d.ts → index-CrWg4Z0y.d.ts} +132 -76
  12. package/dist/runtime/internal/api.d.ts +1 -1
  13. package/dist/runtime/internal/host.d.ts +18 -17
  14. package/dist/runtime/internal/host.js +10 -5
  15. package/dist/runtime/internal/host.js.map +1 -1
  16. package/dist/runtime/internal/server.d.ts +1 -1
  17. package/dist/runtime/internal/server.js +1 -1
  18. package/dist/runtime/internal/types.d.ts +1 -1
  19. package/dist/runtime/internal/types.js +1 -1
  20. package/dist/runtime/pagination-CX5IPbEi.d.ts +1 -0
  21. package/dist/runtime/{pagination-Bt3l7QaC-BYEGmLBE.d.ts → pagination-D-R9NR61-CpIoHFoI.d.ts} +8 -5
  22. package/dist/runtime/result-DIjKM-p4.d.ts +1 -0
  23. package/dist/runtime/{schema-ERFjT8-m.js → schema-B9XxyRO8.js} +132 -16
  24. package/dist/runtime/schema-B9XxyRO8.js.map +1 -0
  25. package/dist/runtime/server.d.ts +7 -3
  26. package/dist/runtime/server.js +5 -3
  27. package/dist/runtime/server.js.map +1 -1
  28. package/dist/runtime/{sync-a2EdGSdY.d.ts → sync-avN7NkcU.d.ts} +2 -2
  29. package/package.json +4 -4
  30. package/src/cli/agent-files.ts +2 -0
  31. package/src/cli/auth-client.ts +1 -1
  32. package/src/cli/bin.ts +13 -3
  33. package/src/cli/build/server.ts +102 -13
  34. package/src/cli/codegen.ts +7 -4
  35. package/src/cli/control-client.ts +30 -8
  36. package/src/cli/deploy.ts +7 -2
  37. package/src/cli/environment.ts +133 -0
  38. package/src/client/files.ts +43 -27
  39. package/src/client/hooks.ts +26 -17
  40. package/src/client/page-observers.ts +77 -0
  41. package/src/client/sync.ts +71 -59
  42. package/src/dev-runtime/functions.ts +127 -4
  43. package/src/dev-runtime/sync.ts +38 -9
  44. package/src/server/index.ts +11 -1
  45. package/dist/runtime/descriptor-C5VA9qRl-DuQsowaQ.js.map +0 -1
  46. package/dist/runtime/pagination-CFJ3xlAt.d.ts +0 -1
  47. package/dist/runtime/schema-ERFjT8-m.js.map +0 -1
  48. package/src/dev-runtime/id.ts +0 -3
@@ -3,7 +3,18 @@ import process from "node:process";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
5
  import * as Config from "effect/Config";
6
- import { Array, Effect, FileSystem, Path, Predicate, Schema, String } from "effect";
6
+ import {
7
+ Array,
8
+ Crypto,
9
+ Effect,
10
+ Encoding,
11
+ FileSystem,
12
+ Path,
13
+ Predicate,
14
+ Schema,
15
+ Stream,
16
+ String,
17
+ } from "effect";
7
18
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
8
19
  import { createServer, defaultServerConditions, normalizePath } from "vite";
9
20
  import type { Plugin, ViteDevServer } from "vite";
@@ -11,7 +22,10 @@ import type { Plugin, ViteDevServer } from "vite";
11
22
  import {
12
23
  ServerBuildManifest,
13
24
  SchemaSnapshot,
25
+ SchemaSnapshotField,
26
+ EnvironmentArtifact,
14
27
  schemaSnapshotPath,
28
+ serverEnvironmentPath,
15
29
  type ServerFunctionArtifact,
16
30
  } from "@ignotum/contracts/deployment";
17
31
  import { FunctionAddress } from "@ignotum/contracts/runtime/sync";
@@ -19,7 +33,9 @@ import type { FunctionKind } from "@ignotum/contracts/runtime/functions";
19
33
  import {
20
34
  descriptorContainsFile,
21
35
  descriptorFields,
36
+ emptyEnv,
22
37
  getValueDescriptor,
38
+ isDefinedEnv,
23
39
  isDefinedSchema,
24
40
  ValueDescriptor,
25
41
  type DefinedSchema,
@@ -34,6 +50,7 @@ import {
34
50
  } from "@ignotum/deployment";
35
51
 
36
52
  import { inspectRuntimeFunctionDefinition } from "../../internal/function-definition.js";
53
+ import { loadEnvironment } from "../environment.js";
37
54
  import { moduleBoundariesPlugin, BuildBoundaryViolation } from "../module-boundaries.js";
38
55
  import {
39
56
  causeMessage,
@@ -69,6 +86,11 @@ interface DiscoveredServerFunction {
69
86
  }
70
87
 
71
88
  const ServerDiscoveryManifest = Schema.Struct({
89
+ environment: Schema.Struct({
90
+ fields: Schema.Array(SchemaSnapshotField),
91
+ hasDefinition: Schema.Boolean,
92
+ values: Schema.Record(Schema.String, Schema.String),
93
+ }),
72
94
  functions: Schema.Array(DiscoveredServerFunction),
73
95
  schema: SchemaSnapshot,
74
96
  });
@@ -77,6 +99,10 @@ const ServerDiscoveryManifestJson = Schema.fromJsonString(ServerDiscoveryManifes
77
99
  const ServerModuleNamesJson = Schema.fromJsonString(Schema.Array(Schema.String));
78
100
 
79
101
  export interface ServerBuildResult {
102
+ readonly environment: {
103
+ readonly bytes: Uint8Array;
104
+ readonly path: typeof serverEnvironmentPath;
105
+ };
80
106
  readonly functions: number;
81
107
  readonly manifestPath: string;
82
108
  }
@@ -268,6 +294,7 @@ const discoverServerFunctionsInProcess = Effect.fn("Deploy.discoverServerFunctio
268
294
  return yield* Effect.scoped(
269
295
  Effect.gen(function* () {
270
296
  const server = yield* acquireModuleRunner(appDirectory, conditions);
297
+ const fileSystem = yield* FileSystem.FileSystem;
271
298
  const path = yield* Path.Path;
272
299
  const schemaPath = path.join(appDirectory, "server", "schema.ts");
273
300
  const loadedSchemaModule = yield* Effect.tryPromise({
@@ -295,14 +322,59 @@ const discoverServerFunctionsInProcess = Effect.fn("Deploy.discoverServerFunctio
295
322
  message: "The default export from server/schema.ts is not an Ignotum schema.",
296
323
  });
297
324
  }
325
+ const environmentPath = path.join(appDirectory, "server", "env.ts");
326
+ const hasEnvironmentDefinition = yield* fileSystem.exists(environmentPath);
327
+ const environment = hasEnvironmentDefinition
328
+ ? yield* Effect.gen(function* () {
329
+ const loadedEnvironmentModule = yield* Effect.tryPromise({
330
+ try: () => server.ssrLoadModule(normalizePath(environmentPath)),
331
+ catch: (cause) =>
332
+ ServerFunctionDiscoveryFailed.make({
333
+ cause,
334
+ message: "Could not load the application environment declaration.",
335
+ path: environmentPath,
336
+ }),
337
+ });
338
+ if (
339
+ !Predicate.isObject(loadedEnvironmentModule.default) ||
340
+ !isDefinedEnv(loadedEnvironmentModule.default)
341
+ ) {
342
+ return yield* InvalidBuildOutput.make({
343
+ message: "The default export from server/env.ts is not an Ignotum environment.",
344
+ });
345
+ }
346
+ const loaded = yield* loadEnvironment(
347
+ appDirectory,
348
+ loadedEnvironmentModule.default,
349
+ true,
350
+ );
351
+ return {
352
+ fields: loadedEnvironmentModule.default.descriptors,
353
+ hasDefinition: true,
354
+ values: loaded.raw,
355
+ } as const;
356
+ })
357
+ : yield* loadEnvironment(appDirectory, emptyEnv, false).pipe(
358
+ Effect.map((loaded) => ({
359
+ fields: [],
360
+ hasDefinition: false,
361
+ values: loaded.raw,
362
+ })),
363
+ );
298
364
  const modules = yield* Effect.forEach(moduleNames, (moduleName) =>
299
365
  discoverModuleFunctions(server, appDirectory, moduleName),
300
366
  );
301
367
  return {
302
368
  functions: Array.flatten(modules),
369
+ environment,
303
370
  schema: schemaCandidate,
304
371
  } satisfies {
305
372
  readonly functions: ReadonlyArray<DiscoveredServerFunction>;
373
+ readonly environment: {
374
+ readonly fields: ReadonlyArray<SchemaSnapshotField>;
375
+ readonly hasDefinition: boolean;
376
+ readonly values: Readonly<Record<string, string>>;
377
+ };
306
378
  readonly schema: DefinedSchema<Tables>;
307
379
  };
308
380
  }),
@@ -312,9 +384,7 @@ const discoverServerFunctionsInProcess = Effect.fn("Deploy.discoverServerFunctio
312
384
  export const runServerDiscoveryWorker = Effect.fn("Deploy.runServerDiscoveryWorker")(function* (
313
385
  appDirectory: string,
314
386
  moduleNamesJson: string,
315
- outputPath: string,
316
387
  ) {
317
- const fileSystem = yield* FileSystem.FileSystem;
318
388
  const moduleNames = yield* Schema.decodeEffect(ServerModuleNamesJson)(moduleNamesJson).pipe(
319
389
  Effect.orDie,
320
390
  );
@@ -327,11 +397,11 @@ export const runServerDiscoveryWorker = Effect.fn("Deploy.runServerDiscoveryWork
327
397
  message: "The application schema does not have canonical Ignotum descriptors.",
328
398
  }),
329
399
  });
330
- const encoded = yield* Schema.encodeEffect(ServerDiscoveryManifestJson)({
400
+ return yield* Schema.encodeEffect(ServerDiscoveryManifestJson)({
401
+ environment: discovered.environment,
331
402
  functions: discovered.functions,
332
403
  schema,
333
404
  });
334
- yield* fileSystem.writeFileString(outputPath, encoded);
335
405
  });
336
406
 
337
407
  const discoveryWorkerEntry = Effect.fn("Deploy.discoveryWorkerEntry")(function* () {
@@ -365,7 +435,6 @@ const discoverServerFunctions = Effect.fn("Deploy.discoverServerFunctionsIsolate
365
435
  (temporary) =>
366
436
  fileSystem.remove(temporary, { force: true, recursive: true }).pipe(Effect.orDie),
367
437
  );
368
- const outputPath = path.join(directory, "discovery.json");
369
438
  const encodedModuleNames = yield* Schema.encodeEffect(ServerModuleNamesJson)(moduleNames);
370
439
  const packageRoot = path.resolve(path.dirname(workerEntry), "../..");
371
440
  const workspaceProbes: Array<string> = [];
@@ -387,23 +456,25 @@ const discoverServerFunctions = Effect.fn("Deploy.discoverServerFunctionsIsolate
387
456
  const readPermissions = [
388
457
  appDirectory,
389
458
  nodeModulesDirectory(),
459
+ path.dirname(nodeModulesDirectory()),
390
460
  packageRoot,
391
461
  path.join(packageRoot, "node_modules"),
462
+ path.resolve(packageRoot, "../..", "node_modules"),
392
463
  ...workspaceProbes,
393
464
  ].map((allowedPath) => `--allow-fs-read=${allowedPath}`);
394
- const exitCode = yield* spawner.exitCode(
465
+ const handle = yield* spawner.spawn(
395
466
  ChildProcess.make(
396
467
  process.execPath,
397
468
  [
398
469
  "--permission",
399
470
  "--allow-addons",
471
+ "--conditions=@ignotum/source",
400
472
  ...readPermissions,
401
473
  `--allow-fs-write=${directory}`,
402
474
  workerEntry,
403
475
  "__ignotum_discover_server",
404
476
  appDirectory,
405
477
  encodedModuleNames,
406
- outputPath,
407
478
  ],
408
479
  {
409
480
  cwd: appDirectory,
@@ -414,9 +485,14 @@ const discoverServerFunctions = Effect.fn("Deploy.discoverServerFunctionsIsolate
414
485
  },
415
486
  stderr: "inherit",
416
487
  stdout: "ignore",
488
+ additionalFds: { fd3: { type: "output" } },
417
489
  },
418
490
  ),
419
491
  );
492
+ const [encoded, exitCode] = yield* Effect.all(
493
+ [Stream.mkString(Stream.decodeText(handle.getOutputFd(3))), handle.exitCode],
494
+ { concurrency: "unbounded" },
495
+ );
420
496
  if (exitCode !== ChildProcessSpawner.ExitCode(0)) {
421
497
  return yield* ServerFunctionDiscoveryFailed.make({
422
498
  cause: new Error(`Discovery worker exited with code ${exitCode}.`),
@@ -424,13 +500,12 @@ const discoverServerFunctions = Effect.fn("Deploy.discoverServerFunctionsIsolate
424
500
  path: appDirectory,
425
501
  });
426
502
  }
427
- const encoded = yield* fileSystem.readFileString(outputPath);
428
503
  return yield* Schema.decodeEffect(ServerDiscoveryManifestJson)(encoded).pipe(
429
504
  Effect.mapError((cause) =>
430
505
  ServerFunctionDiscoveryFailed.make({
431
506
  cause,
432
507
  message: "The isolated build process returned invalid application metadata.",
433
- path: outputPath,
508
+ path: appDirectory,
434
509
  }),
435
510
  ),
436
511
  );
@@ -445,8 +520,8 @@ const serverFunctionEntryPlugin = (definition: DiscoveredServerFunction): Plugin
445
520
  import { invoke } from "ignotum/internal/host"
446
521
  import { ${definition.exportName} as definition } from ${encodeJavaScriptString(normalizePath(definition.modulePath))}
447
522
 
448
- export default async function handler(encodedRequest) {
449
- return invoke(definition, JSON.parse(encodedRequest), call)
523
+ export default async function handler(encodedRequest, encodedEnvironment) {
524
+ return invoke(definition, JSON.parse(encodedRequest), call, JSON.parse(encodedEnvironment))
450
525
  }
451
526
  `;
452
527
 
@@ -573,6 +648,15 @@ export const buildServer = Effect.fn("Deploy.buildServer")(function* (
573
648
  const path = yield* Path.Path;
574
649
  const conditions = yield* serverConditions();
575
650
  const discovered = yield* discoverServerFunctions(appDirectory, functionModules);
651
+ const crypto = yield* Crypto.Crypto;
652
+ const environmentArtifact = {
653
+ formatVersion: 1,
654
+ nonce: Encoding.encodeBase64Url(yield* crypto.randomBytes(32)),
655
+ values: discovered.environment.values,
656
+ } satisfies EnvironmentArtifact;
657
+ const environmentBytes = utf8Bytes(
658
+ `${encodeCanonical(EnvironmentArtifact, environmentArtifact)}\n`,
659
+ );
576
660
 
577
661
  yield* fileSystem.makeDirectory(outputDirectory, { recursive: true });
578
662
  const encodedSnapshot = encodeSchemaSnapshot(discovered.schema);
@@ -584,7 +668,11 @@ export const buildServer = Effect.fn("Deploy.buildServer")(function* (
584
668
  { concurrency: 4 },
585
669
  );
586
670
  const manifest = {
587
- formatVersion: 2,
671
+ formatVersion: 3,
672
+ environment: {
673
+ artifact: yield* artifactReference(serverEnvironmentPath, environmentBytes),
674
+ fields: discovered.environment.fields,
675
+ },
588
676
  schema: yield* artifactReference(schemaSnapshotPath, snapshotBytes),
589
677
  functions,
590
678
  } satisfies ServerBuildManifest;
@@ -598,6 +686,7 @@ export const buildServer = Effect.fn("Deploy.buildServer")(function* (
598
686
  );
599
687
 
600
688
  return {
689
+ environment: { bytes: environmentBytes, path: serverEnvironmentPath },
601
690
  functions: functions.length,
602
691
  manifestPath,
603
692
  } satisfies ServerBuildResult;
@@ -41,6 +41,7 @@ const isFunctionModuleFile = (fileName: string): boolean =>
41
41
  !fileName.endsWith(".test.ts") &&
42
42
  !fileName.endsWith(".spec.ts") &&
43
43
  fileName !== "index.ts" &&
44
+ fileName !== "env.ts" &&
44
45
  fileName !== "schema.ts" &&
45
46
  !fileName.startsWith("_");
46
47
 
@@ -57,13 +58,13 @@ const moduleNameFromFile = (filePath: string, fileName: string) => {
57
58
  );
58
59
  };
59
60
 
60
- const renderServerBindings = (): string => `${generatedHeader}
61
+ const renderServerBindings = (hasEnvironment: boolean): string => `${generatedHeader}
61
62
 
62
63
  import { bindSchema } from "ignotum/internal/server";
63
64
 
64
- import schema from "../server/schema.js";
65
+ ${hasEnvironment ? 'import environment from "../server/env.js";\n' : ""}import schema from "../server/schema.js";
65
66
 
66
- export const { mutation, query, values } = bindSchema(schema);
67
+ export const { mutation, query, values } = bindSchema(schema${hasEnvironment ? ", environment" : ""});
67
68
  `;
68
69
 
69
70
  const renderTypes = (): string => `${generatedHeader}
@@ -157,6 +158,7 @@ export const generate = Effect.fn("Codegen.generate")(function* (appDirectory: s
157
158
  const path = yield* Path.Path;
158
159
  const serverDirectory = path.join(appDirectory, "server");
159
160
  const schemaPath = path.join(serverDirectory, "schema.ts");
161
+ const environmentPath = path.join(serverDirectory, "env.ts");
160
162
  const schemaExists = yield* fileSystem.exists(schemaPath);
161
163
 
162
164
  if (!schemaExists) {
@@ -167,13 +169,14 @@ export const generate = Effect.fn("Codegen.generate")(function* (appDirectory: s
167
169
  }
168
170
 
169
171
  const entries = yield* fileSystem.readDirectory(serverDirectory);
172
+ const hasEnvironment = yield* fileSystem.exists(environmentPath);
170
173
  const functionFiles = Array.sort(String.Order)(Array.filter(entries, isFunctionModuleFile));
171
174
  const functionModules = yield* Effect.forEach(functionFiles, (fileName) =>
172
175
  moduleNameFromFile(path.join(serverDirectory, fileName), fileName),
173
176
  );
174
177
  const outputs: ReadonlyArray<GeneratedOutput> = [
175
178
  {
176
- content: renderServerBindings(),
179
+ content: renderServerBindings(hasEnvironment),
177
180
  path: path.join(appDirectory, "_generated", "server.ts"),
178
181
  },
179
182
  {
@@ -102,11 +102,12 @@ export class ControlClientUnavailable extends Schema.TaggedError<ControlClientUn
102
102
  ) {}
103
103
 
104
104
  export interface DeploymentUploadFile {
105
+ readonly bytes?: Uint8Array;
105
106
  readonly contentType: string;
106
107
  readonly path: string;
107
108
  readonly sha256: string;
108
109
  readonly size: number;
109
- readonly sourcePath: string;
110
+ readonly sourcePath?: string;
110
111
  }
111
112
 
112
113
  interface ControlClientService {
@@ -168,6 +169,7 @@ export const deploymentUploadFiles = Effect.fn("ControlClient.deploymentUploadFi
168
169
  inventorySha256: string,
169
170
  inventorySize: number,
170
171
  buildDirectory: string,
172
+ memoryFiles: ReadonlyMap<string, Uint8Array> = new Map(),
171
173
  ) {
172
174
  const path = yield* Path.Path;
173
175
  return [
@@ -178,7 +180,18 @@ export const deploymentUploadFiles = Effect.fn("ControlClient.deploymentUploadFi
178
180
  size: inventorySize,
179
181
  sourcePath: path.join(buildDirectory, deploymentInventoryPath),
180
182
  },
181
- ...inventory.files.map((file) => uploadFile(file, path.join(buildDirectory, file.path))),
183
+ ...inventory.files.map((file) => {
184
+ const bytes = memoryFiles.get(file.path);
185
+ return bytes === undefined
186
+ ? uploadFile(file, path.join(buildDirectory, file.path))
187
+ : {
188
+ bytes,
189
+ contentType: file.contentType,
190
+ path: file.path,
191
+ sha256: file.sha256,
192
+ size: file.size,
193
+ };
194
+ }),
182
195
  ];
183
196
  });
184
197
 
@@ -304,17 +317,26 @@ export const controlClientLayer = Layer.effect(
304
317
  deploymentId: DeploymentId,
305
318
  file: DeploymentUploadFile,
306
319
  ) {
307
- const request = yield* HttpClientRequest.put(
320
+ const baseRequest = HttpClientRequest.put(
308
321
  endpoint(config.apiUrl, controlDeploymentFilePath(appId, deploymentId, file.path)),
309
322
  ).pipe(
310
323
  HttpClientRequest.setHeader(controlUploadHeaderNames.contentLength, String(file.size)),
311
324
  HttpClientRequest.setHeader(controlUploadHeaderNames.sha256, file.sha256),
312
- HttpClientRequest.bodyFile(file.sourcePath, { contentType: file.contentType }),
313
- Effect.provideService(FileSystem.FileSystem, fileSystem),
314
- Effect.mapError((cause) =>
315
- unavailable(`Deployment file '${file.path}' could not be opened.`, cause),
316
- ),
317
325
  );
326
+ const request = yield* file.bytes === undefined
327
+ ? file.sourcePath === undefined
328
+ ? Effect.fail(unavailable(`Deployment file '${file.path}' has no upload source.`, {}))
329
+ : HttpClientRequest.bodyFile(baseRequest, file.sourcePath, {
330
+ contentType: file.contentType,
331
+ }).pipe(
332
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
333
+ Effect.mapError((cause) =>
334
+ unavailable(`Deployment file '${file.path}' could not be opened.`, cause),
335
+ ),
336
+ )
337
+ : Effect.succeed(
338
+ HttpClientRequest.bodyUint8Array(baseRequest, file.bytes, file.contentType),
339
+ );
318
340
  const response = yield* executeIdempotent(request, DeploymentFileUploadResponse);
319
341
  return response.status;
320
342
  });
package/src/cli/deploy.ts CHANGED
@@ -22,6 +22,7 @@ export interface DeploymentBuildResult {
22
22
  readonly inventory: DeploymentInventory;
23
23
  readonly inventoryBytes: Uint8Array;
24
24
  readonly outputDirectory: string;
25
+ readonly memoryFiles?: ReadonlyMap<string, Uint8Array>;
25
26
  readonly serverFunctions: number;
26
27
  }
27
28
 
@@ -119,13 +120,15 @@ export const buildDeploymentArtifact = Effect.fn("Deploy.buildArtifact")(functio
119
120
  const serverOutput = path.join(stagingDirectory, "server");
120
121
  const client = yield* buildClient(appDirectory, clientOutput);
121
122
  const server = yield* buildServer(appDirectory, serverOutput, codegen.functionModules);
122
- const payloadFiles = yield* readArtifactDirectory(stagingDirectory);
123
+ const diskPayloadFiles = yield* readArtifactDirectory(stagingDirectory);
124
+ const payloadFiles = [...diskPayloadFiles, server.environment];
123
125
  const inventory = yield* makeDeploymentInventory(payloadFiles);
124
126
  yield* fileSystem.writeFile(
125
127
  path.join(stagingDirectory, deploymentInventoryPath),
126
128
  inventory.bytes,
127
129
  );
128
- const artifactFiles = yield* readArtifactDirectory(stagingDirectory);
130
+ const diskArtifactFiles = yield* readArtifactDirectory(stagingDirectory);
131
+ const artifactFiles = [...diskArtifactFiles, server.environment];
129
132
  yield* validateDeploymentArtifact(artifactFiles);
130
133
  yield* Effect.logInfo("Built deployment artifact").pipe(
131
134
  Effect.annotateLogs({
@@ -140,6 +143,7 @@ export const buildDeploymentArtifact = Effect.fn("Deploy.buildArtifact")(functio
140
143
  clientAssets: client.assets,
141
144
  inventory: inventory.inventory,
142
145
  inventoryBytes: inventory.bytes,
146
+ memoryFiles: new Map([[server.environment.path, server.environment.bytes]]),
143
147
  outputDirectory: buildDirectory,
144
148
  serverFunctions: server.functions,
145
149
  } satisfies DeploymentBuildResult;
@@ -158,6 +162,7 @@ export const uploadDeployment = Effect.fn("Deploy.upload")(function* (
158
162
  inventorySha256,
159
163
  build.inventoryBytes.byteLength,
160
164
  build.outputDirectory,
165
+ build.memoryFiles,
161
166
  );
162
167
  const deployment = yield* control.beginDeployment(configuration.appId, build.inventory);
163
168
 
@@ -0,0 +1,133 @@
1
+ import { Array, ConfigProvider, Effect, FileSystem, Path, Schema } from "effect";
2
+
3
+ import {
4
+ decodeDefinedEnvironment,
5
+ emptyEnv,
6
+ type DefinedEnv,
7
+ type EnvironmentType,
8
+ } from "@ignotum/contracts/schema";
9
+
10
+ export class EnvironmentConfigurationInvalid extends Schema.TaggedError<EnvironmentConfigurationInvalid>()(
11
+ "EnvironmentConfigurationInvalid",
12
+ {
13
+ message: Schema.String,
14
+ path: Schema.String,
15
+ },
16
+ ) {}
17
+
18
+ export interface LoadedEnvironment<Environment extends DefinedEnv = DefinedEnv> {
19
+ readonly definition: Environment;
20
+ readonly raw: Readonly<Record<string, string>>;
21
+ readonly value: Readonly<EnvironmentType<Environment>>;
22
+ }
23
+
24
+ export interface RawEnvironment {
25
+ readonly path: string;
26
+ readonly raw: Readonly<Record<string, string>>;
27
+ }
28
+
29
+ const collectKeys = Effect.fn("Environment.collectKeys")(function* (
30
+ provider: ConfigProvider.ConfigProvider,
31
+ providerPath: ConfigProvider.Path = [],
32
+ nameParts: ReadonlyArray<string> = [],
33
+ ): Effect.fn.Return<ReadonlyArray<string>, ConfigProvider.SourceError, never> {
34
+ const node = yield* provider.load(providerPath);
35
+ if (node === undefined) return [];
36
+ const own = node._tag === "Value" || node.value !== undefined ? [nameParts.join("_")] : [];
37
+ if (node._tag === "Value") return own;
38
+ if (node._tag === "Array") {
39
+ const children = yield* Effect.forEach(Array.range(0, node.length - 1), (index) =>
40
+ collectKeys(provider, [...providerPath, index], [...nameParts, index.toString()]),
41
+ );
42
+ return [...own, ...Array.flatten(children)];
43
+ }
44
+ const children = yield* Effect.forEach(Array.fromIterable(node.keys), (key) =>
45
+ collectKeys(provider, [...providerPath, key], [...nameParts, key]),
46
+ );
47
+ return [...own, ...Array.flatten(children)];
48
+ });
49
+
50
+ const nodeValue = (node: ConfigProvider.Node | undefined): string | undefined =>
51
+ node?._tag === "Value" ? node.value : node?.value;
52
+
53
+ export const readEnvironmentRaw = Effect.fn("Environment.readRaw")(function* (
54
+ appDirectory: string,
55
+ hasDefinition: boolean,
56
+ ) {
57
+ const fileSystem = yield* FileSystem.FileSystem;
58
+ const path = yield* Path.Path;
59
+ const environmentPath = path.join(appDirectory, ".env.ignotum");
60
+ const exists = yield* fileSystem.exists(environmentPath);
61
+ if (!hasDefinition && exists) {
62
+ return yield* EnvironmentConfigurationInvalid.make({
63
+ message: ".env.ignotum exists but server/env.ts does not define its variables.",
64
+ path: environmentPath,
65
+ });
66
+ }
67
+ if (!hasDefinition) {
68
+ return { path: environmentPath, raw: {} } satisfies RawEnvironment;
69
+ }
70
+ if (!exists) {
71
+ return yield* EnvironmentConfigurationInvalid.make({
72
+ message: "server/env.ts requires a project-root .env.ignotum file.",
73
+ path: environmentPath,
74
+ });
75
+ }
76
+
77
+ const provider = yield* ConfigProvider.fromDotEnv({
78
+ path: environmentPath,
79
+ preserveEmptyStrings: true,
80
+ }).pipe(
81
+ Effect.mapError(() =>
82
+ EnvironmentConfigurationInvalid.make({
83
+ message: "Ignotum could not read the project environment file.",
84
+ path: environmentPath,
85
+ }),
86
+ ),
87
+ );
88
+ const keys = yield* collectKeys(provider).pipe(
89
+ Effect.mapError(() =>
90
+ EnvironmentConfigurationInvalid.make({
91
+ message: "Ignotum could not inspect the project environment file.",
92
+ path: environmentPath,
93
+ }),
94
+ ),
95
+ );
96
+ const entries = yield* Effect.forEach(keys, (key) =>
97
+ provider.load([key]).pipe(
98
+ Effect.map((node) => [key, nodeValue(node)] as const),
99
+ Effect.mapError(() =>
100
+ EnvironmentConfigurationInvalid.make({
101
+ message: `Ignotum could not read environment variable '${key}'.`,
102
+ path: environmentPath,
103
+ }),
104
+ ),
105
+ ),
106
+ );
107
+ const raw = Object.fromEntries(
108
+ entries.flatMap(([key, value]) => (value === undefined ? [] : [[key, value] as const])),
109
+ );
110
+ return { path: environmentPath, raw } satisfies RawEnvironment;
111
+ });
112
+
113
+ export const loadEnvironment = Effect.fn("Environment.load")(function* (
114
+ appDirectory: string,
115
+ definition: DefinedEnv,
116
+ hasDefinition: boolean,
117
+ ) {
118
+ const loaded = yield* readEnvironmentRaw(appDirectory, hasDefinition);
119
+ if (!hasDefinition) {
120
+ return {
121
+ definition: emptyEnv,
122
+ raw: {},
123
+ value: Object.freeze({}),
124
+ } satisfies LoadedEnvironment<typeof emptyEnv>;
125
+ }
126
+ const raw = loaded.raw;
127
+ const value = yield* decodeDefinedEnvironment(definition, raw).pipe(
128
+ Effect.mapError((error) =>
129
+ EnvironmentConfigurationInvalid.make({ message: error.message, path: loaded.path }),
130
+ ),
131
+ );
132
+ return { definition, raw, value } satisfies LoadedEnvironment;
133
+ });
@@ -12,7 +12,7 @@ import {
12
12
  type FileValue,
13
13
  } from "@ignotum/contracts/schema/file";
14
14
  import { IdGenerator } from "@ignotum/shared/id";
15
- import { Effect, Predicate } from "effect";
15
+ import { Effect, Predicate, Schema } from "effect";
16
16
 
17
17
  export const Files = {
18
18
  url: (file: FileValue): string => {
@@ -27,13 +27,19 @@ export const Files = {
27
27
  const formatByMime: Partial<Record<string, FileFormat>> = {};
28
28
  for (const format of fileFormats) formatByMime[fileMimeTypes[format]] = format;
29
29
 
30
+ const MutationObject = Schema.ObjectKeyword.check(
31
+ Schema.makeFilter((value) => Object.prototype.toString.call(value) === "[object Object]", {
32
+ message: "Mutation objects must contain ordinary fields; built-in collections are unsupported.",
33
+ }),
34
+ );
35
+
30
36
  const collectNativeFiles = (
31
37
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This is the recursive parser for generated mutation arguments and native File values.
32
38
  value: unknown,
33
- files: Array<File>,
39
+ files: Set<File>,
34
40
  ): void => {
35
41
  if (value instanceof File) {
36
- files.push(value);
42
+ files.add(value);
37
43
  return;
38
44
  }
39
45
  if (isFileValue(value) || Predicate.isDate(value)) return;
@@ -42,7 +48,9 @@ const collectNativeFiles = (
42
48
  return;
43
49
  }
44
50
  if (!Predicate.isObject(value)) return;
45
- for (const child of Object.values(value)) collectNativeFiles(child, files);
51
+ for (const child of Object.values(Schema.decodeSync(MutationObject)(value))) {
52
+ collectNativeFiles(child, files);
53
+ }
46
54
  };
47
55
 
48
56
  const replaceNativeFiles = (
@@ -72,36 +80,42 @@ export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArg
72
80
  input: unknown,
73
81
  ) {
74
82
  const ids = yield* IdGenerator;
75
- const native: File[] = [];
76
- collectNativeFiles(input, native);
77
- const unique = new Set(native);
78
- if (unique.size > fileLimits.filesPerMutation) {
79
- throw new Error(`A mutation may upload at most ${fileLimits.filesPerMutation} files.`);
80
- }
81
- const totalBytes = [...unique].reduce((total, file) => total + file.size, 0);
82
- if (totalBytes > fileLimits.mutationBytes) {
83
- throw new Error(`Mutation uploads may total at most ${fileLimits.mutationBytes} bytes.`);
84
- }
83
+ const unique = yield* Effect.try(() => {
84
+ const files = new Set<File>();
85
+ collectNativeFiles(input, files);
86
+ if (files.size > fileLimits.filesPerMutation) {
87
+ throw new Error(`A mutation may upload at most ${fileLimits.filesPerMutation} files.`);
88
+ }
89
+ const totalBytes = [...files].reduce((total, file) => total + file.size, 0);
90
+ if (totalBytes > fileLimits.mutationBytes) {
91
+ throw new Error(`Mutation uploads may total at most ${fileLimits.mutationBytes} bytes.`);
92
+ }
93
+ return files;
94
+ });
85
95
  const replacements = new Map<File, FileValue>();
86
96
  const nativeFiles = new Map<FileId, File>();
87
97
  for (const file of unique) {
88
- const format = formatByMime[file.type.toLowerCase()];
89
- if (format === undefined) {
90
- throw new Error(
91
- `File '${file.name}' has unsupported media type '${file.type || "unknown"}'.`,
92
- );
93
- }
94
- if (file.size === 0 || file.size > fileLimits.fileBytes) {
95
- throw new Error(`File '${file.name}' must be between 1 and ${fileLimits.fileBytes} bytes.`);
96
- }
97
- if (new TextEncoder().encode(file.name).byteLength > fileLimits.filenameBytes) {
98
- throw new Error(`File '${file.name}' has a filename that is too long.`);
99
- }
98
+ const format = yield* Effect.try(() => {
99
+ const format = formatByMime[file.type.toLowerCase()];
100
+ if (format === undefined) {
101
+ throw new Error(
102
+ `File '${file.name}' has unsupported media type '${file.type || "unknown"}'.`,
103
+ );
104
+ }
105
+ if (file.size === 0 || file.size > fileLimits.fileBytes) {
106
+ throw new Error(`File '${file.name}' must be between 1 and ${fileLimits.fileBytes} bytes.`);
107
+ }
108
+ if (new TextEncoder().encode(file.name).byteLength > fileLimits.filenameBytes) {
109
+ throw new Error(`File '${file.name}' has a filename that is too long.`);
110
+ }
111
+ return format;
112
+ });
100
113
  const id = yield* ids.generate(FileId);
101
114
  replacements.set(file, makeFileValue(id, { format, name: file.name, size: file.size }));
102
115
  nativeFiles.set(id, file);
103
116
  }
104
- const value = replaceNativeFiles(input, replacements);
117
+ const value =
118
+ unique.size === 0 ? input : yield* Effect.try(() => replaceNativeFiles(input, replacements));
105
119
  return {
106
120
  value,
107
121
  nativeFiles,
@@ -121,6 +135,8 @@ export const applyFileGrants = (
121
135
  grants: ReadonlyArray<FileGrant>,
122
136
  // oxlint-disable-next-line anti-slop/no-unknown-returns -- The traversal preserves the decoded query result shape while attaching private symbols.
123
137
  ): unknown => {
138
+ if (grants.length === 0) return value;
139
+
124
140
  const byId = new Map<FileId, string>();
125
141
  for (const grant of grants) {
126
142
  if (byId.has(grant.id)) throw new Error("A query file grant ID is duplicated.");