ignotum 0.0.8 → 0.0.10

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 (44) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/bin.mjs +295 -64
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-BTeMI5tx.js → api-DzcR7spt.js} +22 -23
  5. package/dist/runtime/api-DzcR7spt.js.map +1 -0
  6. package/dist/runtime/{api-D9lV-5av.d.ts → api-jCl8Hzry.d.ts} +3 -3
  7. package/dist/runtime/client.d.ts +5 -5
  8. package/dist/runtime/client.js +57 -55
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/{descriptor-C5VA9qRl-C338iC6l.js → descriptor-C5VA9qRl-BhD-WQeO.js} +4 -3
  11. package/dist/runtime/descriptor-C5VA9qRl-BhD-WQeO.js.map +1 -0
  12. package/dist/runtime/{file-BXf63ulU.js → file-C1abuMgd.js} +8 -1
  13. package/dist/runtime/file-C1abuMgd.js.map +1 -0
  14. package/dist/runtime/{id-Btwac71X-DGj0DQuu.d.ts → id-Cs82tq9Q-Caqfx54f.d.ts} +2 -2
  15. package/dist/runtime/{index-CF04_Dps.d.ts → index-D6VLTbDB.d.ts} +4 -4
  16. package/dist/runtime/internal/api.d.ts +1 -1
  17. package/dist/runtime/internal/api.js +1 -1
  18. package/dist/runtime/internal/host.d.ts +4 -4
  19. package/dist/runtime/internal/host.js +3 -3
  20. package/dist/runtime/internal/server.d.ts +1 -1
  21. package/dist/runtime/internal/server.js +1 -1
  22. package/dist/runtime/internal/types.d.ts +1 -1
  23. package/dist/runtime/internal/types.js +1 -1
  24. package/dist/runtime/{pagination-DrOowBve-Bnipd34u.d.ts → pagination-Bt3l7QaC-D_zI5zsu.d.ts} +3 -3
  25. package/dist/runtime/pagination-DcIkTOFs.d.ts +1 -0
  26. package/dist/runtime/{schema-DJfwfq87.js → schema-C00wobuy.js} +3 -3
  27. package/dist/runtime/{schema-DJfwfq87.js.map → schema-C00wobuy.js.map} +1 -1
  28. package/dist/runtime/server.d.ts +2 -2
  29. package/dist/runtime/server.js +2 -2
  30. package/dist/runtime/{sync-mIXn9eKv.d.ts → sync-bMQq9tXx.d.ts} +2 -2
  31. package/package.json +4 -2
  32. package/src/cli/auth-client.ts +286 -0
  33. package/src/cli/command.ts +65 -2
  34. package/src/cli/control-client.ts +19 -6
  35. package/src/cli/new-app.ts +2 -1
  36. package/src/client/errors.ts +7 -10
  37. package/src/client/files.ts +36 -51
  38. package/src/client/sync.ts +35 -23
  39. package/src/dev-runtime/files.ts +30 -31
  40. package/src/dev-runtime/sync.ts +19 -25
  41. package/dist/runtime/api-BTeMI5tx.js.map +0 -1
  42. package/dist/runtime/descriptor-C5VA9qRl-C338iC6l.js.map +0 -1
  43. package/dist/runtime/file-BXf63ulU.js.map +0 -1
  44. package/dist/runtime/pagination-BNFhAjns.d.ts +0 -1
@@ -6,6 +6,12 @@ import { IdGenerator } from "@ignotum/shared/id";
6
6
 
7
7
  import packageJson from "../../package.json" with { type: "json" };
8
8
  import { generate } from "./codegen.js";
9
+ import {
10
+ AuthCredentialStore,
11
+ login as runLogin,
12
+ logout as runLogout,
13
+ status as readAuthStatus,
14
+ } from "./auth-client.js";
9
15
  import { ControlClient } from "./control-client.js";
10
16
  import { deploy as runDeploy } from "./deploy.js";
11
17
  import { resetDevDatabase } from "../dev-runtime/dev-database.js";
@@ -15,6 +21,58 @@ import { installDependencies } from "./package-manager.js";
15
21
 
16
22
  const DevPort = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 }));
17
23
 
24
+ const authLogin = Command.make(
25
+ "login",
26
+ {},
27
+ Effect.fn("auth login")(function* () {
28
+ const terminal = yield* Terminal.Terminal;
29
+ const result = yield* runLogin().pipe(
30
+ Effect.mapError((error) =>
31
+ CliError.UserError.make({ cause: error, userMessage: error.message }),
32
+ ),
33
+ );
34
+ yield* terminal.display(`Signed in as ${result.email}.\n`);
35
+ }),
36
+ ).pipe(Command.withDescription("Sign in to Ignotum with GitHub."));
37
+
38
+ const authLogout = Command.make(
39
+ "logout",
40
+ {},
41
+ Effect.fn("auth logout")(function* () {
42
+ const terminal = yield* Terminal.Terminal;
43
+ const removed = yield* runLogout().pipe(
44
+ Effect.mapError((error) =>
45
+ CliError.UserError.make({ cause: error, userMessage: error.message }),
46
+ ),
47
+ );
48
+ yield* terminal.display(removed ? "Signed out.\n" : "Not signed in.\n");
49
+ }),
50
+ ).pipe(Command.withDescription("Revoke and remove the saved login."));
51
+
52
+ const authStatus = Command.make(
53
+ "status",
54
+ {},
55
+ Effect.fn("auth status")(function* () {
56
+ const terminal = yield* Terminal.Terminal;
57
+ const current = yield* readAuthStatus().pipe(
58
+ Effect.mapError((error) =>
59
+ CliError.UserError.make({ cause: error, userMessage: error.message }),
60
+ ),
61
+ );
62
+ if (current === undefined) {
63
+ yield* terminal.display("Not signed in.\n");
64
+ return;
65
+ }
66
+ const team = current.teamName === undefined ? "" : `\nTeam: ${current.teamName}`;
67
+ yield* terminal.display(`Signed in as ${current.name} (${current.email}).${team}\n`);
68
+ }),
69
+ ).pipe(Command.withDescription("Show the current Ignotum login."));
70
+
71
+ const auth = Command.make("auth").pipe(
72
+ Command.withDescription("Manage your Ignotum login."),
73
+ Command.withSubcommands([authLogin, authLogout, authStatus]),
74
+ );
75
+
18
76
  const codegen = Command.make(
19
77
  "codegen",
20
78
  {},
@@ -172,7 +230,7 @@ const newApp = Command.make(
172
230
  );
173
231
 
174
232
  const ignotum = Command.make("ignotum").pipe(
175
- Command.withSubcommands([newApp, install, codegen, dev, deploy]),
233
+ Command.withSubcommands([newApp, install, codegen, dev, deploy, auth]),
176
234
  );
177
235
 
178
236
  export const main = (): void => {
@@ -180,7 +238,12 @@ export const main = (): void => {
180
238
  Command.run({ version: packageJson.version }),
181
239
  // @effect-diagnostics-next-line strictEffectProvide:off
182
240
  Effect.provide(
183
- Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, IdGenerator.layer),
241
+ Layer.mergeAll(
242
+ NodeServices.layer,
243
+ NodeHttpClient.layerUndici,
244
+ IdGenerator.layer,
245
+ AuthCredentialStore.layer.pipe(Layer.provide(NodeServices.layer)),
246
+ ),
184
247
  ),
185
248
  NodeRuntime.runMain,
186
249
  );
@@ -41,6 +41,7 @@ import {
41
41
  } from "effect";
42
42
  import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
43
43
  import type { HttpClientRequest as HttpRequest } from "effect/unstable/http/HttpClientRequest";
44
+ import { AuthCommandError, AuthCredentialStore } from "./auth-client.js";
44
45
 
45
46
  export interface HostedControlConfig {
46
47
  readonly apiUrl: URL;
@@ -69,11 +70,19 @@ export class HostedControlConfiguration extends Context.Service<
69
70
  >()("ignotum/cli/control-client/HostedControlConfiguration") {
70
71
  static readonly layer = Layer.effect(
71
72
  HostedControlConfiguration,
72
- Config.all({
73
- apiUrl: Config.schema(HostedApiUrl, "IGNOTUM_API_URL").pipe(
74
- Config.withDefault(new URL("https://api.ignotum.cloud")),
75
- ),
76
- token: Config.redacted("IGNOTUM_API_TOKEN"),
73
+ Effect.gen(function* () {
74
+ const credential = yield* (yield* AuthCredentialStore).load;
75
+ if (credential === undefined) {
76
+ return yield* AuthCommandError.make({
77
+ message: "Run 'npx ignotum auth login' first.",
78
+ });
79
+ }
80
+ return {
81
+ apiUrl: yield* Config.schema(HostedApiUrl, "IGNOTUM_API_URL").pipe(
82
+ Config.withDefault(new URL("https://api.ignotum.cloud")),
83
+ ),
84
+ token: credential.token,
85
+ };
77
86
  }),
78
87
  );
79
88
  }
@@ -133,7 +142,11 @@ export class ControlClient extends Context.Service<ControlClient, ControlClientS
133
142
  "ignotum/cli/control-client/ControlClient",
134
143
  ) {
135
144
  static get layer() {
136
- return controlClientLayer.pipe(Layer.provideMerge(HostedControlConfiguration.layer));
145
+ return controlClientLayer.pipe(
146
+ Layer.provideMerge(
147
+ HostedControlConfiguration.layer.pipe(Layer.provide(AuthCredentialStore.layer)),
148
+ ),
149
+ );
137
150
  }
138
151
  }
139
152
 
@@ -209,9 +209,10 @@ and updates them when the schema or server functions change.
209
209
 
210
210
  ## Deploy
211
211
 
212
- Set \`IGNOTUM_API_TOKEN\`, then choose a unique slug on the first deployment:
212
+ Sign in, then choose a unique slug on the first deployment:
213
213
 
214
214
  \`\`\`sh
215
+ npx ignotum auth login
215
216
  npx ignotum deploy --app my-app
216
217
  \`\`\`
217
218
 
@@ -1,7 +1,7 @@
1
1
  /** @effect-diagnostics globalConsole:skip-file */
2
2
  import { Cause, Effect, ErrorReporter, Schema } from "effect";
3
3
 
4
- import { FunctionAddress, Operation, ProtocolErrorCode } from "@ignotum/contracts/runtime/sync";
4
+ import { ErrorCode, FunctionAddress, Operation } from "@ignotum/contracts/runtime/sync";
5
5
 
6
6
  export class ConnectionUnavailable extends Schema.TaggedError<ConnectionUnavailable>()(
7
7
  "ConnectionUnavailable",
@@ -36,21 +36,18 @@ export class InvalidServerMessage extends Schema.TaggedError<InvalidServerMessag
36
36
  },
37
37
  ) {}
38
38
 
39
- export class ServerProtocolError extends Schema.TaggedError<ServerProtocolError>()(
40
- "ServerProtocolError",
41
- {
42
- code: ProtocolErrorCode,
43
- message: Schema.String,
44
- operation: Schema.optional(Operation),
45
- },
46
- ) {}
39
+ export class ServerError extends Schema.TaggedError<ServerError>()("ServerError", {
40
+ code: ErrorCode,
41
+ message: Schema.String,
42
+ operation: Schema.optional(Operation),
43
+ }) {}
47
44
 
48
45
  export const ClientInfrastructureError = Schema.Union([
49
46
  ConnectionUnavailable,
50
47
  InvalidClientMessage,
51
48
  InvalidMutationArguments,
52
49
  InvalidServerMessage,
53
- ServerProtocolError,
50
+ ServerError,
54
51
  ]);
55
52
  export type ClientInfrastructureError = typeof ClientInfrastructureError.Type;
56
53
 
@@ -10,7 +10,6 @@ import {
10
10
  makeFileValue,
11
11
  type FileFormat,
12
12
  type FileValue,
13
- type RuntimeFileOccurrence,
14
13
  } from "@ignotum/contracts/schema/file";
15
14
  import { IdGenerator } from "@ignotum/shared/id";
16
15
  import { Effect, Predicate } from "effect";
@@ -28,30 +27,22 @@ export const Files = {
28
27
  const formatByMime: Partial<Record<string, FileFormat>> = {};
29
28
  for (const format of fileFormats) formatByMime[fileMimeTypes[format]] = format;
30
29
 
31
- interface NativeOccurrence {
32
- readonly path: ReadonlyArray<string | number>;
33
- readonly value: File;
34
- }
35
-
36
30
  const collectNativeFiles = (
37
31
  // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This is the recursive parser for generated mutation arguments and native File values.
38
32
  value: unknown,
39
- path: ReadonlyArray<string | number>,
40
- occurrences: Array<NativeOccurrence>,
33
+ files: Array<File>,
41
34
  ): void => {
42
35
  if (value instanceof File) {
43
- occurrences.push({ path, value });
36
+ files.push(value);
44
37
  return;
45
38
  }
46
39
  if (isFileValue(value) || Predicate.isDate(value)) return;
47
40
  if (globalThis.Array.isArray(value)) {
48
- for (const [index, child] of value.entries())
49
- collectNativeFiles(child, [...path, index], occurrences);
41
+ for (const child of value) collectNativeFiles(child, files);
50
42
  return;
51
43
  }
52
44
  if (!Predicate.isObject(value)) return;
53
- for (const [key, child] of Object.entries(value))
54
- collectNativeFiles(child, [...path, key], occurrences);
45
+ for (const child of Object.values(value)) collectNativeFiles(child, files);
55
46
  };
56
47
 
57
48
  const replaceNativeFiles = (
@@ -73,7 +64,7 @@ const replaceNativeFiles = (
73
64
  export interface PreparedMutationArguments {
74
65
  readonly value: unknown;
75
66
  readonly nativeFiles: ReadonlyMap<FileId, File>;
76
- readonly occurrences: ReadonlyArray<RuntimeFileOccurrence>;
67
+ readonly fileIds: ReadonlyArray<FileId>;
77
68
  }
78
69
 
79
70
  export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArguments")(function* (
@@ -81,9 +72,9 @@ export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArg
81
72
  input: unknown,
82
73
  ) {
83
74
  const ids = yield* IdGenerator;
84
- const native: NativeOccurrence[] = [];
85
- collectNativeFiles(input, [], native);
86
- const unique = new Set(native.map(({ value }) => value));
75
+ const native: File[] = [];
76
+ collectNativeFiles(input, native);
77
+ const unique = new Set(native);
87
78
  if (unique.size > fileLimits.filesPerMutation) {
88
79
  throw new Error(`A mutation may upload at most ${fileLimits.filesPerMutation} files.`);
89
80
  }
@@ -111,58 +102,52 @@ export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArg
111
102
  nativeFiles.set(id, file);
112
103
  }
113
104
  const value = replaceNativeFiles(input, replacements);
114
- const occurrences = native.map(({ path, value: file }) => {
115
- const replacement = replacements.get(file);
116
- if (replacement === undefined) throw new Error("A native file replacement is missing.");
117
- return {
118
- path,
119
- file: {
120
- id: fileIdOf(replacement),
121
- format: replacement.format,
122
- name: replacement.name,
123
- size: replacement.size,
124
- },
125
- } satisfies RuntimeFileOccurrence;
126
- });
127
- return { value, nativeFiles, occurrences } satisfies PreparedMutationArguments;
105
+ return {
106
+ value,
107
+ nativeFiles,
108
+ fileIds: Array.from(nativeFiles.keys()),
109
+ } satisfies PreparedMutationArguments;
128
110
  });
129
111
 
130
- interface FileGrantOccurrence extends RuntimeFileOccurrence {
112
+ interface FileGrant {
113
+ readonly id: FileId;
131
114
  readonly url: string;
132
115
  }
133
116
 
134
- const pathKey = (path: ReadonlyArray<string | number>): string => JSON.stringify(path);
135
-
136
117
  // @effect-diagnostics-next-line missingPipeableSignature:off File grant attachment naturally takes the decoded value first.
137
118
  export const applyFileGrants = (
138
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Query result types are erased at the wire decoding boundary and checked against grant paths below.
119
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Query result types are erased at the wire decoding boundary and checked against grant identities below.
139
120
  value: unknown,
140
- grants: ReadonlyArray<FileGrantOccurrence>,
121
+ grants: ReadonlyArray<FileGrant>,
141
122
  // oxlint-disable-next-line anti-slop/no-unknown-returns -- The traversal preserves the decoded query result shape while attaching private symbols.
142
123
  ): unknown => {
143
- const byPath = new Map(grants.map((grant) => [pathKey(grant.path), grant] as const));
124
+ const byId = new Map<FileId, string>();
125
+ for (const grant of grants) {
126
+ if (byId.has(grant.id)) throw new Error("A query file grant ID is duplicated.");
127
+ byId.set(grant.id, grant.url);
128
+ }
129
+ const matched = new Set<FileId>();
144
130
  const visit = (
145
- // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This recursive visitor validates a grant and file ID before replacement.
131
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This recursive visitor identifies file values before attaching grants.
146
132
  current: unknown,
147
- path: ReadonlyArray<string | number>,
148
133
  // oxlint-disable-next-line anti-slop/no-unknown-returns -- Recursive branches preserve their input shape.
149
134
  ): unknown => {
150
- const grant = byPath.get(pathKey(path));
151
- if (grant !== undefined) {
152
- if (!isFileValue(current) || fileIdOf(current) !== grant.file.id) {
153
- throw new Error("A query file grant does not match its result value.");
154
- }
155
- return grantFileValue(current, grant.url);
135
+ if (isFileValue(current)) {
136
+ const id = fileIdOf(current);
137
+ const url = byId.get(id);
138
+ if (url === undefined) return current;
139
+ matched.add(id);
140
+ return grantFileValue(current, url);
156
141
  }
157
142
  if (globalThis.Array.isArray(current)) {
158
- return current.map((child, index) => visit(child, [...path, index]));
143
+ return current.map(visit);
159
144
  }
160
- if (!Predicate.isObject(current) || Predicate.isDate(current) || isFileValue(current)) {
145
+ if (!Predicate.isObject(current) || Predicate.isDate(current)) {
161
146
  return current;
162
147
  }
163
- return Object.fromEntries(
164
- Object.entries(current).map(([key, child]) => [key, visit(child, [...path, key])]),
165
- );
148
+ return Object.fromEntries(Object.entries(current).map(([key, child]) => [key, visit(child)]));
166
149
  };
167
- return visit(value, []);
150
+ const granted = visit(value);
151
+ if (matched.size !== byId.size) throw new Error("A query file grant has no result value.");
152
+ return granted;
168
153
  };
@@ -26,7 +26,7 @@ import type {
26
26
  import { failureFromWire, pending, Result } from "@ignotum/contracts/runtime/result";
27
27
  import {
28
28
  ClientMessageJson,
29
- DeploymentChangedCloseCode,
29
+ DeploymentCloseCode,
30
30
  InvocationId,
31
31
  ServerMessageJson,
32
32
  SubscriptionId,
@@ -42,7 +42,7 @@ import {
42
42
  ConnectionUnavailable,
43
43
  InvalidClientMessage,
44
44
  InvalidServerMessage,
45
- ServerProtocolError,
45
+ ServerError,
46
46
  InvalidMutationArguments,
47
47
  clientErrorReporterLayer,
48
48
  reportClientError,
@@ -213,8 +213,7 @@ const resultFromWire = Effect.fn("SyncClient.resultFromWire")(
213
213
  ),
214
214
  );
215
215
 
216
- export const shouldReloadForCloseCode = (code: number): boolean =>
217
- code === DeploymentChangedCloseCode;
216
+ export const shouldReloadForCloseCode = (code: number): boolean => code === DeploymentCloseCode;
218
217
 
219
218
  const handshakesMatch = (first: SyncHandshake, next: SyncHandshake): boolean =>
220
219
  first.appId === next.appId &&
@@ -265,9 +264,9 @@ interface PendingInvocation {
265
264
  ClientInfrastructureError
266
265
  >;
267
266
  readonly function: FunctionAddress;
268
- readonly message: Extract<ClientMessage, { readonly type: "Invoke" | "PrepareMutation" }>;
267
+ readonly message: Extract<ClientMessage, { readonly type: "Invoke" | "Prepare" }>;
269
268
  readonly prepared: Deferred.Deferred<
270
- Extract<ServerMessage, { readonly type: "MutationPrepared" }>["uploads"],
269
+ Extract<ServerMessage, { readonly type: "Preparation" }>["uploads"],
271
270
  ClientInfrastructureError
272
271
  >;
273
272
  }
@@ -339,7 +338,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
339
338
  };
340
339
 
341
340
  const handleMessage = Effect.fn("SyncClient.handleMessage")(function* (
342
- message: Exclude<ServerMessage, SyncHandshake | { readonly type: "DeploymentChanged" }>,
341
+ message: Exclude<ServerMessage, SyncHandshake | { readonly type: "Deployment" }>,
343
342
  ) {
344
343
  switch (message.type) {
345
344
  case "Snapshot": {
@@ -352,7 +351,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
352
351
  );
353
352
  return;
354
353
  }
355
- case "MutationPrepared": {
354
+ case "Preparation": {
356
355
  const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
357
356
  if (pendingInvocation !== undefined) {
358
357
  yield* Deferred.succeed(pendingInvocation.prepared, message.uploads);
@@ -368,14 +367,14 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
368
367
  );
369
368
  return;
370
369
  }
371
- case "ProtocolError": {
370
+ case "Error": {
372
371
  const error =
373
372
  message.operation === undefined
374
- ? ServerProtocolError.make({
373
+ ? ServerError.make({
375
374
  code: message.code,
376
375
  message: message.message,
377
376
  })
378
- : ServerProtocolError.make({
377
+ : ServerError.make({
379
378
  code: message.code,
380
379
  message: message.message,
381
380
  operation: message.operation,
@@ -449,7 +448,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
449
448
  ),
450
449
  );
451
450
  if (message.type === "Handshake") return yield* acceptHandshake(message);
452
- if (message.type === "DeploymentChanged") {
451
+ if (message.type === "Deployment") {
453
452
  requestReload();
454
453
  return;
455
454
  }
@@ -534,7 +533,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
534
533
  ClientInfrastructureError
535
534
  >();
536
535
  const prepared = yield* Deferred.make<
537
- Extract<ServerMessage, { readonly type: "MutationPrepared" }>["uploads"],
536
+ Extract<ServerMessage, { readonly type: "Preparation" }>["uploads"],
538
537
  ClientInfrastructureError
539
538
  >();
540
539
  const invokeMessage: Extract<ClientMessage, { readonly type: "Invoke" }> = {
@@ -545,15 +544,15 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
545
544
  args: jsonArgs,
546
545
  };
547
546
  const message: PendingInvocation["message"] =
548
- preparedArguments.occurrences.length === 0
547
+ preparedArguments.fileIds.length === 0
549
548
  ? invokeMessage
550
549
  : {
551
- type: "PrepareMutation",
550
+ type: "Prepare",
552
551
  id: invocationId,
553
552
  kind: "Mutation",
554
553
  function: functionAddress,
555
554
  args: jsonArgs,
556
- files: preparedArguments.occurrences,
555
+ files: preparedArguments.fileIds,
557
556
  };
558
557
  invocations = HashMap.set(invocations, invocationId, {
559
558
  deferred,
@@ -570,20 +569,27 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
570
569
  }),
571
570
  );
572
571
  }
573
- if (message.type === "PrepareMutation") {
572
+ if (message.type === "Prepare") {
574
573
  const uploads = yield* Deferred.await(prepared);
575
- const uploaded = new Set<string>();
574
+ const expected = new Set(message.files);
575
+ const received = new Set<string>();
576
576
  for (const upload of uploads) {
577
- if (upload.url === undefined || uploaded.has(upload.file.id)) continue;
578
- const uploadUrl = upload.url;
579
- uploaded.add(upload.file.id);
580
- const file = preparedArguments.nativeFiles.get(upload.file.id);
577
+ if (!expected.has(upload.id) || received.has(upload.id)) {
578
+ return yield* InvalidServerMessage.make({
579
+ cause: new Error(`Unexpected prepared file '${upload.id}'.`),
580
+ message: "The server returned invalid application file preparation data.",
581
+ });
582
+ }
583
+ received.add(upload.id);
584
+ const file = preparedArguments.nativeFiles.get(upload.id);
581
585
  if (file === undefined) {
582
586
  return yield* InvalidServerMessage.make({
583
- cause: new Error(`Missing native file '${upload.file.id}'.`),
587
+ cause: new Error(`Missing native file '${upload.id}'.`),
584
588
  message: "The server prepared an unknown application file.",
585
589
  });
586
590
  }
591
+ if (upload.url === undefined) continue;
592
+ const uploadUrl = upload.url;
587
593
  const response = yield* Effect.tryPromise({
588
594
  try: () =>
589
595
  fetch(uploadUrl, {
@@ -606,6 +612,12 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
606
612
  });
607
613
  }
608
614
  }
615
+ if (received.size !== expected.size) {
616
+ return yield* InvalidServerMessage.make({
617
+ cause: new Error("The server omitted a prepared file."),
618
+ message: "The server returned incomplete application file preparation data.",
619
+ });
620
+ }
609
621
  const pendingInvocation = HashMap.getUnsafe(invocations, invocationId);
610
622
  if (pendingInvocation === undefined) return yield* Deferred.await(deferred);
611
623
  invocations = HashMap.set(invocations, invocationId, {
@@ -45,8 +45,8 @@ interface LocalApplicationFilesService {
45
45
  invocationId: InvocationId,
46
46
  functionAddress: FunctionAddress,
47
47
  args: Schema.Json,
48
- files: ReadonlyArray<RuntimeFileOccurrenceValue>,
49
- ) => Effect.Effect<ReadonlyArray<RuntimeFileOccurrenceValue & { readonly url?: string }>>;
48
+ fileIds: ReadonlyArray<FileId>,
49
+ ) => Effect.Effect<ReadonlyArray<{ readonly id: FileId; readonly url?: string }>>;
50
50
  readonly admit: (
51
51
  invocationId: InvocationId,
52
52
  functionAddress: FunctionAddress,
@@ -56,7 +56,7 @@ interface LocalApplicationFilesService {
56
56
  readonly grants: (
57
57
  subscriptionId: SubscriptionId,
58
58
  files: ReadonlyArray<RuntimeFileOccurrenceValue>,
59
- ) => Effect.Effect<ReadonlyArray<RuntimeFileOccurrenceValue & { readonly url: string }>>;
59
+ ) => Effect.Effect<ReadonlyArray<{ readonly id: FileId; readonly url: string }>>;
60
60
  readonly releaseGrants: (subscriptionId: SubscriptionId) => Effect.Effect<void>;
61
61
  readonly readGrant: (
62
62
  token: FileGrantToken,
@@ -74,8 +74,11 @@ export const LocalApplicationFiles = Context.Reference<LocalApplicationFilesServ
74
74
  admit: (_invocationId, _functionAddress, args) =>
75
75
  Effect.succeed(encodedFileOccurrencesOf(args)),
76
76
  grants: (_subscriptionId, files) =>
77
- Effect.succeed(files.map((file) => ({ ...file, url: "" }))),
78
- prepare: (_invocationId, _functionAddress, _args, files) => Effect.succeed(files),
77
+ Effect.succeed(
78
+ Array.from(new Set(files.map(({ file }) => file.id)), (id) => ({ id, url: "" })),
79
+ ),
80
+ prepare: (_invocationId, _functionAddress, _args, fileIds) =>
81
+ Effect.succeed(fileIds.map((id) => ({ id }))),
79
82
  upload: () => Effect.die("Local application file storage is unavailable."),
80
83
  releaseGrants: () => Effect.void,
81
84
  readGrant: () => Effect.succeed(Option.none()),
@@ -125,8 +128,19 @@ export const localApplicationFilesLayer = (appDirectory: string) =>
125
128
  invocationId: InvocationId,
126
129
  functionAddress: FunctionAddress,
127
130
  args: Schema.Json,
128
- files: ReadonlyArray<RuntimeFileOccurrenceValue>,
131
+ fileIds: ReadonlyArray<FileId>,
129
132
  ) {
133
+ if (fileIds.length === 0) throw new Error("A file preparation must contain a file ID.");
134
+ const selected = new Set(fileIds);
135
+ if (selected.size !== fileIds.length) {
136
+ throw new Error("A file preparation contains a duplicate file ID.");
137
+ }
138
+ const files = encodedFileOccurrencesOf(args).filter((occurrence) =>
139
+ selected.has(occurrence.file.id),
140
+ );
141
+ if (new Set(files.map((occurrence) => occurrence.file.id)).size !== selected.size) {
142
+ throw new Error("Prepared files do not match the mutation arguments.");
143
+ }
130
144
  const existing = preparations.get(invocationId);
131
145
  if (existing !== undefined) {
132
146
  if (
@@ -136,27 +150,14 @@ export const localApplicationFilesLayer = (appDirectory: string) =>
136
150
  ) {
137
151
  throw new Error("The invocation ID already belongs to another file preparation.");
138
152
  }
139
- return files.map((occurrence) => {
140
- const token = existing.uploads.get(occurrence.file.id);
141
- return token === undefined
142
- ? occurrence
143
- : { ...occurrence, url: `${fileUploadUrlPrefix}${token}` };
153
+ return fileIds.map((id) => {
154
+ const token = existing.uploads.get(id);
155
+ return token === undefined ? { id } : { id, url: `${fileUploadUrlPrefix}${token}` };
144
156
  });
145
157
  }
146
- const all = new Map(
147
- encodedFileOccurrencesOf(args).map((occurrence) => [
148
- encodeCanonicalJson(occurrence.path),
149
- occurrence.file,
150
- ]),
151
- );
152
158
  const distinct = new Map<FileId, RuntimeFileMetadata>();
153
159
  for (const occurrence of files) {
154
- const arg = all.get(encodeCanonicalJson(occurrence.path));
155
- if (
156
- arg === undefined ||
157
- !sameFile(arg, occurrence.file) ||
158
- !validFilename(occurrence.file.name)
159
- ) {
160
+ if (!validFilename(occurrence.file.name)) {
160
161
  throw new Error("Prepared files do not match the mutation arguments.");
161
162
  }
162
163
  const prior = distinct.get(occurrence.file.id);
@@ -214,11 +215,9 @@ export const localApplicationFilesLayer = (appDirectory: string) =>
214
215
  `;
215
216
  }
216
217
  preparations.set(invocationId, { function: functionAddress, args, files, uploads });
217
- return files.map((occurrence) => {
218
- const token = uploads.get(occurrence.file.id);
219
- return token === undefined
220
- ? occurrence
221
- : { ...occurrence, url: `${fileUploadUrlPrefix}${token}` };
218
+ return fileIds.map((id) => {
219
+ const token = uploads.get(id);
220
+ return token === undefined ? { id } : { id, url: `${fileUploadUrlPrefix}${token}` };
222
221
  });
223
222
  });
224
223
 
@@ -279,9 +278,9 @@ export const localApplicationFilesLayer = (appDirectory: string) =>
279
278
  tokens.set(occurrence.file.id, token);
280
279
  grants.set(token, { subscriptionId, file: occurrence.file });
281
280
  }
282
- return occurrences.map((occurrence) => ({
283
- ...occurrence,
284
- url: `${fileGrantUrlPrefix}${tokens.get(occurrence.file.id)}`,
281
+ return Array.from(tokens, ([id, token]) => ({
282
+ id,
283
+ url: `${fileGrantUrlPrefix}${token}`,
285
284
  }));
286
285
  });
287
286