ignotum 0.0.6 → 0.0.8

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 (54) hide show
  1. package/README.md +85 -101
  2. package/dist/cli/bin.mjs +1487 -168
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-DtX8qPrq.js → api-BTeMI5tx.js} +27 -3
  5. package/dist/runtime/api-BTeMI5tx.js.map +1 -0
  6. package/dist/runtime/{api-D2bsAz4T.d.ts → api-D9lV-5av.d.ts} +7 -10
  7. package/dist/runtime/client.d.ts +27 -3
  8. package/dist/runtime/client.js +468 -68
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/{descriptor-t6BOEGw9-BTHR-ZMv.js → descriptor-C5VA9qRl-C338iC6l.js} +63 -5
  11. package/dist/runtime/descriptor-C5VA9qRl-C338iC6l.js.map +1 -0
  12. package/dist/runtime/file-BXf63ulU.js +166 -0
  13. package/dist/runtime/file-BXf63ulU.js.map +1 -0
  14. package/dist/runtime/{result-BgcHn25t.d.ts → id-Btwac71X-DGj0DQuu.d.ts} +56 -4
  15. package/dist/runtime/{index-Dl_VQGmA.d.ts → index-CF04_Dps.d.ts} +96 -86
  16. package/dist/runtime/internal/api.d.ts +2 -2
  17. package/dist/runtime/internal/api.js +1 -1
  18. package/dist/runtime/internal/host.d.ts +25 -8
  19. package/dist/runtime/internal/host.js +209 -15
  20. package/dist/runtime/internal/host.js.map +1 -1
  21. package/dist/runtime/internal/server.d.ts +1 -1
  22. package/dist/runtime/internal/server.js +1 -1
  23. package/dist/runtime/internal/types.d.ts +1 -1
  24. package/dist/runtime/internal/types.js +1 -1
  25. package/dist/runtime/pagination-BNFhAjns.d.ts +1 -0
  26. package/dist/runtime/pagination-DrOowBve-Bnipd34u.d.ts +84 -0
  27. package/dist/runtime/{schema-B-jMZEbs.js → schema-DJfwfq87.js} +108 -30
  28. package/dist/runtime/schema-DJfwfq87.js.map +1 -0
  29. package/dist/runtime/server.d.ts +3 -3
  30. package/dist/runtime/server.js +2 -2
  31. package/dist/runtime/server.js.map +1 -1
  32. package/dist/runtime/sync-mIXn9eKv.d.ts +8 -0
  33. package/package.json +2 -2
  34. package/src/cli/agent-files.ts +56 -13
  35. package/src/cli/app-configuration.ts +4 -1
  36. package/src/cli/build/server.ts +83 -6
  37. package/src/cli/new-app.ts +15 -0
  38. package/src/client/files.ts +168 -0
  39. package/src/client/hooks.ts +166 -17
  40. package/src/client/index.ts +9 -1
  41. package/src/client/sync.ts +137 -21
  42. package/src/dev-runtime/database.ts +621 -78
  43. package/src/dev-runtime/files.ts +338 -0
  44. package/src/dev-runtime/functions.ts +14 -6
  45. package/src/dev-runtime/migrations.ts +44 -0
  46. package/src/dev-runtime/sync.ts +123 -5
  47. package/src/internal/api.ts +16 -1
  48. package/src/server/index.ts +8 -1
  49. package/dist/runtime/api-DtX8qPrq.js.map +0 -1
  50. package/dist/runtime/descriptor-t6BOEGw9-BTHR-ZMv.js.map +0 -1
  51. package/dist/runtime/id-Btwac71X-B9OeBzvq.d.ts +0 -9
  52. package/dist/runtime/id-D570vudg.js +0 -26
  53. package/dist/runtime/id-D570vudg.js.map +0 -1
  54. package/dist/runtime/schema-B-jMZEbs.js.map +0 -1
@@ -0,0 +1,168 @@
1
+ import {
2
+ FileId,
3
+ fileGrantUrlOf,
4
+ fileIdOf,
5
+ fileFormats,
6
+ fileLimits,
7
+ fileMimeTypes,
8
+ grantFileValue,
9
+ isFileValue,
10
+ makeFileValue,
11
+ type FileFormat,
12
+ type FileValue,
13
+ type RuntimeFileOccurrence,
14
+ } from "@ignotum/contracts/schema/file";
15
+ import { IdGenerator } from "@ignotum/shared/id";
16
+ import { Effect, Predicate } from "effect";
17
+
18
+ export const Files = {
19
+ url: (file: FileValue): string => {
20
+ const url = fileGrantUrlOf(file);
21
+ if (url === undefined) {
22
+ throw new Error("This FileValue has no active query grant.");
23
+ }
24
+ return url;
25
+ },
26
+ } as const;
27
+
28
+ const formatByMime: Partial<Record<string, FileFormat>> = {};
29
+ for (const format of fileFormats) formatByMime[fileMimeTypes[format]] = format;
30
+
31
+ interface NativeOccurrence {
32
+ readonly path: ReadonlyArray<string | number>;
33
+ readonly value: File;
34
+ }
35
+
36
+ const collectNativeFiles = (
37
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This is the recursive parser for generated mutation arguments and native File values.
38
+ value: unknown,
39
+ path: ReadonlyArray<string | number>,
40
+ occurrences: Array<NativeOccurrence>,
41
+ ): void => {
42
+ if (value instanceof File) {
43
+ occurrences.push({ path, value });
44
+ return;
45
+ }
46
+ if (isFileValue(value) || Predicate.isDate(value)) return;
47
+ if (globalThis.Array.isArray(value)) {
48
+ for (const [index, child] of value.entries())
49
+ collectNativeFiles(child, [...path, index], occurrences);
50
+ return;
51
+ }
52
+ if (!Predicate.isObject(value)) return;
53
+ for (const [key, child] of Object.entries(value))
54
+ collectNativeFiles(child, [...path, key], occurrences);
55
+ };
56
+
57
+ const replaceNativeFiles = (
58
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Each node is classified before it is copied into the transport value.
59
+ value: unknown,
60
+ replacements: ReadonlyMap<File, FileValue>,
61
+ // oxlint-disable-next-line anti-slop/no-unknown-returns -- The generated function schema validates the reconstructed argument value on the server.
62
+ ): unknown => {
63
+ if (value instanceof File) return replacements.get(value);
64
+ if (isFileValue(value) || Predicate.isDate(value)) return value;
65
+ if (globalThis.Array.isArray(value))
66
+ return value.map((child) => replaceNativeFiles(child, replacements));
67
+ if (!Predicate.isObject(value)) return value;
68
+ return Object.fromEntries(
69
+ Object.entries(value).map(([key, child]) => [key, replaceNativeFiles(child, replacements)]),
70
+ );
71
+ };
72
+
73
+ export interface PreparedMutationArguments {
74
+ readonly value: unknown;
75
+ readonly nativeFiles: ReadonlyMap<FileId, File>;
76
+ readonly occurrences: ReadonlyArray<RuntimeFileOccurrence>;
77
+ }
78
+
79
+ export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArguments")(function* (
80
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Generated mutation argument types are erased only at this transport preparation boundary.
81
+ input: unknown,
82
+ ) {
83
+ const ids = yield* IdGenerator;
84
+ const native: NativeOccurrence[] = [];
85
+ collectNativeFiles(input, [], native);
86
+ const unique = new Set(native.map(({ value }) => value));
87
+ if (unique.size > fileLimits.filesPerMutation) {
88
+ throw new Error(`A mutation may upload at most ${fileLimits.filesPerMutation} files.`);
89
+ }
90
+ const totalBytes = [...unique].reduce((total, file) => total + file.size, 0);
91
+ if (totalBytes > fileLimits.mutationBytes) {
92
+ throw new Error(`Mutation uploads may total at most ${fileLimits.mutationBytes} bytes.`);
93
+ }
94
+ const replacements = new Map<File, FileValue>();
95
+ const nativeFiles = new Map<FileId, File>();
96
+ for (const file of unique) {
97
+ const format = formatByMime[file.type.toLowerCase()];
98
+ if (format === undefined) {
99
+ throw new Error(
100
+ `File '${file.name}' has unsupported media type '${file.type || "unknown"}'.`,
101
+ );
102
+ }
103
+ if (file.size === 0 || file.size > fileLimits.fileBytes) {
104
+ throw new Error(`File '${file.name}' must be between 1 and ${fileLimits.fileBytes} bytes.`);
105
+ }
106
+ if (new TextEncoder().encode(file.name).byteLength > fileLimits.filenameBytes) {
107
+ throw new Error(`File '${file.name}' has a filename that is too long.`);
108
+ }
109
+ const id = yield* ids.generate(FileId);
110
+ replacements.set(file, makeFileValue(id, { format, name: file.name, size: file.size }));
111
+ nativeFiles.set(id, file);
112
+ }
113
+ 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;
128
+ });
129
+
130
+ interface FileGrantOccurrence extends RuntimeFileOccurrence {
131
+ readonly url: string;
132
+ }
133
+
134
+ const pathKey = (path: ReadonlyArray<string | number>): string => JSON.stringify(path);
135
+
136
+ // @effect-diagnostics-next-line missingPipeableSignature:off File grant attachment naturally takes the decoded value first.
137
+ 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.
139
+ value: unknown,
140
+ grants: ReadonlyArray<FileGrantOccurrence>,
141
+ // oxlint-disable-next-line anti-slop/no-unknown-returns -- The traversal preserves the decoded query result shape while attaching private symbols.
142
+ ): unknown => {
143
+ const byPath = new Map(grants.map((grant) => [pathKey(grant.path), grant] as const));
144
+ const visit = (
145
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This recursive visitor validates a grant and file ID before replacement.
146
+ current: unknown,
147
+ path: ReadonlyArray<string | number>,
148
+ // oxlint-disable-next-line anti-slop/no-unknown-returns -- Recursive branches preserve their input shape.
149
+ ): 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);
156
+ }
157
+ if (globalThis.Array.isArray(current)) {
158
+ return current.map((child, index) => visit(child, [...path, index]));
159
+ }
160
+ if (!Predicate.isObject(current) || Predicate.isDate(current) || isFileValue(current)) {
161
+ return current;
162
+ }
163
+ return Object.fromEntries(
164
+ Object.entries(current).map(([key, child]) => [key, visit(child, [...path, key])]),
165
+ );
166
+ };
167
+ return visit(value, []);
168
+ };
@@ -1,15 +1,16 @@
1
1
  import { Effect, Schema } from "effect";
2
- import { useDebugValue, useEffect, useMemo, useState } from "preact/hooks";
2
+ import { useCallback, useDebugValue, useEffect, useMemo, useState } from "preact/hooks";
3
3
 
4
4
  import { functionPathOf, type FunctionReference } from "../internal/api.js";
5
- import { pending } from "@ignotum/contracts/runtime/result";
5
+ import { inspectResult, pending, Result as RuntimeResult } from "@ignotum/contracts/runtime/result";
6
6
  import type { ErrorValue, QueryResult, SettledResult } from "@ignotum/contracts/runtime/result";
7
- import { encodeTransportObject } from "@ignotum/contracts/runtime/sync";
8
7
  import {
9
- ClientInfrastructureError,
10
- InvalidMutationArguments,
11
- reportClientError,
12
- } from "./errors.js";
8
+ PaginationPageSize,
9
+ type PaginationOptions,
10
+ type PaginationPage,
11
+ } from "@ignotum/contracts/runtime/pagination";
12
+ import { encodeTransportObject } from "@ignotum/contracts/runtime/sync";
13
+ import { ClientInfrastructureError } from "./errors.js";
13
14
  import { isQuerySkip, type QuerySkip } from "./query.js";
14
15
  import { SyncClient, syncClientInternals, syncRuntime } from "./sync.js";
15
16
 
@@ -102,6 +103,163 @@ export function useQuery<Args extends object, Success, Failure extends ErrorValu
102
103
  return result;
103
104
  }
104
105
 
106
+ export type PaginationStatus = "CanLoadMore" | "Exhausted" | "LoadingMore";
107
+
108
+ export interface PaginatedQueryValue<Item> {
109
+ readonly items: ReadonlyArray<Item>;
110
+ readonly loadMore: () => void;
111
+ readonly status: PaginationStatus;
112
+ }
113
+
114
+ export interface PaginatedQueryOptions {
115
+ readonly pageSize?: number;
116
+ }
117
+
118
+ type PaginationArguments = { readonly pagination: PaginationOptions };
119
+ type ApplicationPaginationArguments<Args extends PaginationArguments> = Omit<Args, "pagination">;
120
+
121
+ // @effect-diagnostics-next-line missingPipeableSignature:off React hooks are not pipeable functions.
122
+ export function usePaginatedQuery<
123
+ Args extends PaginationArguments,
124
+ Item,
125
+ Failure extends ErrorValue,
126
+ >(
127
+ reference: keyof ApplicationPaginationArguments<Args> extends never
128
+ ? FunctionReference<"Query", Args, PaginationPage<Item>, Failure>
129
+ : never,
130
+ ): QueryResult<PaginatedQueryValue<Item>, Failure>;
131
+ export function usePaginatedQuery<
132
+ Args extends PaginationArguments,
133
+ Item,
134
+ Failure extends ErrorValue,
135
+ >(
136
+ reference: FunctionReference<"Query", Args, PaginationPage<Item>, Failure>,
137
+ args: NoInfer<ApplicationPaginationArguments<Args>> | QuerySkip,
138
+ options?: PaginatedQueryOptions,
139
+ ): QueryResult<PaginatedQueryValue<Item>, Failure>;
140
+ // @effect-diagnostics-next-line missingPipeableSignature:off React hooks must remain direct calls so hook order is statically visible.
141
+ export function usePaginatedQuery<Args extends object, Item, Failure extends ErrorValue>(
142
+ reference: FunctionReference<"Query", Args & PaginationArguments, PaginationPage<Item>, Failure>,
143
+ args?: Args | QuerySkip,
144
+ options: PaginatedQueryOptions = {},
145
+ ): QueryResult<PaginatedQueryValue<Item>, Failure> {
146
+ const functionPath = functionPathOf(reference);
147
+ const skipped = isQuerySkip(args);
148
+ const input = args === undefined || skipped ? {} : args;
149
+ const jsonArgs = encodeTransportObject(input);
150
+ const pageSize = Schema.decodeSync(PaginationPageSize)(options.pageSize ?? 20);
151
+ const identity = skipped
152
+ ? `skip:${functionPath}`
153
+ : syncClientInternals.queryIdentity(functionPath, jsonArgs);
154
+ const [requestedPages, setRequestedPages] = useState(1);
155
+ const [refresh, setRefresh] = useState(0);
156
+ const [state, setState] = useState<{
157
+ readonly identity: string;
158
+ readonly result: QueryResult<PaginatedQueryValue<Item>, Failure> | ClientInfrastructureError;
159
+ }>(() => ({ identity, result: pending() }));
160
+ const loadMore = useCallback(() => setRequestedPages((count) => count + 1), []);
161
+
162
+ useDebugValue({ args: input, function: functionPath, result: state.result });
163
+
164
+ useEffect(() => {
165
+ setRequestedPages(1);
166
+ setState({ identity, result: pending() });
167
+ }, [identity, pageSize]);
168
+
169
+ useEffect(() => {
170
+ if (skipped) return;
171
+
172
+ let active = true;
173
+ const releases: Array<() => void> = [];
174
+
175
+ void syncRuntime
176
+ .runPromise(
177
+ Effect.gen(function* () {
178
+ const client = yield* SyncClient;
179
+ const items: Item[] = [];
180
+ let cursor: PaginationOptions["cursor"] = null;
181
+
182
+ for (let pageNumber = 0; pageNumber < requestedPages; pageNumber += 1) {
183
+ const pageArgs = encodeTransportObject({
184
+ ...input,
185
+ pagination: { cursor, pageSize },
186
+ });
187
+ const observer = yield* client.observe(functionPath, pageArgs);
188
+ if (!active) {
189
+ yield* observer.release;
190
+ return;
191
+ }
192
+ const releaseSubscription = observer.subscribe(() => {
193
+ if (active) setRefresh((version) => version + 1);
194
+ });
195
+ releases.push(() => {
196
+ releaseSubscription();
197
+ syncRuntime.runFork(observer.release);
198
+ });
199
+
200
+ const snapshot = observer.getSnapshot();
201
+ if (Schema.is(ClientInfrastructureError)(snapshot)) {
202
+ setState({ identity, result: snapshot });
203
+ return;
204
+ }
205
+ const inspected = inspectResult(snapshot);
206
+ if (inspected === undefined) {
207
+ setState({
208
+ identity,
209
+ result:
210
+ items.length === 0
211
+ ? pending()
212
+ : RuntimeResult.succeed({ items, loadMore, status: "LoadingMore" }),
213
+ });
214
+ return;
215
+ }
216
+ if (inspected.type === "Failure") {
217
+ // SAFETY: pagination changes only the successful value. The
218
+ // failure channel is the generated query reference's Failure.
219
+ setState({
220
+ identity,
221
+ result: snapshot as QueryResult<PaginatedQueryValue<Item>, Failure>,
222
+ });
223
+ return;
224
+ }
225
+ // SAFETY: the generated reference and values.page validator couple
226
+ // this successful snapshot to PaginationPage<Item>.
227
+ const page = inspected.value as PaginationPage<Item>;
228
+ items.push(...page.items);
229
+ cursor = page.nextCursor;
230
+ if (cursor === null) {
231
+ setState({
232
+ identity,
233
+ result: RuntimeResult.succeed({ items, loadMore, status: "Exhausted" }),
234
+ });
235
+ return;
236
+ }
237
+ }
238
+
239
+ setState({
240
+ identity,
241
+ result: RuntimeResult.succeed({ items, loadMore, status: "CanLoadMore" }),
242
+ });
243
+ }),
244
+ )
245
+ .catch((error) => {
246
+ if (active && Schema.is(ClientInfrastructureError)(error)) {
247
+ setState({ identity, result: error });
248
+ }
249
+ });
250
+
251
+ return () => {
252
+ active = false;
253
+ for (const release of releases) release();
254
+ };
255
+ }, [functionPath, identity, loadMore, pageSize, refresh, requestedPages, skipped]);
256
+
257
+ if (skipped || state.identity !== identity) return pending();
258
+ const result = state.result;
259
+ if (Schema.is(ClientInfrastructureError)(result)) throw result;
260
+ return result;
261
+ }
262
+
105
263
  type Mutation<Args, Success, Failure extends ErrorValue> = [Args] extends [void]
106
264
  ? () => Promise<SettledResult<Success, Failure>>
107
265
  : (args: Args) => Promise<SettledResult<Success, Failure>>;
@@ -116,19 +274,10 @@ export const useMutation = <Args extends object | void, Success, Failure extends
116
274
  return syncRuntime.runPromise<SettledResult<Success, Failure>, ClientInfrastructureError>(
117
275
  Effect.gen(function* () {
118
276
  const client = yield* SyncClient;
119
- const jsonArgs = yield* Effect.try({
120
- try: () => encodeTransportObject(input),
121
- catch: (cause) =>
122
- InvalidMutationArguments.make({
123
- cause,
124
- function: functionPath,
125
- message: `Mutation arguments for ${functionPath} contain an unsupported value.`,
126
- }),
127
- }).pipe(Effect.tapError(reportClientError));
128
277
  // SAFETY: generated function references bind the runtime path to the
129
278
  // declared public result types validated by the server executor.
130
279
  // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- The untyped sync transport deliberately erases the generated reference's result parameters.
131
- return (yield* client.mutate(functionPath, jsonArgs)) as unknown as SettledResult<
280
+ return (yield* client.mutate(functionPath, input)) as unknown as SettledResult<
132
281
  Success,
133
282
  Failure
134
283
  >;
@@ -9,7 +9,15 @@ export const Result = { match: contractResult.match };
9
9
  export type Result<Value, Error extends ErrorValue> = SettledResult<Value, Error>;
10
10
  export type QueryResult<Value, Error extends ErrorValue> = ContractQueryResult<Value, Error>;
11
11
  export type { InternalServerError } from "@ignotum/contracts/runtime/result";
12
- export { useMutation, useQuery } from "./hooks.js";
12
+ export type {
13
+ FileFormat,
14
+ FileMetadata,
15
+ FileMimeType,
16
+ FileValue,
17
+ } from "@ignotum/contracts/schema/file";
18
+ export { Files } from "./files.js";
19
+ export { useMutation, usePaginatedQuery, useQuery } from "./hooks.js";
20
+ export type { PaginatedQueryOptions, PaginatedQueryValue, PaginationStatus } from "./hooks.js";
13
21
  export { Query } from "./query.js";
14
22
  export type { QuerySkip } from "./query.js";
15
23
  export { app } from "./app.js";
@@ -16,6 +16,7 @@ import * as BrowserCrypto from "@effect/platform-browser/BrowserCrypto";
16
16
  import { encodeCanonicalJson } from "@ignotum/contracts/json";
17
17
  import { type AppStateRevision } from "@ignotum/contracts/runtime/hosted";
18
18
  import { IdGenerator } from "@ignotum/shared/id";
19
+ import { encodeTransportObject } from "@ignotum/contracts/runtime/sync";
19
20
 
20
21
  import type {
21
22
  ErrorValue,
@@ -42,10 +43,12 @@ import {
42
43
  InvalidClientMessage,
43
44
  InvalidServerMessage,
44
45
  ServerProtocolError,
46
+ InvalidMutationArguments,
45
47
  clientErrorReporterLayer,
46
48
  reportClientError,
47
49
  } from "./errors.js";
48
50
  import { syncPath } from "../internal/http-paths.js";
51
+ import { applyFileGrants, prepareMutationArguments } from "./files.js";
49
52
 
50
53
  type Listener = () => void;
51
54
  type SocketWriter = (chunk: string) => Effect.Effect<void, Socket.SocketError>;
@@ -195,14 +198,19 @@ const makeQueryCache = (
195
198
  } satisfies QueryCache;
196
199
  };
197
200
 
198
- const resultFromWire = Effect.fn("SyncClient.resultFromWire")((wire: WireResult) =>
199
- Effect.succeed(
200
- wire.type === "Success"
201
- ? Result.succeed(
202
- wire.value === undefined ? undefined : decodeTransportValue(wire.value, wire.dates ?? []),
203
- )
204
- : failureFromWire(wire.error, wire.dates),
205
- ),
201
+ type SnapshotFiles = NonNullable<Extract<ServerMessage, { readonly type: "Snapshot" }>["files"]>;
202
+
203
+ const resultFromWire = Effect.fn("SyncClient.resultFromWire")(
204
+ (wire: WireResult, files: SnapshotFiles = []) =>
205
+ Effect.succeed(
206
+ wire.type === "Success"
207
+ ? Result.succeed(
208
+ wire.value === undefined
209
+ ? undefined
210
+ : applyFileGrants(decodeTransportValue(wire.value, wire.dates ?? []), files),
211
+ )
212
+ : failureFromWire(wire.error, wire.dates),
213
+ ),
206
214
  );
207
215
 
208
216
  export const shouldReloadForCloseCode = (code: number): boolean =>
@@ -242,7 +250,8 @@ const socketUrl = (): string => {
242
250
  interface SyncClientService {
243
251
  readonly mutate: (
244
252
  functionAddress: FunctionAddress,
245
- args: Schema.Json,
253
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Generated reference argument types are deliberately erased at the client transport boundary.
254
+ args: unknown,
246
255
  ) => Effect.Effect<IgnotumResult<unknown, ErrorValue>, ClientInfrastructureError>;
247
256
  readonly observe: (
248
257
  functionAddress: FunctionAddress,
@@ -256,7 +265,11 @@ interface PendingInvocation {
256
265
  ClientInfrastructureError
257
266
  >;
258
267
  readonly function: FunctionAddress;
259
- readonly message: Extract<ClientMessage, { readonly type: "Invoke" }>;
268
+ readonly message: Extract<ClientMessage, { readonly type: "Invoke" | "PrepareMutation" }>;
269
+ readonly prepared: Deferred.Deferred<
270
+ Extract<ServerMessage, { readonly type: "MutationPrepared" }>["uploads"],
271
+ ClientInfrastructureError
272
+ >;
260
273
  }
261
274
 
262
275
  export class SyncClient extends Context.Service<SyncClient, SyncClientService>()(
@@ -319,6 +332,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
319
332
  }
320
333
  invocations = HashMap.remove(invocations, id);
321
334
  return reportClientError(error).pipe(
335
+ Effect.andThen(Deferred.fail(pendingInvocation.prepared, error)),
322
336
  Effect.andThen(Deferred.fail(pendingInvocation.deferred, error)),
323
337
  Effect.asVoid,
324
338
  );
@@ -331,13 +345,20 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
331
345
  case "Snapshot": {
332
346
  const entry = queryCache.getById(message.id);
333
347
  if (entry === undefined) return;
334
- yield* resultFromWire(message.result).pipe(
348
+ yield* resultFromWire(message.result, message.files ?? []).pipe(
335
349
  Effect.tap((result) =>
336
350
  Effect.sync(() => queryCache.setResult(message.id, result, message.revision)),
337
351
  ),
338
352
  );
339
353
  return;
340
354
  }
355
+ case "MutationPrepared": {
356
+ const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
357
+ if (pendingInvocation !== undefined) {
358
+ yield* Deferred.succeed(pendingInvocation.prepared, message.uploads);
359
+ }
360
+ return;
361
+ }
341
362
  case "Result": {
342
363
  const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
343
364
  if (pendingInvocation === undefined) return;
@@ -488,31 +509,126 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
488
509
  const mutate: SyncClientService["mutate"] = Effect.fn("SyncClient.mutate")(
489
510
  function* (functionAddress, args) {
490
511
  const invocationId = yield* ids.generate(InvocationId);
512
+ const preparedArguments = yield* prepareMutationArguments(args).pipe(
513
+ Effect.provideService(IdGenerator, ids),
514
+ Effect.mapError((cause) =>
515
+ InvalidMutationArguments.make({
516
+ cause,
517
+ function: functionAddress,
518
+ message: `Mutation arguments for ${functionAddress} contain an unsupported file or value.`,
519
+ }),
520
+ ),
521
+ Effect.tapError(reportClientError),
522
+ );
523
+ const jsonArgs = yield* Effect.try({
524
+ try: () => encodeTransportObject(preparedArguments.value),
525
+ catch: (cause) =>
526
+ InvalidMutationArguments.make({
527
+ cause,
528
+ function: functionAddress,
529
+ message: `Mutation arguments for ${functionAddress} contain an unsupported value.`,
530
+ }),
531
+ }).pipe(Effect.tapError(reportClientError));
491
532
  const deferred = yield* Deferred.make<
492
533
  IgnotumResult<unknown, ErrorValue>,
493
534
  ClientInfrastructureError
494
535
  >();
495
- const message: ClientMessage = {
536
+ const prepared = yield* Deferred.make<
537
+ Extract<ServerMessage, { readonly type: "MutationPrepared" }>["uploads"],
538
+ ClientInfrastructureError
539
+ >();
540
+ const invokeMessage: Extract<ClientMessage, { readonly type: "Invoke" }> = {
496
541
  type: "Invoke",
497
542
  id: invocationId,
498
543
  kind: "Mutation",
499
544
  function: functionAddress,
500
- args,
545
+ args: jsonArgs,
501
546
  };
547
+ const message: PendingInvocation["message"] =
548
+ preparedArguments.occurrences.length === 0
549
+ ? invokeMessage
550
+ : {
551
+ type: "PrepareMutation",
552
+ id: invocationId,
553
+ kind: "Mutation",
554
+ function: functionAddress,
555
+ args: jsonArgs,
556
+ files: preparedArguments.occurrences,
557
+ };
502
558
  invocations = HashMap.set(invocations, invocationId, {
503
559
  deferred,
504
560
  function: functionAddress,
505
561
  message,
562
+ prepared,
506
563
  });
507
- if (writer !== undefined) {
508
- yield* sendWith(writer, message).pipe(
509
- Effect.catchTags({
510
- ConnectionUnavailable: reportClientError,
511
- InvalidClientMessage: (error) => rejectInvocation(invocationId, error),
564
+ return yield* Effect.gen(function* () {
565
+ if (writer !== undefined) {
566
+ yield* sendWith(writer, message).pipe(
567
+ Effect.catchTags({
568
+ ConnectionUnavailable: reportClientError,
569
+ InvalidClientMessage: (error) => rejectInvocation(invocationId, error),
570
+ }),
571
+ );
572
+ }
573
+ if (message.type === "PrepareMutation") {
574
+ const uploads = yield* Deferred.await(prepared);
575
+ const uploaded = new Set<string>();
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);
581
+ if (file === undefined) {
582
+ return yield* InvalidServerMessage.make({
583
+ cause: new Error(`Missing native file '${upload.file.id}'.`),
584
+ message: "The server prepared an unknown application file.",
585
+ });
586
+ }
587
+ const response = yield* Effect.tryPromise({
588
+ try: () =>
589
+ fetch(uploadUrl, {
590
+ method: "PUT",
591
+ body: file,
592
+ headers: { "content-type": file.type },
593
+ }),
594
+ catch: (cause) =>
595
+ ConnectionUnavailable.make({
596
+ cause,
597
+ message: `File '${file.name}' could not be uploaded.`,
598
+ }),
599
+ });
600
+ if (!response.ok) {
601
+ const detail = yield* Effect.promise(() => response.text());
602
+ return yield* InvalidMutationArguments.make({
603
+ cause: new Error(detail),
604
+ function: functionAddress,
605
+ message: `File '${file.name}' was rejected by application storage.`,
606
+ });
607
+ }
608
+ }
609
+ const pendingInvocation = HashMap.getUnsafe(invocations, invocationId);
610
+ if (pendingInvocation === undefined) return yield* Deferred.await(deferred);
611
+ invocations = HashMap.set(invocations, invocationId, {
612
+ ...pendingInvocation,
613
+ message: invokeMessage,
614
+ });
615
+ if (writer !== undefined) {
616
+ yield* sendWith(writer, invokeMessage).pipe(
617
+ Effect.catchTags({
618
+ ConnectionUnavailable: reportClientError,
619
+ InvalidClientMessage: (error) => rejectInvocation(invocationId, error),
620
+ }),
621
+ );
622
+ }
623
+ }
624
+ return yield* Deferred.await(deferred);
625
+ }).pipe(
626
+ Effect.tapError(() =>
627
+ Effect.sync(() => {
628
+ invocations = HashMap.remove(invocations, invocationId);
512
629
  }),
513
- );
514
- }
515
- return yield* Deferred.await(deferred);
630
+ ),
631
+ );
516
632
  },
517
633
  );
518
634