ignotum 0.0.7 → 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 +869 -175
  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-Dp4J-xt-.d.ts → api-D9lV-5av.d.ts} +7 -9
  7. package/dist/runtime/client.d.ts +12 -5
  8. package/dist/runtime/client.js +275 -52
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/{descriptor-t6BOEGw9-C1rwYIlx.js → descriptor-C5VA9qRl-C338iC6l.js} +38 -7
  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/{id-Btwac71X-DhnKYsjY.d.ts → id-Btwac71X-DGj0DQuu.d.ts} +50 -4
  15. package/dist/runtime/{index-B5KSOjGN.d.ts → index-CF04_Dps.d.ts} +39 -20
  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 +8 -7
  19. package/dist/runtime/internal/host.js +21 -10
  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-B1BzNkh8-BUSTbeSg.d.ts → pagination-DrOowBve-Bnipd34u.d.ts} +8 -4
  27. package/dist/runtime/{schema-D9RmboaS.js → schema-DJfwfq87.js} +17 -3
  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 +4 -4
  34. package/src/cli/agent-files.ts +52 -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 +2 -15
  40. package/src/client/index.ts +7 -0
  41. package/src/client/sync.ts +137 -21
  42. package/src/dev-runtime/database.ts +69 -57
  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 +14 -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-C1rwYIlx.js.map +0 -1
  51. package/dist/runtime/id-D570vudg.js +0 -26
  52. package/dist/runtime/id-D570vudg.js.map +0 -1
  53. package/dist/runtime/pagination-BKPko9Hm.d.ts +0 -1
  54. package/dist/runtime/schema-D9RmboaS.js.map +0 -1
@@ -207,6 +207,17 @@ Open <http://127.0.0.1:3210>.
207
207
  The app generator creates \`_generated\`. The dev server checks those files before it starts
208
208
  and updates them when the schema or server functions change.
209
209
 
210
+ ## Deploy
211
+
212
+ Set \`IGNOTUM_API_TOKEN\`, then choose a unique slug on the first deployment:
213
+
214
+ \`\`\`sh
215
+ npx ignotum deploy --app my-app
216
+ \`\`\`
217
+
218
+ The command prints the hosted URL and records the app link in \`.ignotum/app.json\`. Later
219
+ deployments use \`npx ignotum deploy\` without the \`--app\` flag.
220
+
210
221
  ## App files
211
222
 
212
223
  - \`server/schema.ts\` defines the database tables.
@@ -216,6 +227,7 @@ and updates them when the schema or server functions change.
216
227
  - Ignotum loads Tailwind CSS automatically. Custom CSS files are ordinary client modules.
217
228
  - \`shared/utils.ts\` contains code shared across the app.
218
229
  - \`_generated\` contains Ignotum's generated types and bindings. Do not edit it by hand.
230
+ - \`.agents/skills/ignotum\` contains the app skill and references used by coding agents.
219
231
 
220
232
  Run the typechecker after a change:
221
233
 
@@ -224,6 +236,9 @@ npx ignotum codegen
224
236
  npx tsc --noEmit
225
237
  \`\`\`
226
238
 
239
+ The [Ignotum documentation](https://docs.ignotum.cloud) covers schema values, indexes, queries,
240
+ mutations, client hooks, deployment, guarantees, and hosted limits.
241
+
227
242
  ## Claude Code
228
243
 
229
244
  If you use Claude Code, rename \`AGENTS.md\` to \`CLAUDE.md\` and \`.agents\` to \`.claude\` so it
@@ -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
+ };
@@ -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, jsonArgs)) as unknown as SettledResult<
280
+ return (yield* client.mutate(functionPath, input)) as unknown as SettledResult<
294
281
  Success,
295
282
  Failure
296
283
  >;
@@ -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";
@@ -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
 
@@ -47,6 +47,7 @@ import {
47
47
  SchemaDefinitionTypeId,
48
48
  type ValueDescriptor,
49
49
  } from "@ignotum/contracts/schema";
50
+ import type { FileValue } from "@ignotum/contracts/schema/file";
50
51
 
51
52
  import { DevelopmentDatabase } from "./migrations.js";
52
53
 
@@ -59,6 +60,7 @@ type RuntimeDocumentValue =
59
60
  | number
60
61
  | string
61
62
  | Date
63
+ | FileValue
62
64
  | ReadonlyArray<RuntimeDocumentValue>
63
65
  | RuntimeObject;
64
66
  type RuntimeValue = RuntimeObject;
@@ -98,10 +100,18 @@ export interface LocalDatabaseReader {
98
100
  }
99
101
 
100
102
  export interface LocalDatabaseWriter extends LocalDatabaseReader {
101
- readonly delete: (tableName: string, id: string) => Result<void, never>;
103
+ readonly delete: (tableName: string, id: string) => Result<void, DocumentNotFoundValue>;
102
104
  readonly insert: (tableName: string, value: RuntimeValue) => Result<GeneratedIdType, never>;
103
- readonly patch: (tableName: string, id: string, value: RuntimeValue) => Result<void, never>;
104
- readonly replace: (tableName: string, id: string, value: RuntimeValue) => Result<void, never>;
105
+ readonly patch: (
106
+ tableName: string,
107
+ id: string,
108
+ value: RuntimeValue,
109
+ ) => Result<void, DocumentNotFoundValue>;
110
+ readonly replace: (
111
+ tableName: string,
112
+ id: string,
113
+ value: RuntimeValue,
114
+ ) => Result<void, DocumentNotFoundValue>;
105
115
  }
106
116
 
107
117
  export interface LocalQueryContext {
@@ -722,11 +732,14 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
722
732
  id: string,
723
733
  invalidations: DependencyRecorder<WriteInvalidation>,
724
734
  ) {
725
- const table = yield* resolveTable(schema, tableName);
735
+ const table = yield* resolveTable(schema, tableName).pipe(Effect.orDie);
726
736
  const documentId = GeneratedId.make(id);
727
- const stored = yield* findStored({ id, tableId: table.id });
728
- const keys = Option.isSome(stored) ? yield* indexKeys(table, stored.value) : [];
729
- yield* sql`DELETE FROM documents WHERE id = ${id} AND tableId = ${table.id}`;
737
+ const stored = yield* findStored({ id, tableId: table.id }).pipe(Effect.orDie);
738
+ if (Option.isNone(stored)) return yield* Effect.fail(documentNotFound(tableName, id));
739
+ const keys = yield* indexKeys(table, stored.value).pipe(Effect.orDie);
740
+ yield* sql`DELETE FROM documents WHERE id = ${id} AND tableId = ${table.id}`.pipe(
741
+ Effect.orDie,
742
+ );
730
743
  invalidations.record(
731
744
  tableDependency(table.id),
732
745
  documentDependency(table.id, documentId),
@@ -741,32 +754,35 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
741
754
  value: RuntimeValue,
742
755
  invalidations: DependencyRecorder<WriteInvalidation>,
743
756
  ) {
744
- const table = yield* resolveTable(schema, tableName);
757
+ const table = yield* resolveTable(schema, tableName).pipe(Effect.orDie);
745
758
  const documentId = GeneratedId.make(id);
746
- const stored = yield* findStored({ id, tableId: table.id });
747
- const oldKeys = Option.isSome(stored) ? yield* indexKeys(table, stored.value) : [];
748
- let newKeys = oldKeys.slice(0, 0);
749
- if (Option.isSome(stored)) {
750
- const current = yield* decodeFields(table.definition, tableName, stored.value, "patch");
751
- const fields = yield* encodeFields(
752
- table.definition,
753
- tableName,
754
- { ...current, ...value },
755
- "patch",
756
- id,
757
- );
758
- const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
759
- yield* sql`
760
- UPDATE documents
761
- SET fields = ${fields}, updatedAt = ${updatedAt}
762
- WHERE id = ${id} AND tableId = ${table.id}
763
- `;
764
- newKeys = yield* writeIndexEntries(table, {
765
- ...stored.value,
766
- fields,
767
- updatedAt,
768
- });
769
- }
759
+ const stored = yield* findStored({ id, tableId: table.id }).pipe(Effect.orDie);
760
+ if (Option.isNone(stored)) return yield* Effect.fail(documentNotFound(tableName, id));
761
+ const oldKeys = yield* indexKeys(table, stored.value).pipe(Effect.orDie);
762
+ const current = yield* decodeFields(
763
+ table.definition,
764
+ tableName,
765
+ stored.value,
766
+ "patch",
767
+ ).pipe(Effect.orDie);
768
+ const fields = yield* encodeFields(
769
+ table.definition,
770
+ tableName,
771
+ { ...current, ...value },
772
+ "patch",
773
+ id,
774
+ ).pipe(Effect.orDie);
775
+ const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
776
+ yield* sql`
777
+ UPDATE documents
778
+ SET fields = ${fields}, updatedAt = ${updatedAt}
779
+ WHERE id = ${id} AND tableId = ${table.id}
780
+ `.pipe(Effect.orDie);
781
+ const newKeys = yield* writeIndexEntries(table, {
782
+ ...stored.value,
783
+ fields,
784
+ updatedAt,
785
+ }).pipe(Effect.orDie);
770
786
  invalidations.record(
771
787
  tableDependency(table.id),
772
788
  documentDependency(table.id, documentId),
@@ -783,25 +799,25 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
783
799
  value: RuntimeValue,
784
800
  invalidations: DependencyRecorder<WriteInvalidation>,
785
801
  ) {
786
- const table = yield* resolveTable(schema, tableName);
802
+ const table = yield* resolveTable(schema, tableName).pipe(Effect.orDie);
787
803
  const documentId = GeneratedId.make(id);
788
- const stored = yield* findStored({ id, tableId: table.id });
789
- const oldKeys = Option.isSome(stored) ? yield* indexKeys(table, stored.value) : [];
790
- let newKeys = oldKeys.slice(0, 0);
791
- if (Option.isSome(stored)) {
792
- const fields = yield* encodeFields(table.definition, tableName, value, "replace", id);
793
- const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
794
- yield* sql`
795
- UPDATE documents
796
- SET fields = ${fields}, updatedAt = ${updatedAt}
797
- WHERE id = ${id} AND tableId = ${table.id}
798
- `;
799
- newKeys = yield* writeIndexEntries(table, {
800
- ...stored.value,
801
- fields,
802
- updatedAt,
803
- });
804
- }
804
+ const stored = yield* findStored({ id, tableId: table.id }).pipe(Effect.orDie);
805
+ if (Option.isNone(stored)) return yield* Effect.fail(documentNotFound(tableName, id));
806
+ const oldKeys = yield* indexKeys(table, stored.value).pipe(Effect.orDie);
807
+ const fields = yield* encodeFields(table.definition, tableName, value, "replace", id).pipe(
808
+ Effect.orDie,
809
+ );
810
+ const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
811
+ yield* sql`
812
+ UPDATE documents
813
+ SET fields = ${fields}, updatedAt = ${updatedAt}
814
+ WHERE id = ${id} AND tableId = ${table.id}
815
+ `.pipe(Effect.orDie);
816
+ const newKeys = yield* writeIndexEntries(table, {
817
+ ...stored.value,
818
+ fields,
819
+ updatedAt,
820
+ }).pipe(Effect.orDie);
805
821
  invalidations.record(
806
822
  tableDependency(table.id),
807
823
  documentDependency(table.id, documentId),
@@ -994,17 +1010,13 @@ export class LocalDatabase extends Context.Service<LocalDatabase, LocalDatabaseS
994
1010
  return Object.freeze({
995
1011
  ...reader,
996
1012
  delete: (tableName: string, id: string) =>
997
- resultFromEffect(
998
- deleteDocument(schema, tableName, id, invalidations).pipe(Effect.orDie),
999
- ),
1013
+ resultFromEffect(deleteDocument(schema, tableName, id, invalidations)),
1000
1014
  insert: (tableName: string, value: RuntimeValue) =>
1001
1015
  resultFromEffect(insert(schema, tableName, value, invalidations).pipe(Effect.orDie)),
1002
1016
  patch: (tableName: string, id: string, value: RuntimeValue) =>
1003
- resultFromEffect(patch(schema, tableName, id, value, invalidations).pipe(Effect.orDie)),
1017
+ resultFromEffect(patch(schema, tableName, id, value, invalidations)),
1004
1018
  replace: (tableName: string, id: string, value: RuntimeValue) =>
1005
- resultFromEffect(
1006
- replace(schema, tableName, id, value, invalidations).pipe(Effect.orDie),
1007
- ),
1019
+ resultFromEffect(replace(schema, tableName, id, value, invalidations)),
1008
1020
  });
1009
1021
  };
1010
1022