ignotum 0.0.7 → 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.
- package/README.md +89 -101
- package/dist/cli/bin.mjs +1119 -195
- package/dist/cli/bin.mjs.map +1 -1
- package/dist/runtime/{api-DtX8qPrq.js → api-DzcR7spt.js} +36 -13
- package/dist/runtime/api-DzcR7spt.js.map +1 -0
- package/dist/runtime/{api-Dp4J-xt-.d.ts → api-jCl8Hzry.d.ts} +7 -9
- package/dist/runtime/client.d.ts +12 -5
- package/dist/runtime/client.js +285 -60
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/{descriptor-t6BOEGw9-C1rwYIlx.js → descriptor-C5VA9qRl-BKbdenuU.js} +38 -7
- package/dist/runtime/descriptor-C5VA9qRl-BKbdenuU.js.map +1 -0
- package/dist/runtime/file-C1abuMgd.js +173 -0
- package/dist/runtime/file-C1abuMgd.js.map +1 -0
- package/dist/runtime/{id-Btwac71X-DhnKYsjY.d.ts → id-Cs82tq9Q-Caqfx54f.d.ts} +51 -5
- package/dist/runtime/{index-B5KSOjGN.d.ts → index-D6VLTbDB.d.ts} +40 -21
- package/dist/runtime/internal/api.d.ts +2 -2
- package/dist/runtime/internal/api.js +1 -1
- package/dist/runtime/internal/host.d.ts +8 -7
- package/dist/runtime/internal/host.js +21 -10
- package/dist/runtime/internal/host.js.map +1 -1
- package/dist/runtime/internal/server.d.ts +1 -1
- package/dist/runtime/internal/server.js +1 -1
- package/dist/runtime/internal/types.d.ts +1 -1
- package/dist/runtime/internal/types.js +1 -1
- package/dist/runtime/{pagination-B1BzNkh8-BUSTbeSg.d.ts → pagination-Bt3l7QaC-D_zI5zsu.d.ts} +8 -4
- package/dist/runtime/pagination-DcIkTOFs.d.ts +1 -0
- package/dist/runtime/{schema-D9RmboaS.js → schema-B6PK_ZwV.js} +17 -3
- package/dist/runtime/schema-B6PK_ZwV.js.map +1 -0
- package/dist/runtime/server.d.ts +3 -3
- package/dist/runtime/server.js +2 -2
- package/dist/runtime/server.js.map +1 -1
- package/dist/runtime/sync-bMQq9tXx.d.ts +8 -0
- package/package.json +7 -5
- package/src/cli/agent-files.ts +52 -13
- package/src/cli/app-configuration.ts +4 -1
- package/src/cli/auth-client.ts +286 -0
- package/src/cli/build/server.ts +83 -6
- package/src/cli/command.ts +65 -2
- package/src/cli/control-client.ts +19 -6
- package/src/cli/new-app.ts +16 -0
- package/src/client/errors.ts +7 -10
- package/src/client/files.ts +153 -0
- package/src/client/hooks.ts +2 -15
- package/src/client/index.ts +7 -0
- package/src/client/sync.ts +158 -30
- package/src/dev-runtime/database.ts +69 -57
- package/src/dev-runtime/files.ts +337 -0
- package/src/dev-runtime/functions.ts +14 -6
- package/src/dev-runtime/migrations.ts +14 -0
- package/src/dev-runtime/sync.ts +134 -22
- package/src/internal/api.ts +16 -1
- package/src/server/index.ts +8 -1
- package/dist/runtime/api-DtX8qPrq.js.map +0 -1
- package/dist/runtime/descriptor-t6BOEGw9-C1rwYIlx.js.map +0 -1
- package/dist/runtime/id-D570vudg.js +0 -26
- package/dist/runtime/id-D570vudg.js.map +0 -1
- package/dist/runtime/pagination-BKPko9Hm.d.ts +0 -1
- package/dist/runtime/schema-D9RmboaS.js.map +0 -1
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
} from "@ignotum/contracts/schema/file";
|
|
14
|
+
import { IdGenerator } from "@ignotum/shared/id";
|
|
15
|
+
import { Effect, Predicate } from "effect";
|
|
16
|
+
|
|
17
|
+
export const Files = {
|
|
18
|
+
url: (file: FileValue): string => {
|
|
19
|
+
const url = fileGrantUrlOf(file);
|
|
20
|
+
if (url === undefined) {
|
|
21
|
+
throw new Error("This FileValue has no active query grant.");
|
|
22
|
+
}
|
|
23
|
+
return url;
|
|
24
|
+
},
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
const formatByMime: Partial<Record<string, FileFormat>> = {};
|
|
28
|
+
for (const format of fileFormats) formatByMime[fileMimeTypes[format]] = format;
|
|
29
|
+
|
|
30
|
+
const collectNativeFiles = (
|
|
31
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- This is the recursive parser for generated mutation arguments and native File values.
|
|
32
|
+
value: unknown,
|
|
33
|
+
files: Array<File>,
|
|
34
|
+
): void => {
|
|
35
|
+
if (value instanceof File) {
|
|
36
|
+
files.push(value);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (isFileValue(value) || Predicate.isDate(value)) return;
|
|
40
|
+
if (globalThis.Array.isArray(value)) {
|
|
41
|
+
for (const child of value) collectNativeFiles(child, files);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (!Predicate.isObject(value)) return;
|
|
45
|
+
for (const child of Object.values(value)) collectNativeFiles(child, files);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const replaceNativeFiles = (
|
|
49
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Each node is classified before it is copied into the transport value.
|
|
50
|
+
value: unknown,
|
|
51
|
+
replacements: ReadonlyMap<File, FileValue>,
|
|
52
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- The generated function schema validates the reconstructed argument value on the server.
|
|
53
|
+
): unknown => {
|
|
54
|
+
if (value instanceof File) return replacements.get(value);
|
|
55
|
+
if (isFileValue(value) || Predicate.isDate(value)) return value;
|
|
56
|
+
if (globalThis.Array.isArray(value))
|
|
57
|
+
return value.map((child) => replaceNativeFiles(child, replacements));
|
|
58
|
+
if (!Predicate.isObject(value)) return value;
|
|
59
|
+
return Object.fromEntries(
|
|
60
|
+
Object.entries(value).map(([key, child]) => [key, replaceNativeFiles(child, replacements)]),
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export interface PreparedMutationArguments {
|
|
65
|
+
readonly value: unknown;
|
|
66
|
+
readonly nativeFiles: ReadonlyMap<FileId, File>;
|
|
67
|
+
readonly fileIds: ReadonlyArray<FileId>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArguments")(function* (
|
|
71
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Generated mutation argument types are erased only at this transport preparation boundary.
|
|
72
|
+
input: unknown,
|
|
73
|
+
) {
|
|
74
|
+
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
|
+
}
|
|
85
|
+
const replacements = new Map<File, FileValue>();
|
|
86
|
+
const nativeFiles = new Map<FileId, File>();
|
|
87
|
+
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
|
+
}
|
|
100
|
+
const id = yield* ids.generate(FileId);
|
|
101
|
+
replacements.set(file, makeFileValue(id, { format, name: file.name, size: file.size }));
|
|
102
|
+
nativeFiles.set(id, file);
|
|
103
|
+
}
|
|
104
|
+
const value = replaceNativeFiles(input, replacements);
|
|
105
|
+
return {
|
|
106
|
+
value,
|
|
107
|
+
nativeFiles,
|
|
108
|
+
fileIds: Array.from(nativeFiles.keys()),
|
|
109
|
+
} satisfies PreparedMutationArguments;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
interface FileGrant {
|
|
113
|
+
readonly id: FileId;
|
|
114
|
+
readonly url: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// @effect-diagnostics-next-line missingPipeableSignature:off File grant attachment naturally takes the decoded value first.
|
|
118
|
+
export const applyFileGrants = (
|
|
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.
|
|
120
|
+
value: unknown,
|
|
121
|
+
grants: ReadonlyArray<FileGrant>,
|
|
122
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- The traversal preserves the decoded query result shape while attaching private symbols.
|
|
123
|
+
): unknown => {
|
|
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>();
|
|
130
|
+
const visit = (
|
|
131
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- This recursive visitor identifies file values before attaching grants.
|
|
132
|
+
current: unknown,
|
|
133
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- Recursive branches preserve their input shape.
|
|
134
|
+
): unknown => {
|
|
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);
|
|
141
|
+
}
|
|
142
|
+
if (globalThis.Array.isArray(current)) {
|
|
143
|
+
return current.map(visit);
|
|
144
|
+
}
|
|
145
|
+
if (!Predicate.isObject(current) || Predicate.isDate(current)) {
|
|
146
|
+
return current;
|
|
147
|
+
}
|
|
148
|
+
return Object.fromEntries(Object.entries(current).map(([key, child]) => [key, visit(child)]));
|
|
149
|
+
};
|
|
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;
|
|
153
|
+
};
|
package/src/client/hooks.ts
CHANGED
|
@@ -10,11 +10,7 @@ import {
|
|
|
10
10
|
type PaginationPage,
|
|
11
11
|
} from "@ignotum/contracts/runtime/pagination";
|
|
12
12
|
import { encodeTransportObject } from "@ignotum/contracts/runtime/sync";
|
|
13
|
-
import {
|
|
14
|
-
ClientInfrastructureError,
|
|
15
|
-
InvalidMutationArguments,
|
|
16
|
-
reportClientError,
|
|
17
|
-
} from "./errors.js";
|
|
13
|
+
import { ClientInfrastructureError } from "./errors.js";
|
|
18
14
|
import { isQuerySkip, type QuerySkip } from "./query.js";
|
|
19
15
|
import { SyncClient, syncClientInternals, syncRuntime } from "./sync.js";
|
|
20
16
|
|
|
@@ -278,19 +274,10 @@ export const useMutation = <Args extends object | void, Success, Failure extends
|
|
|
278
274
|
return syncRuntime.runPromise<SettledResult<Success, Failure>, ClientInfrastructureError>(
|
|
279
275
|
Effect.gen(function* () {
|
|
280
276
|
const client = yield* SyncClient;
|
|
281
|
-
const jsonArgs = yield* Effect.try({
|
|
282
|
-
try: () => encodeTransportObject(input),
|
|
283
|
-
catch: (cause) =>
|
|
284
|
-
InvalidMutationArguments.make({
|
|
285
|
-
cause,
|
|
286
|
-
function: functionPath,
|
|
287
|
-
message: `Mutation arguments for ${functionPath} contain an unsupported value.`,
|
|
288
|
-
}),
|
|
289
|
-
}).pipe(Effect.tapError(reportClientError));
|
|
290
277
|
// SAFETY: generated function references bind the runtime path to the
|
|
291
278
|
// declared public result types validated by the server executor.
|
|
292
279
|
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- The untyped sync transport deliberately erases the generated reference's result parameters.
|
|
293
|
-
return (yield* client.mutate(functionPath,
|
|
280
|
+
return (yield* client.mutate(functionPath, input)) as unknown as SettledResult<
|
|
294
281
|
Success,
|
|
295
282
|
Failure
|
|
296
283
|
>;
|
package/src/client/index.ts
CHANGED
|
@@ -9,6 +9,13 @@ 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 type {
|
|
13
|
+
FileFormat,
|
|
14
|
+
FileMetadata,
|
|
15
|
+
FileMimeType,
|
|
16
|
+
FileValue,
|
|
17
|
+
} from "@ignotum/contracts/schema/file";
|
|
18
|
+
export { Files } from "./files.js";
|
|
12
19
|
export { useMutation, usePaginatedQuery, useQuery } from "./hooks.js";
|
|
13
20
|
export type { PaginatedQueryOptions, PaginatedQueryValue, PaginationStatus } from "./hooks.js";
|
|
14
21
|
export { Query } from "./query.js";
|
package/src/client/sync.ts
CHANGED
|
@@ -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,
|
|
@@ -25,7 +26,7 @@ import type {
|
|
|
25
26
|
import { failureFromWire, pending, Result } from "@ignotum/contracts/runtime/result";
|
|
26
27
|
import {
|
|
27
28
|
ClientMessageJson,
|
|
28
|
-
|
|
29
|
+
DeploymentCloseCode,
|
|
29
30
|
InvocationId,
|
|
30
31
|
ServerMessageJson,
|
|
31
32
|
SubscriptionId,
|
|
@@ -41,11 +42,13 @@ import {
|
|
|
41
42
|
ConnectionUnavailable,
|
|
42
43
|
InvalidClientMessage,
|
|
43
44
|
InvalidServerMessage,
|
|
44
|
-
|
|
45
|
+
ServerError,
|
|
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,18 +198,22 @@ const makeQueryCache = (
|
|
|
195
198
|
} satisfies QueryCache;
|
|
196
199
|
};
|
|
197
200
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
export const shouldReloadForCloseCode = (code: number): boolean =>
|
|
209
|
-
code === DeploymentChangedCloseCode;
|
|
216
|
+
export const shouldReloadForCloseCode = (code: number): boolean => code === DeploymentCloseCode;
|
|
210
217
|
|
|
211
218
|
const handshakesMatch = (first: SyncHandshake, next: SyncHandshake): boolean =>
|
|
212
219
|
first.appId === next.appId &&
|
|
@@ -242,7 +249,8 @@ const socketUrl = (): string => {
|
|
|
242
249
|
interface SyncClientService {
|
|
243
250
|
readonly mutate: (
|
|
244
251
|
functionAddress: FunctionAddress,
|
|
245
|
-
|
|
252
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Generated reference argument types are deliberately erased at the client transport boundary.
|
|
253
|
+
args: unknown,
|
|
246
254
|
) => Effect.Effect<IgnotumResult<unknown, ErrorValue>, ClientInfrastructureError>;
|
|
247
255
|
readonly observe: (
|
|
248
256
|
functionAddress: FunctionAddress,
|
|
@@ -256,7 +264,11 @@ interface PendingInvocation {
|
|
|
256
264
|
ClientInfrastructureError
|
|
257
265
|
>;
|
|
258
266
|
readonly function: FunctionAddress;
|
|
259
|
-
readonly message: Extract<ClientMessage, { readonly type: "Invoke" }>;
|
|
267
|
+
readonly message: Extract<ClientMessage, { readonly type: "Invoke" | "Prepare" }>;
|
|
268
|
+
readonly prepared: Deferred.Deferred<
|
|
269
|
+
Extract<ServerMessage, { readonly type: "Preparation" }>["uploads"],
|
|
270
|
+
ClientInfrastructureError
|
|
271
|
+
>;
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
export class SyncClient extends Context.Service<SyncClient, SyncClientService>()(
|
|
@@ -319,25 +331,33 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
|
|
|
319
331
|
}
|
|
320
332
|
invocations = HashMap.remove(invocations, id);
|
|
321
333
|
return reportClientError(error).pipe(
|
|
334
|
+
Effect.andThen(Deferred.fail(pendingInvocation.prepared, error)),
|
|
322
335
|
Effect.andThen(Deferred.fail(pendingInvocation.deferred, error)),
|
|
323
336
|
Effect.asVoid,
|
|
324
337
|
);
|
|
325
338
|
};
|
|
326
339
|
|
|
327
340
|
const handleMessage = Effect.fn("SyncClient.handleMessage")(function* (
|
|
328
|
-
message: Exclude<ServerMessage, SyncHandshake | { readonly type: "
|
|
341
|
+
message: Exclude<ServerMessage, SyncHandshake | { readonly type: "Deployment" }>,
|
|
329
342
|
) {
|
|
330
343
|
switch (message.type) {
|
|
331
344
|
case "Snapshot": {
|
|
332
345
|
const entry = queryCache.getById(message.id);
|
|
333
346
|
if (entry === undefined) return;
|
|
334
|
-
yield* resultFromWire(message.result).pipe(
|
|
347
|
+
yield* resultFromWire(message.result, message.files ?? []).pipe(
|
|
335
348
|
Effect.tap((result) =>
|
|
336
349
|
Effect.sync(() => queryCache.setResult(message.id, result, message.revision)),
|
|
337
350
|
),
|
|
338
351
|
);
|
|
339
352
|
return;
|
|
340
353
|
}
|
|
354
|
+
case "Preparation": {
|
|
355
|
+
const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
|
|
356
|
+
if (pendingInvocation !== undefined) {
|
|
357
|
+
yield* Deferred.succeed(pendingInvocation.prepared, message.uploads);
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
341
361
|
case "Result": {
|
|
342
362
|
const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
|
|
343
363
|
if (pendingInvocation === undefined) return;
|
|
@@ -347,14 +367,14 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
|
|
|
347
367
|
);
|
|
348
368
|
return;
|
|
349
369
|
}
|
|
350
|
-
case "
|
|
370
|
+
case "Error": {
|
|
351
371
|
const error =
|
|
352
372
|
message.operation === undefined
|
|
353
|
-
?
|
|
373
|
+
? ServerError.make({
|
|
354
374
|
code: message.code,
|
|
355
375
|
message: message.message,
|
|
356
376
|
})
|
|
357
|
-
:
|
|
377
|
+
: ServerError.make({
|
|
358
378
|
code: message.code,
|
|
359
379
|
message: message.message,
|
|
360
380
|
operation: message.operation,
|
|
@@ -428,7 +448,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
|
|
|
428
448
|
),
|
|
429
449
|
);
|
|
430
450
|
if (message.type === "Handshake") return yield* acceptHandshake(message);
|
|
431
|
-
if (message.type === "
|
|
451
|
+
if (message.type === "Deployment") {
|
|
432
452
|
requestReload();
|
|
433
453
|
return;
|
|
434
454
|
}
|
|
@@ -488,31 +508,139 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
|
|
|
488
508
|
const mutate: SyncClientService["mutate"] = Effect.fn("SyncClient.mutate")(
|
|
489
509
|
function* (functionAddress, args) {
|
|
490
510
|
const invocationId = yield* ids.generate(InvocationId);
|
|
511
|
+
const preparedArguments = yield* prepareMutationArguments(args).pipe(
|
|
512
|
+
Effect.provideService(IdGenerator, ids),
|
|
513
|
+
Effect.mapError((cause) =>
|
|
514
|
+
InvalidMutationArguments.make({
|
|
515
|
+
cause,
|
|
516
|
+
function: functionAddress,
|
|
517
|
+
message: `Mutation arguments for ${functionAddress} contain an unsupported file or value.`,
|
|
518
|
+
}),
|
|
519
|
+
),
|
|
520
|
+
Effect.tapError(reportClientError),
|
|
521
|
+
);
|
|
522
|
+
const jsonArgs = yield* Effect.try({
|
|
523
|
+
try: () => encodeTransportObject(preparedArguments.value),
|
|
524
|
+
catch: (cause) =>
|
|
525
|
+
InvalidMutationArguments.make({
|
|
526
|
+
cause,
|
|
527
|
+
function: functionAddress,
|
|
528
|
+
message: `Mutation arguments for ${functionAddress} contain an unsupported value.`,
|
|
529
|
+
}),
|
|
530
|
+
}).pipe(Effect.tapError(reportClientError));
|
|
491
531
|
const deferred = yield* Deferred.make<
|
|
492
532
|
IgnotumResult<unknown, ErrorValue>,
|
|
493
533
|
ClientInfrastructureError
|
|
494
534
|
>();
|
|
495
|
-
const
|
|
535
|
+
const prepared = yield* Deferred.make<
|
|
536
|
+
Extract<ServerMessage, { readonly type: "Preparation" }>["uploads"],
|
|
537
|
+
ClientInfrastructureError
|
|
538
|
+
>();
|
|
539
|
+
const invokeMessage: Extract<ClientMessage, { readonly type: "Invoke" }> = {
|
|
496
540
|
type: "Invoke",
|
|
497
541
|
id: invocationId,
|
|
498
542
|
kind: "Mutation",
|
|
499
543
|
function: functionAddress,
|
|
500
|
-
args,
|
|
544
|
+
args: jsonArgs,
|
|
501
545
|
};
|
|
546
|
+
const message: PendingInvocation["message"] =
|
|
547
|
+
preparedArguments.fileIds.length === 0
|
|
548
|
+
? invokeMessage
|
|
549
|
+
: {
|
|
550
|
+
type: "Prepare",
|
|
551
|
+
id: invocationId,
|
|
552
|
+
kind: "Mutation",
|
|
553
|
+
function: functionAddress,
|
|
554
|
+
args: jsonArgs,
|
|
555
|
+
files: preparedArguments.fileIds,
|
|
556
|
+
};
|
|
502
557
|
invocations = HashMap.set(invocations, invocationId, {
|
|
503
558
|
deferred,
|
|
504
559
|
function: functionAddress,
|
|
505
560
|
message,
|
|
561
|
+
prepared,
|
|
506
562
|
});
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
563
|
+
return yield* Effect.gen(function* () {
|
|
564
|
+
if (writer !== undefined) {
|
|
565
|
+
yield* sendWith(writer, message).pipe(
|
|
566
|
+
Effect.catchTags({
|
|
567
|
+
ConnectionUnavailable: reportClientError,
|
|
568
|
+
InvalidClientMessage: (error) => rejectInvocation(invocationId, error),
|
|
569
|
+
}),
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (message.type === "Prepare") {
|
|
573
|
+
const uploads = yield* Deferred.await(prepared);
|
|
574
|
+
const expected = new Set(message.files);
|
|
575
|
+
const received = new Set<string>();
|
|
576
|
+
for (const upload of uploads) {
|
|
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);
|
|
585
|
+
if (file === undefined) {
|
|
586
|
+
return yield* InvalidServerMessage.make({
|
|
587
|
+
cause: new Error(`Missing native file '${upload.id}'.`),
|
|
588
|
+
message: "The server prepared an unknown application file.",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
if (upload.url === undefined) continue;
|
|
592
|
+
const uploadUrl = upload.url;
|
|
593
|
+
const response = yield* Effect.tryPromise({
|
|
594
|
+
try: () =>
|
|
595
|
+
fetch(uploadUrl, {
|
|
596
|
+
method: "PUT",
|
|
597
|
+
body: file,
|
|
598
|
+
headers: { "content-type": file.type },
|
|
599
|
+
}),
|
|
600
|
+
catch: (cause) =>
|
|
601
|
+
ConnectionUnavailable.make({
|
|
602
|
+
cause,
|
|
603
|
+
message: `File '${file.name}' could not be uploaded.`,
|
|
604
|
+
}),
|
|
605
|
+
});
|
|
606
|
+
if (!response.ok) {
|
|
607
|
+
const detail = yield* Effect.promise(() => response.text());
|
|
608
|
+
return yield* InvalidMutationArguments.make({
|
|
609
|
+
cause: new Error(detail),
|
|
610
|
+
function: functionAddress,
|
|
611
|
+
message: `File '${file.name}' was rejected by application storage.`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
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
|
+
}
|
|
621
|
+
const pendingInvocation = HashMap.getUnsafe(invocations, invocationId);
|
|
622
|
+
if (pendingInvocation === undefined) return yield* Deferred.await(deferred);
|
|
623
|
+
invocations = HashMap.set(invocations, invocationId, {
|
|
624
|
+
...pendingInvocation,
|
|
625
|
+
message: invokeMessage,
|
|
626
|
+
});
|
|
627
|
+
if (writer !== undefined) {
|
|
628
|
+
yield* sendWith(writer, invokeMessage).pipe(
|
|
629
|
+
Effect.catchTags({
|
|
630
|
+
ConnectionUnavailable: reportClientError,
|
|
631
|
+
InvalidClientMessage: (error) => rejectInvocation(invocationId, error),
|
|
632
|
+
}),
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return yield* Deferred.await(deferred);
|
|
637
|
+
}).pipe(
|
|
638
|
+
Effect.tapError(() =>
|
|
639
|
+
Effect.sync(() => {
|
|
640
|
+
invocations = HashMap.remove(invocations, invocationId);
|
|
512
641
|
}),
|
|
513
|
-
)
|
|
514
|
-
|
|
515
|
-
return yield* Deferred.await(deferred);
|
|
642
|
+
),
|
|
643
|
+
);
|
|
516
644
|
},
|
|
517
645
|
);
|
|
518
646
|
|