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
@@ -0,0 +1,338 @@
1
+ import { encodeCanonicalJson } from "@ignotum/contracts/json";
2
+ import { validateApplicationFile } from "@ignotum/contracts/schema/file-content";
3
+ import {
4
+ FileGrantToken,
5
+ FileId,
6
+ FileUploadToken,
7
+ RuntimeFileOccurrence,
8
+ encodedFileOccurrencesOf,
9
+ fileLimits,
10
+ type RuntimeFileMetadata,
11
+ type RuntimeFileOccurrence as RuntimeFileOccurrenceValue,
12
+ } from "@ignotum/contracts/schema/file";
13
+ import type { FunctionAddress, SubscriptionId } from "@ignotum/contracts/runtime/sync";
14
+ import { fileGrantUrlPrefix, fileUploadUrlPrefix } from "@ignotum/contracts/runtime/transport";
15
+ import type { InvocationId } from "@ignotum/contracts/runtime/identity";
16
+ import { IdGenerator } from "@ignotum/shared/id";
17
+ import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect";
18
+ import { SqlClient } from "effect/unstable/sql";
19
+
20
+ interface Preparation {
21
+ readonly function: FunctionAddress;
22
+ readonly args: Schema.Json;
23
+ readonly files: ReadonlyArray<RuntimeFileOccurrenceValue>;
24
+ readonly uploads: ReadonlyMap<FileId, FileUploadToken>;
25
+ }
26
+
27
+ interface Grant {
28
+ readonly subscriptionId: SubscriptionId;
29
+ readonly file: RuntimeFileMetadata;
30
+ }
31
+
32
+ const StoredFile = Schema.Struct({
33
+ fileId: FileId,
34
+ name: Schema.String,
35
+ format: RuntimeFileOccurrence.fields.file.fields.format,
36
+ size: Schema.Natural,
37
+ ready: Schema.Literals([0, 1]),
38
+ });
39
+
40
+ const StoredDocumentFields = Schema.Struct({ fields: Schema.String });
41
+ const JsonObjectString = Schema.fromJsonString(Schema.JsonObject);
42
+
43
+ interface LocalApplicationFilesService {
44
+ readonly prepare: (
45
+ invocationId: InvocationId,
46
+ functionAddress: FunctionAddress,
47
+ args: Schema.Json,
48
+ files: ReadonlyArray<RuntimeFileOccurrenceValue>,
49
+ ) => Effect.Effect<ReadonlyArray<RuntimeFileOccurrenceValue & { readonly url?: string }>>;
50
+ readonly admit: (
51
+ invocationId: InvocationId,
52
+ functionAddress: FunctionAddress,
53
+ args: Schema.Json,
54
+ ) => Effect.Effect<ReadonlyArray<RuntimeFileOccurrenceValue>>;
55
+ readonly upload: (token: FileUploadToken, bytes: Uint8Array) => Effect.Effect<string>;
56
+ readonly grants: (
57
+ subscriptionId: SubscriptionId,
58
+ files: ReadonlyArray<RuntimeFileOccurrenceValue>,
59
+ ) => Effect.Effect<ReadonlyArray<RuntimeFileOccurrenceValue & { readonly url: string }>>;
60
+ readonly releaseGrants: (subscriptionId: SubscriptionId) => Effect.Effect<void>;
61
+ readonly readGrant: (
62
+ token: FileGrantToken,
63
+ ) => Effect.Effect<
64
+ Option.Option<{ readonly file: RuntimeFileMetadata; readonly bytes: Uint8Array }>
65
+ >;
66
+ readonly reconcile: Effect.Effect<void>;
67
+ readonly releasePreparation: (invocationId: InvocationId) => Effect.Effect<void>;
68
+ }
69
+
70
+ export const LocalApplicationFiles = Context.Reference<LocalApplicationFilesService>(
71
+ "ignotum/dev-runtime/files/LocalApplicationFiles",
72
+ {
73
+ defaultValue: () => ({
74
+ admit: (_invocationId, _functionAddress, args) =>
75
+ Effect.succeed(encodedFileOccurrencesOf(args)),
76
+ grants: (_subscriptionId, files) =>
77
+ Effect.succeed(files.map((file) => ({ ...file, url: "" }))),
78
+ prepare: (_invocationId, _functionAddress, _args, files) => Effect.succeed(files),
79
+ upload: () => Effect.die("Local application file storage is unavailable."),
80
+ releaseGrants: () => Effect.void,
81
+ readGrant: () => Effect.succeed(Option.none()),
82
+ reconcile: Effect.void,
83
+ releasePreparation: () => Effect.void,
84
+ }),
85
+ },
86
+ );
87
+
88
+ const sameFile = (left: RuntimeFileMetadata, right: RuntimeFileMetadata): boolean =>
89
+ left.id === right.id &&
90
+ left.format === right.format &&
91
+ left.name === right.name &&
92
+ left.size === right.size;
93
+
94
+ const validFilename = (name: string): boolean => {
95
+ const bytes = new TextEncoder().encode(name).byteLength;
96
+ const hasControlCharacter = Array.from(name).some((character) => {
97
+ const codePoint = character.codePointAt(0) ?? 0;
98
+ return codePoint <= 0x1f || codePoint === 0x7f;
99
+ });
100
+ return (
101
+ bytes > 0 &&
102
+ bytes <= fileLimits.filenameBytes &&
103
+ !hasControlCharacter &&
104
+ !name.includes("/") &&
105
+ !name.includes("\\")
106
+ );
107
+ };
108
+
109
+ export const localApplicationFilesLayer = (appDirectory: string) =>
110
+ Layer.effect(
111
+ LocalApplicationFiles,
112
+ Effect.gen(function* () {
113
+ const ids = yield* IdGenerator;
114
+ const fileSystem = yield* FileSystem.FileSystem;
115
+ const path = yield* Path.Path;
116
+ const sql = yield* SqlClient.SqlClient;
117
+ const directory = path.join(appDirectory, ".ignotum", "files");
118
+ yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie);
119
+ const preparations = new Map<InvocationId, Preparation>();
120
+ const uploadFiles = new Map<FileUploadToken, RuntimeFileMetadata>();
121
+ const grants = new Map<FileGrantToken, Grant>();
122
+ const objectPath = (fileId: FileId) => path.join(directory, fileId);
123
+
124
+ const prepare = Effect.fn("LocalApplicationFiles.prepare")(function* (
125
+ invocationId: InvocationId,
126
+ functionAddress: FunctionAddress,
127
+ args: Schema.Json,
128
+ files: ReadonlyArray<RuntimeFileOccurrenceValue>,
129
+ ) {
130
+ const existing = preparations.get(invocationId);
131
+ if (existing !== undefined) {
132
+ if (
133
+ existing.function !== functionAddress ||
134
+ encodeCanonicalJson(existing.args) !== encodeCanonicalJson(args) ||
135
+ encodeCanonicalJson(existing.files) !== encodeCanonicalJson(files)
136
+ ) {
137
+ throw new Error("The invocation ID already belongs to another file preparation.");
138
+ }
139
+ return files.map((occurrence) => {
140
+ const token = existing.uploads.get(occurrence.file.id);
141
+ return token === undefined
142
+ ? occurrence
143
+ : { ...occurrence, url: `${fileUploadUrlPrefix}${token}` };
144
+ });
145
+ }
146
+ const all = new Map(
147
+ encodedFileOccurrencesOf(args).map((occurrence) => [
148
+ encodeCanonicalJson(occurrence.path),
149
+ occurrence.file,
150
+ ]),
151
+ );
152
+ const distinct = new Map<FileId, RuntimeFileMetadata>();
153
+ for (const occurrence of files) {
154
+ const arg = all.get(encodeCanonicalJson(occurrence.path));
155
+ if (
156
+ arg === undefined ||
157
+ !sameFile(arg, occurrence.file) ||
158
+ !validFilename(occurrence.file.name)
159
+ ) {
160
+ throw new Error("Prepared files do not match the mutation arguments.");
161
+ }
162
+ const prior = distinct.get(occurrence.file.id);
163
+ if (prior !== undefined && !sameFile(prior, occurrence.file)) {
164
+ throw new Error("One file ID has conflicting metadata.");
165
+ }
166
+ distinct.set(occurrence.file.id, occurrence.file);
167
+ }
168
+ if (distinct.size > fileLimits.filesPerMutation) throw new Error("Too many files.");
169
+ const totalBytes = [...distinct.values()].reduce((total, file) => total + file.size, 0);
170
+ if (totalBytes > fileLimits.mutationBytes) throw new Error("File uploads are too large.");
171
+
172
+ const staged = yield* sql<{ readonly bytes: number }>`
173
+ SELECT COALESCE(SUM(size), 0) AS bytes FROM applicationFiles WHERE ready = 0
174
+ `;
175
+ let additionalStagedBytes = 0;
176
+ for (const file of distinct.values()) {
177
+ const rows = yield* sql<typeof StoredFile.Type>`
178
+ SELECT fileId, name, format, size, ready FROM applicationFiles WHERE fileId = ${file.id}
179
+ `;
180
+ const stored = rows[0];
181
+ if (stored === undefined) additionalStagedBytes += file.size;
182
+ else if (
183
+ !sameFile(
184
+ {
185
+ id: stored.fileId,
186
+ name: stored.name,
187
+ format: stored.format,
188
+ size: stored.size,
189
+ },
190
+ file,
191
+ )
192
+ ) {
193
+ throw new Error(`File '${file.id}' has conflicting metadata.`);
194
+ }
195
+ }
196
+ if ((staged[0]?.bytes ?? 0) + additionalStagedBytes > fileLimits.stagedBytesPerApp) {
197
+ throw new Error("The app has too many staged file bytes.");
198
+ }
199
+ const uploads = new Map<FileId, FileUploadToken>();
200
+ for (const file of distinct.values()) {
201
+ const rows = yield* sql<typeof StoredFile.Type>`
202
+ SELECT fileId, name, format, size, ready FROM applicationFiles WHERE fileId = ${file.id}
203
+ `;
204
+ const stored = rows[0];
205
+ if (stored !== undefined && stored.ready === 1) continue;
206
+ const token = yield* ids.generate(FileUploadToken);
207
+ uploads.set(file.id, token);
208
+ uploadFiles.set(token, file);
209
+ yield* sql`
210
+ INSERT INTO applicationFiles (fileId, name, format, size, ready)
211
+ VALUES (${file.id}, ${file.name}, ${file.format}, ${file.size}, 0)
212
+ ON CONFLICT(fileId) DO UPDATE SET
213
+ name = excluded.name, format = excluded.format, size = excluded.size
214
+ `;
215
+ }
216
+ preparations.set(invocationId, { function: functionAddress, args, files, uploads });
217
+ return files.map((occurrence) => {
218
+ const token = uploads.get(occurrence.file.id);
219
+ return token === undefined
220
+ ? occurrence
221
+ : { ...occurrence, url: `${fileUploadUrlPrefix}${token}` };
222
+ });
223
+ });
224
+
225
+ const admit = Effect.fn("LocalApplicationFiles.admit")(function* (
226
+ invocationId: InvocationId,
227
+ functionAddress: FunctionAddress,
228
+ args: Schema.Json,
229
+ ) {
230
+ const preparation = preparations.get(invocationId);
231
+ if (
232
+ preparation !== undefined &&
233
+ (preparation.function !== functionAddress ||
234
+ encodeCanonicalJson(preparation.args) !== encodeCanonicalJson(args))
235
+ ) {
236
+ throw new Error("The mutation does not match its file preparation.");
237
+ }
238
+ for (const occurrence of preparation?.files ?? []) {
239
+ const rows = yield* sql<{ readonly ready: number }>`
240
+ SELECT ready FROM applicationFiles WHERE fileId = ${occurrence.file.id}
241
+ `;
242
+ if (rows[0]?.ready !== 1) throw new Error("A prepared file has not finished uploading.");
243
+ }
244
+ const occurrences = encodedFileOccurrencesOf(args);
245
+ for (const occurrence of occurrences) {
246
+ const rows = yield* sql<{ readonly ready: number }>`
247
+ SELECT ready FROM applicationFiles WHERE fileId = ${occurrence.file.id}
248
+ `;
249
+ if (rows[0]?.ready !== 1) throw new Error(`File '${occurrence.file.id}' is unavailable.`);
250
+ }
251
+ return occurrences;
252
+ });
253
+
254
+ const upload = Effect.fn("LocalApplicationFiles.upload")(function* (
255
+ token: FileUploadToken,
256
+ bytes: Uint8Array,
257
+ ) {
258
+ const file = uploadFiles.get(token);
259
+ if (file === undefined) throw new Error("The local file upload ticket is invalid.");
260
+ const validation = validateApplicationFile(bytes, file.format, file.size);
261
+ if (!validation.valid) throw new Error(validation.message);
262
+ yield* fileSystem.writeFile(objectPath(file.id), bytes).pipe(Effect.orDie);
263
+ yield* sql`UPDATE applicationFiles SET ready = 1 WHERE fileId = ${file.id}`;
264
+ uploadFiles.delete(token);
265
+ return "Stored";
266
+ });
267
+
268
+ const grantFiles = Effect.fn("LocalApplicationFiles.grants")(function* (
269
+ subscriptionId: SubscriptionId,
270
+ occurrences: ReadonlyArray<RuntimeFileOccurrenceValue>,
271
+ ) {
272
+ for (const [token, grant] of grants) {
273
+ if (grant.subscriptionId === subscriptionId) grants.delete(token);
274
+ }
275
+ const tokens = new Map<FileId, FileGrantToken>();
276
+ for (const occurrence of occurrences) {
277
+ if (tokens.has(occurrence.file.id)) continue;
278
+ const token = yield* ids.generate(FileGrantToken);
279
+ tokens.set(occurrence.file.id, token);
280
+ grants.set(token, { subscriptionId, file: occurrence.file });
281
+ }
282
+ return occurrences.map((occurrence) => ({
283
+ ...occurrence,
284
+ url: `${fileGrantUrlPrefix}${tokens.get(occurrence.file.id)}`,
285
+ }));
286
+ });
287
+
288
+ const reconcile = Effect.gen(function* () {
289
+ const rows = yield* sql<typeof StoredDocumentFields.Type>`
290
+ SELECT fields FROM documents
291
+ `;
292
+ const referenced = new Set<FileId>();
293
+ for (const row of rows) {
294
+ const fields = yield* Schema.decodeEffect(JsonObjectString)(row.fields).pipe(
295
+ Effect.orDie,
296
+ );
297
+ for (const occurrence of encodedFileOccurrencesOf(fields))
298
+ referenced.add(occurrence.file.id);
299
+ }
300
+ const stored = yield* sql<typeof StoredFile.Type>`
301
+ SELECT fileId, name, format, size, ready FROM applicationFiles
302
+ `;
303
+ for (const file of stored) {
304
+ if (referenced.has(file.fileId)) continue;
305
+ yield* fileSystem.remove(objectPath(file.fileId), { force: true }).pipe(Effect.orDie);
306
+ yield* sql`DELETE FROM applicationFiles WHERE fileId = ${file.fileId}`;
307
+ }
308
+ });
309
+
310
+ return LocalApplicationFiles.of({
311
+ admit: (...args) => admit(...args).pipe(Effect.orDie),
312
+ grants: grantFiles,
313
+ readGrant: (token) => {
314
+ const grant = grants.get(token);
315
+ return grant === undefined
316
+ ? Effect.succeed(Option.none())
317
+ : fileSystem.readFile(objectPath(grant.file.id)).pipe(
318
+ Effect.orDie,
319
+ Effect.map((bytes) => Option.some({ file: grant.file, bytes })),
320
+ );
321
+ },
322
+ reconcile: reconcile.pipe(Effect.orDie),
323
+ releaseGrants: (subscriptionId) =>
324
+ Effect.sync(() => {
325
+ for (const [token, grant] of grants) {
326
+ if (grant.subscriptionId === subscriptionId) grants.delete(token);
327
+ }
328
+ }),
329
+ releasePreparation: (invocationId) =>
330
+ Effect.suspend(() => {
331
+ if (!preparations.delete(invocationId)) return Effect.void;
332
+ return reconcile.pipe(Effect.orDie);
333
+ }),
334
+ prepare: (...args) => prepare(...args).pipe(Effect.orDie),
335
+ upload: (...args) => upload(...args).pipe(Effect.orDie),
336
+ });
337
+ }),
338
+ );
@@ -10,6 +10,7 @@ import { type RuntimeSchemaDefinition } from "@ignotum/contracts/runtime/schema"
10
10
  import { ErrorValueSchema, type ErrorValue } from "@ignotum/contracts/runtime/result";
11
11
  import { RequestId } from "@ignotum/contracts/runtime/id";
12
12
  import { IdGenerator } from "@ignotum/shared/id";
13
+ import { encodedFileOccurrencesOf } from "@ignotum/contracts/schema/file";
13
14
  import type { RuntimeInvocationResult } from "@ignotum/contracts/runtime/hosted";
14
15
  import {
15
16
  FunctionAddress,
@@ -176,12 +177,19 @@ export class FunctionExecutor extends Context.Service<
176
177
  kind === "Query"
177
178
  ? database.trackedQueryTransaction(resolved.schema, invoke).pipe(
178
179
  Effect.map(
179
- ({ dependencies, observedRevision, value }): RuntimeInvocationResult => ({
180
- type: "Query",
181
- result: value,
182
- dependencies,
183
- observedRevision,
184
- }),
180
+ ({ dependencies, observedRevision, value }): RuntimeInvocationResult => {
181
+ const files =
182
+ value.type === "Success" && value.value !== undefined
183
+ ? encodedFileOccurrencesOf(value.value)
184
+ : [];
185
+ const result = {
186
+ type: "Query",
187
+ result: value,
188
+ dependencies,
189
+ observedRevision,
190
+ } as const;
191
+ return files.length === 0 ? result : { ...result, files };
192
+ },
185
193
  ),
186
194
  )
187
195
  : mutationSemaphore.withPermits(1)(
@@ -75,9 +75,23 @@ const addDevelopmentIndexes = Effect.gen(function* () {
75
75
  `;
76
76
  });
77
77
 
78
+ const addApplicationFiles = Effect.gen(function* () {
79
+ const sql = yield* SqlClient.SqlClient;
80
+ yield* sql`
81
+ CREATE TABLE applicationFiles (
82
+ fileId TEXT PRIMARY KEY NOT NULL,
83
+ name TEXT NOT NULL,
84
+ format TEXT NOT NULL CHECK (format IN ('jpeg', 'png', 'webp', 'avif', 'gif')),
85
+ size INTEGER NOT NULL CHECK (size >= 0),
86
+ ready INTEGER NOT NULL DEFAULT 0 CHECK (ready IN (0, 1))
87
+ ) STRICT
88
+ `;
89
+ });
90
+
78
91
  export const developmentMigrationLoader = Migrator.fromRecord({
79
92
  "0001_initial_development_schema": initialDevelopmentSchema,
80
93
  "0002_application_indexes": addDevelopmentIndexes,
94
+ "0003_application_files": addApplicationFiles,
81
95
  });
82
96
 
83
97
  interface DevelopmentDatabaseService {
@@ -44,6 +44,15 @@ import { SqlClient, SqlError, SqlSchema } from "effect/unstable/sql";
44
44
  import type * as Socket from "effect/unstable/socket/Socket";
45
45
  import type { Plugin, ViteDevServer } from "vite";
46
46
  import { FunctionRuntime, type PreparedFunction } from "@ignotum/runtime/functions";
47
+ import {
48
+ FileGrantToken,
49
+ FileUploadToken,
50
+ encodedFileOccurrencesOf,
51
+ fileLimits,
52
+ fileMimeTypes,
53
+ } from "@ignotum/contracts/schema/file";
54
+ import { fileGrantUrlPrefix, fileUploadUrlPrefix } from "@ignotum/contracts/runtime/transport";
55
+ import { collectStreamBytes } from "@ignotum/shared/http-body";
47
56
  import {
48
57
  makeDependencyIndex,
49
58
  QueryInvalidation,
@@ -56,6 +65,7 @@ import { LocalDatabase } from "./database.js";
56
65
  import { idGeneratorLayer } from "./id.js";
57
66
  import { DevelopmentDatabase } from "./migrations.js";
58
67
  import { FunctionExecutor, FunctionRegistry, functionRuntimeLayer } from "./functions.js";
68
+ import { LocalApplicationFiles, localApplicationFilesLayer } from "./files.js";
59
69
 
60
70
  interface QuerySubscription {
61
71
  readonly args: Schema.Json;
@@ -186,6 +196,7 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
186
196
  const runtime = yield* FunctionRuntime;
187
197
  const invalidation = yield* QueryInvalidation;
188
198
  const mutationReplay = yield* MutationReplay;
199
+ const files = yield* LocalApplicationFiles;
189
200
  const subscriptions = yield* Ref.make(HashMap.empty<SubscriptionId, QuerySubscription>());
190
201
  const dependencyIndex = makeDependencyIndex<SubscriptionId>();
191
202
  const invocationFibers = yield* FiberSet.make();
@@ -268,12 +279,14 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
268
279
  }
269
280
  dependencyIndex.record(subscriptionId, result.dependencies);
270
281
  if ((yield* invalidation.latestRevision) > result.observedRevision) continue;
271
- yield* deliver({
282
+ const granted = yield* files.grants(subscriptionId, result.files ?? []);
283
+ const snapshot = {
272
284
  type: "Snapshot",
273
285
  id: subscriptionId,
274
286
  result: result.result,
275
287
  revision: result.observedRevision,
276
- });
288
+ } as const;
289
+ yield* deliver(granted.length === 0 ? snapshot : { ...snapshot, files: granted });
277
290
  return;
278
291
  }
279
292
  });
@@ -325,6 +338,12 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
325
338
  message: Extract<ClientMessage, { readonly type: "Subscribe" }>,
326
339
  ) {
327
340
  const operation = operationForSubscription(message.id);
341
+ if (encodedFileOccurrencesOf(message.args).length > 0) {
342
+ yield* send(
343
+ protocolError("InvalidArguments", "Queries cannot take files as arguments.", operation),
344
+ );
345
+ return;
346
+ }
328
347
  const current = yield* Ref.get(subscriptions);
329
348
 
330
349
  if (HashMap.has(current, message.id)) {
@@ -365,6 +384,22 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
365
384
  message: Extract<ClientMessage, { readonly type: "Invoke" }>,
366
385
  ) {
367
386
  const operation: Operation = { type: "Invocation", id: message.id };
387
+ const admitted = yield* files.admit(message.id, message.function, message.args).pipe(
388
+ Effect.as(true),
389
+ Effect.catchCause((cause) =>
390
+ send(
391
+ protocolError(
392
+ "InvalidArguments",
393
+ `The mutation files could not be admitted: ${String(cause)}`,
394
+ operation,
395
+ ),
396
+ ).pipe(Effect.as(false)),
397
+ ),
398
+ );
399
+ if (!admitted) {
400
+ yield* files.releasePreparation(message.id);
401
+ return;
402
+ }
368
403
  const prepared = yield* runtime.prepare(message.function, "Mutation", message.args).pipe(
369
404
  Effect.catchTags({
370
405
  FunctionUnavailable: (error) => sendResolutionError(operation, error),
@@ -375,6 +410,7 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
375
410
  );
376
411
 
377
412
  if (prepared === undefined) {
413
+ yield* files.releasePreparation(message.id);
378
414
  return;
379
415
  }
380
416
 
@@ -425,6 +461,7 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
425
461
  ),
426
462
  ),
427
463
  }),
464
+ Effect.ensuring(files.releasePreparation(message.id)),
428
465
  Effect.catchCause((cause) =>
429
466
  Effect.logError("A mutation invocation fiber failed.").pipe(
430
467
  Effect.annotateLogs({ cause, function: message.function, requestId: message.id }),
@@ -435,6 +472,24 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
435
472
  );
436
473
  });
437
474
 
475
+ const handlePrepareMutation = Effect.fn("SyncServer.handlePrepareMutation")(function* (
476
+ message: Extract<ClientMessage, { readonly type: "PrepareMutation" }>,
477
+ ) {
478
+ const operation: Operation = { type: "Invocation", id: message.id };
479
+ yield* files.prepare(message.id, message.function, message.args, message.files).pipe(
480
+ Effect.flatMap((uploads) => send({ type: "MutationPrepared", id: message.id, uploads })),
481
+ Effect.catchCause((cause) =>
482
+ send(
483
+ protocolError(
484
+ "InvalidArguments",
485
+ `The mutation files could not be prepared: ${String(cause)}`,
486
+ operation,
487
+ ),
488
+ ),
489
+ ),
490
+ );
491
+ });
492
+
438
493
  const handleMessage = Effect.fn("SyncServer.handleMessage")(function* (text: string) {
439
494
  const message = yield* Schema.decodeEffect(ClientMessageJson)(text).pipe(
440
495
  Effect.catch(() =>
@@ -453,6 +508,10 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
453
508
  case "Unsubscribe":
454
509
  dependencyIndex.remove(message.id);
455
510
  yield* Ref.update(subscriptions, HashMap.remove(message.id));
511
+ yield* files.releaseGrants(message.id);
512
+ return;
513
+ case "PrepareMutation":
514
+ yield* handlePrepareMutation(message);
456
515
  return;
457
516
  case "Invoke":
458
517
  yield* handleInvoke(message);
@@ -460,9 +519,22 @@ export const runSession = Effect.fn("SyncServer.runSession")(function* (socket:
460
519
  }
461
520
  });
462
521
 
463
- yield* socket.runString((text) => messageSemaphore.withPermits(1)(handleMessage(text)), {
464
- onOpen: send({ type: "Handshake", ...localSyncIdentity }).pipe(Effect.orDie),
465
- });
522
+ yield* socket
523
+ .runString((text) => messageSemaphore.withPermits(1)(handleMessage(text)), {
524
+ onOpen: send({ type: "Handshake", ...localSyncIdentity }).pipe(Effect.orDie),
525
+ })
526
+ .pipe(
527
+ Effect.ensuring(
528
+ Ref.get(subscriptions).pipe(
529
+ Effect.flatMap((active) =>
530
+ Effect.forEach(HashMap.keys(active), (subscriptionId) =>
531
+ files.releaseGrants(subscriptionId),
532
+ ),
533
+ ),
534
+ Effect.asVoid,
535
+ ),
536
+ ),
537
+ );
466
538
  });
467
539
 
468
540
  interface SyncHandlersService {
@@ -500,6 +572,10 @@ const makeHandlersLayer = (server: ViteDevServer, appDirectory: string, database
500
572
  queryInvalidationLayer,
501
573
  persistenceLayer,
502
574
  NodeServices.layer,
575
+ localApplicationFilesLayer(appDirectory).pipe(
576
+ Layer.provideMerge(DevelopmentDatabase.layer),
577
+ Layer.provide(Layer.mergeAll(idGeneratorLayer, sqliteLayer, NodeServices.layer)),
578
+ ),
503
579
  );
504
580
 
505
581
  return Layer.effect(
@@ -522,6 +598,48 @@ const makeHandlersLayer = (server: ViteDevServer, appDirectory: string, database
522
598
  const httpApp = Effect.gen(function* () {
523
599
  const request = yield* HttpServerRequest.HttpServerRequest;
524
600
  const pathname = new URL(request.url, "http://ignotum.local").pathname;
601
+ if (pathname.startsWith(fileUploadUrlPrefix)) {
602
+ if (request.method !== "PUT")
603
+ return HttpServerResponse.text("Method Not Allowed", { status: 405 });
604
+ const token = Schema.decodeOption(FileUploadToken)(
605
+ pathname.slice(fileUploadUrlPrefix.length),
606
+ );
607
+ if (Option.isNone(token)) return HttpServerResponse.text("Not Found", { status: 404 });
608
+ const bytes = yield* collectStreamBytes(
609
+ request.stream,
610
+ fileLimits.fileBytes,
611
+ () => new Error("The local application file is too large."),
612
+ ).pipe(Effect.orDie);
613
+ return yield* LocalApplicationFiles.pipe(
614
+ Effect.flatMap((storage) => storage.upload(token.value, bytes)),
615
+ Effect.match({
616
+ onFailure: (error) => HttpServerResponse.text(String(error), { status: 422 }),
617
+ onSuccess: () => HttpServerResponse.text("Stored", { status: 201 }),
618
+ }),
619
+ );
620
+ }
621
+ if (pathname.startsWith(fileGrantUrlPrefix)) {
622
+ if (request.method !== "GET" && request.method !== "HEAD") {
623
+ return HttpServerResponse.text("Method Not Allowed", { status: 405 });
624
+ }
625
+ const token = Schema.decodeOption(FileGrantToken)(
626
+ pathname.slice(fileGrantUrlPrefix.length),
627
+ );
628
+ if (Option.isNone(token)) return HttpServerResponse.text("Not Found", { status: 404 });
629
+ const granted = yield* LocalApplicationFiles.pipe(
630
+ Effect.flatMap((storage) => storage.readGrant(token.value)),
631
+ );
632
+ if (Option.isNone(granted)) return HttpServerResponse.text("Not Found", { status: 404 });
633
+ const headers = {
634
+ "cache-control": "private, no-store",
635
+ "content-length": String(granted.value.bytes.byteLength),
636
+ "content-type": fileMimeTypes[granted.value.file.format],
637
+ "x-content-type-options": "nosniff",
638
+ };
639
+ return request.method === "HEAD"
640
+ ? HttpServerResponse.empty({ headers })
641
+ : HttpServerResponse.uint8Array(granted.value.bytes, { headers });
642
+ }
525
643
  return yield* HttpServerResponse.json(
526
644
  pathname === syncPath
527
645
  ? { code: "UpgradeRequired", message: `Connect to ${syncPath} with WebSocket.` }
@@ -3,12 +3,23 @@ import type { Effect } from "effect";
3
3
 
4
4
  import type { ErrorValue, InternalServerError } from "@ignotum/contracts/runtime/result";
5
5
  import { FunctionAddress } from "@ignotum/contracts/runtime/sync";
6
+ import type { FileValue } from "@ignotum/contracts/schema/file";
6
7
 
7
8
  const FunctionReferenceTypeId: unique symbol = Symbol.for("ignotum/internal/api/FunctionReference");
8
9
  declare const FunctionReferenceTypesTypeId: unique symbol;
9
10
 
10
11
  type FunctionKind = "Mutation" | "Query";
11
12
 
13
+ export type MutationInput<Value> = Value extends FileValue
14
+ ? Value | File
15
+ : Value extends Date
16
+ ? Value
17
+ : Value extends ReadonlyArray<infer Item>
18
+ ? ReadonlyArray<MutationInput<Item>>
19
+ : Value extends object
20
+ ? { readonly [Key in keyof Value]: MutationInput<Value[Key]> }
21
+ : Value;
22
+
12
23
  export interface FunctionReference<
13
24
  Kind extends FunctionKind,
14
25
  Args,
@@ -58,7 +69,11 @@ type ReferenceOf<Definition> = Definition extends {
58
69
  ? HandlerArguments extends readonly [infer _Context, ...infer Rest]
59
70
  ? FunctionReference<
60
71
  Kind,
61
- Rest extends readonly [infer Args, ...ReadonlyArray<unknown>] ? Args : void,
72
+ Rest extends readonly [infer Args, ...ReadonlyArray<unknown>]
73
+ ? Kind extends "Mutation"
74
+ ? MutationInput<Args>
75
+ : Args
76
+ : void,
62
77
  Success,
63
78
  | (Yielded extends Effect.Effect<unknown, infer Failure extends ErrorValue, never>
64
79
  ? Failure
@@ -5,4 +5,11 @@ import { defineSchema as defineContractSchema } from "@ignotum/contracts/schema"
5
5
  export const Result: typeof contractResult = contractResult;
6
6
  export type Result<Success, Failure extends ErrorValue> = ContractResult<Success, Failure>;
7
7
  export const defineSchema: typeof defineContractSchema = defineContractSchema;
8
- export type { DefinedSchema, SchemaAuthoring } from "@ignotum/contracts/schema";
8
+ export type {
9
+ DefinedSchema,
10
+ FileFormat,
11
+ FileMetadata,
12
+ FileMimeType,
13
+ FileValue,
14
+ SchemaAuthoring,
15
+ } from "@ignotum/contracts/schema";