ignotum 0.0.8 → 0.0.9

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 (43) hide show
  1. package/README.md +6 -2
  2. package/dist/cli/bin.mjs +294 -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-BKbdenuU.js} +2 -2
  11. package/dist/runtime/{descriptor-C5VA9qRl-C338iC6l.js.map → descriptor-C5VA9qRl-BKbdenuU.js.map} +1 -1
  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-B6PK_ZwV.js} +3 -3
  27. package/dist/runtime/{schema-DJfwfq87.js.map → schema-B6PK_ZwV.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/file-BXf63ulU.js.map +0 -1
  43. package/dist/runtime/pagination-BNFhAjns.d.ts +0 -1
@@ -0,0 +1,286 @@
1
+ import { homedir } from "node:os";
2
+ import process from "node:process";
3
+
4
+ import * as Versioned from "@ignotum/contracts/versioned";
5
+ import { createAuthClient } from "better-auth/client";
6
+ import { deviceAuthorizationClient, organizationClient } from "better-auth/client/plugins";
7
+ import {
8
+ Config,
9
+ Context,
10
+ Effect,
11
+ FileSystem,
12
+ Layer,
13
+ Option,
14
+ Path,
15
+ Redacted,
16
+ Schema,
17
+ Terminal,
18
+ } from "effect";
19
+
20
+ const CredentialV1 = Schema.Struct({
21
+ formatVersion: Schema.Literal(1),
22
+ authUrl: Schema.URLFromString,
23
+ token: Schema.String,
24
+ });
25
+ const CredentialThroughV1 = Versioned.initial(CredentialV1);
26
+ const Credential = CredentialThroughV1;
27
+ type Credential = typeof Credential.Type;
28
+
29
+ const AuthUrl = Schema.URL.check(
30
+ Schema.makeFilter(
31
+ (url) =>
32
+ url.protocol === "https:" ||
33
+ (url.protocol === "http:" && ["127.0.0.1", "::1", "localhost"].includes(url.hostname)),
34
+ { message: "The auth URL must use HTTPS unless it targets localhost." },
35
+ ),
36
+ );
37
+
38
+ export class AuthCommandError extends Schema.TaggedError<AuthCommandError>()("AuthCommandError", {
39
+ cause: Schema.optional(Schema.Defect()),
40
+ message: Schema.String,
41
+ }) {}
42
+
43
+ const commandError = (message: string, cause?: unknown) =>
44
+ AuthCommandError.make(cause === undefined ? { message } : { cause, message });
45
+
46
+ interface StoredCredential {
47
+ readonly authUrl: URL;
48
+ readonly token: Redacted.Redacted<string>;
49
+ }
50
+
51
+ interface AuthCredentialStoreService {
52
+ readonly load: Effect.Effect<StoredCredential | undefined, AuthCommandError>;
53
+ readonly remove: Effect.Effect<boolean, AuthCommandError>;
54
+ readonly save: (credential: StoredCredential) => Effect.Effect<void, AuthCommandError>;
55
+ }
56
+
57
+ export class AuthCredentialStore extends Context.Service<
58
+ AuthCredentialStore,
59
+ AuthCredentialStoreService
60
+ >()("ignotum/cli/auth-client/AuthCredentialStore") {
61
+ static readonly layer = Layer.effect(
62
+ AuthCredentialStore,
63
+ Effect.gen(function* () {
64
+ const fileSystem = yield* FileSystem.FileSystem;
65
+ const path = yield* Path.Path;
66
+ const configured = yield* Config.option(Config.string("IGNOTUM_CONFIG_DIR"));
67
+ const xdg = yield* Config.option(Config.string("XDG_CONFIG_HOME"));
68
+ const appData = yield* Config.option(Config.string("APPDATA"));
69
+ const configurationDirectory = Option.getOrElse(configured, () =>
70
+ Option.getOrElse(xdg, () =>
71
+ process.platform === "win32" && Option.isSome(appData)
72
+ ? appData.value
73
+ : path.join(homedir(), ".config"),
74
+ ),
75
+ );
76
+ const directory = path.join(configurationDirectory, "ignotum");
77
+ const credentialPath = path.join(directory, "auth.json");
78
+
79
+ const load = Effect.fn("AuthCredentialStore.load")(function* () {
80
+ if (
81
+ !(yield* fileSystem
82
+ .exists(credentialPath)
83
+ .pipe(
84
+ Effect.mapError((cause) => commandError("Could not check the saved login.", cause)),
85
+ ))
86
+ ) {
87
+ return undefined;
88
+ }
89
+ const text = yield* fileSystem
90
+ .readFileString(credentialPath)
91
+ .pipe(Effect.mapError((cause) => commandError("Could not read the saved login.", cause)));
92
+ const credential = yield* Schema.decodeEffect(Schema.fromJsonString(Credential))(text).pipe(
93
+ Effect.mapError((cause) => commandError("The saved login is invalid.", cause)),
94
+ );
95
+ return {
96
+ authUrl: credential.authUrl,
97
+ token: Redacted.make(credential.token),
98
+ } satisfies StoredCredential;
99
+ });
100
+
101
+ const save = Effect.fn("AuthCredentialStore.save")(function* (credential: StoredCredential) {
102
+ const value = {
103
+ formatVersion: 1,
104
+ authUrl: credential.authUrl,
105
+ token: Redacted.value(credential.token),
106
+ } satisfies Credential;
107
+ const encoded = yield* Schema.encodeEffect(Schema.fromJsonString(Credential))(value).pipe(
108
+ Effect.mapError((cause) => commandError("Could not encode the login.", cause)),
109
+ );
110
+ yield* fileSystem
111
+ .makeDirectory(directory, { mode: 0o700, recursive: true })
112
+ .pipe(
113
+ Effect.mapError((cause) =>
114
+ commandError("Could not create the config directory.", cause),
115
+ ),
116
+ );
117
+ yield* Effect.scoped(
118
+ Effect.gen(function* () {
119
+ const temporaryPath = yield* fileSystem.makeTempFileScoped({
120
+ directory,
121
+ prefix: ".auth-",
122
+ suffix: ".json",
123
+ });
124
+ yield* fileSystem.writeFileString(temporaryPath, `${encoded}\n`, { mode: 0o600 });
125
+ yield* fileSystem.rename(temporaryPath, credentialPath);
126
+ }),
127
+ ).pipe(Effect.mapError((cause) => commandError("Could not save the login.", cause)));
128
+ });
129
+
130
+ const remove = Effect.fn("AuthCredentialStore.remove")(function* () {
131
+ if (
132
+ !(yield* fileSystem
133
+ .exists(credentialPath)
134
+ .pipe(
135
+ Effect.mapError((cause) => commandError("Could not check the saved login.", cause)),
136
+ ))
137
+ ) {
138
+ return false;
139
+ }
140
+ yield* fileSystem
141
+ .remove(credentialPath)
142
+ .pipe(
143
+ Effect.mapError((cause) => commandError("Could not remove the saved login.", cause)),
144
+ );
145
+ return true;
146
+ });
147
+
148
+ return AuthCredentialStore.of({ load: load(), remove: remove(), save });
149
+ }),
150
+ );
151
+ }
152
+
153
+ const makeClient = (authUrl: URL) =>
154
+ createAuthClient({
155
+ basePath: "/v1",
156
+ baseURL: authUrl.href,
157
+ plugins: [deviceAuthorizationClient(), organizationClient()],
158
+ });
159
+
160
+ const authorization = (token: Redacted.Redacted<string>) => ({
161
+ Authorization: `Bearer ${Redacted.value(token)}`,
162
+ });
163
+
164
+ const responseError = (error: { readonly error_description?: string; readonly message?: string }) =>
165
+ error.error_description ?? error.message ?? "Authentication failed.";
166
+
167
+ export const login = Effect.fn("Auth.login")(function* () {
168
+ const store = yield* AuthCredentialStore;
169
+ const terminal = yield* Terminal.Terminal;
170
+ const authUrl = yield* Config.schema(AuthUrl, "IGNOTUM_AUTH_URL").pipe(
171
+ Config.withDefault(new URL("https://auth.ignotum.cloud")),
172
+ Effect.mapError((cause) => commandError("The auth URL is invalid.", cause)),
173
+ );
174
+ const client = makeClient(authUrl);
175
+ const existing = yield* store.load;
176
+ if (existing !== undefined && existing.authUrl.href === authUrl.href) {
177
+ const result = yield* Effect.tryPromise({
178
+ try: () => client.getSession({ fetchOptions: { headers: authorization(existing.token) } }),
179
+ catch: (cause) => commandError("Could not check the saved login.", cause),
180
+ });
181
+ if (result.data?.user !== undefined) {
182
+ return {
183
+ email: result.data.user.email,
184
+ verificationUrl: undefined,
185
+ };
186
+ }
187
+ }
188
+
189
+ const started = yield* Effect.tryPromise({
190
+ try: () => client.device.code({ client_id: "ignotum-cli", scope: "openid profile email" }),
191
+ catch: (cause) => commandError("Could not start login.", cause),
192
+ });
193
+ if (started.data === null) {
194
+ return yield* commandError(responseError(started.error));
195
+ }
196
+ const url = started.data.verification_uri_complete ?? started.data.verification_uri;
197
+ yield* terminal.display(
198
+ `Open ${started.data.verification_uri}\nCode: ${started.data.user_code}\n\nWaiting for approval...\n`,
199
+ );
200
+ yield* Effect.tryPromise({
201
+ // @effect-diagnostics-next-line asyncFunction:off Dynamic import keeps browser-only code out of other CLI commands.
202
+ try: async () => (await import("open")).default(url),
203
+ catch: (cause) => commandError("Could not open the browser.", cause),
204
+ }).pipe(Effect.ignore);
205
+
206
+ let interval = started.data.interval ?? 5;
207
+ while (true) {
208
+ yield* Effect.sleep(`${interval} seconds`);
209
+ const polled = yield* Effect.tryPromise({
210
+ try: () =>
211
+ client.device.token({
212
+ client_id: "ignotum-cli",
213
+ device_code: started.data.device_code,
214
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
215
+ }),
216
+ catch: (cause) => commandError("Login polling failed.", cause),
217
+ });
218
+ if (polled.data?.access_token !== undefined) {
219
+ const token = Redacted.make(polled.data.access_token);
220
+ const session = yield* Effect.tryPromise({
221
+ try: () => client.getSession({ fetchOptions: { headers: authorization(token) } }),
222
+ catch: (cause) => commandError("Could not read the new session.", cause),
223
+ });
224
+ if (session.data?.user === undefined) {
225
+ return yield* commandError("The auth service returned an invalid session.");
226
+ }
227
+ yield* store.save({ authUrl, token });
228
+ return { email: session.data.user.email, verificationUrl: url };
229
+ }
230
+ switch (polled.error?.error) {
231
+ case "authorization_pending":
232
+ break;
233
+ case "slow_down":
234
+ interval += 5;
235
+ break;
236
+ case "access_denied":
237
+ return yield* commandError("Login was denied.");
238
+ case "expired_token":
239
+ return yield* commandError("The login code expired. Run login again.");
240
+ default:
241
+ return yield* commandError(
242
+ polled.error === null ? "Login failed." : responseError(polled.error),
243
+ );
244
+ }
245
+ }
246
+ });
247
+
248
+ export const logout = Effect.fn("Auth.logout")(function* () {
249
+ const store = yield* AuthCredentialStore;
250
+ const credential = yield* store.load;
251
+ if (credential === undefined) return false;
252
+ const client = makeClient(credential.authUrl);
253
+ const result = yield* Effect.tryPromise({
254
+ try: () => client.signOut({ fetchOptions: { headers: authorization(credential.token) } }),
255
+ catch: (cause) => commandError("Could not revoke the session.", cause),
256
+ });
257
+ if (result.error !== null) {
258
+ return yield* commandError(responseError(result.error));
259
+ }
260
+ yield* store.remove;
261
+ return true;
262
+ });
263
+
264
+ export const status = Effect.fn("Auth.status")(function* () {
265
+ const store = yield* AuthCredentialStore;
266
+ const credential = yield* store.load;
267
+ if (credential === undefined) return undefined;
268
+ const client = makeClient(credential.authUrl);
269
+ const session = yield* Effect.tryPromise({
270
+ try: () => client.getSession({ fetchOptions: { headers: authorization(credential.token) } }),
271
+ catch: (cause) => commandError("Could not check login status.", cause),
272
+ });
273
+ if (session.data?.user === undefined) return undefined;
274
+ const teams = yield* Effect.tryPromise({
275
+ try: () =>
276
+ client.organization.list({
277
+ fetchOptions: { headers: authorization(credential.token) },
278
+ }),
279
+ catch: (cause) => commandError("Could not load the team.", cause),
280
+ });
281
+ return {
282
+ email: session.data.user.email,
283
+ name: session.data.user.name,
284
+ teamName: teams.data?.[0]?.name,
285
+ };
286
+ });
@@ -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
  };