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.
Files changed (58) hide show
  1. package/README.md +89 -101
  2. package/dist/cli/bin.mjs +1119 -195
  3. package/dist/cli/bin.mjs.map +1 -1
  4. package/dist/runtime/{api-DtX8qPrq.js → api-DzcR7spt.js} +36 -13
  5. package/dist/runtime/api-DzcR7spt.js.map +1 -0
  6. package/dist/runtime/{api-Dp4J-xt-.d.ts → api-jCl8Hzry.d.ts} +7 -9
  7. package/dist/runtime/client.d.ts +12 -5
  8. package/dist/runtime/client.js +285 -60
  9. package/dist/runtime/client.js.map +1 -1
  10. package/dist/runtime/{descriptor-t6BOEGw9-C1rwYIlx.js → descriptor-C5VA9qRl-BKbdenuU.js} +38 -7
  11. package/dist/runtime/descriptor-C5VA9qRl-BKbdenuU.js.map +1 -0
  12. package/dist/runtime/file-C1abuMgd.js +173 -0
  13. package/dist/runtime/file-C1abuMgd.js.map +1 -0
  14. package/dist/runtime/{id-Btwac71X-DhnKYsjY.d.ts → id-Cs82tq9Q-Caqfx54f.d.ts} +51 -5
  15. package/dist/runtime/{index-B5KSOjGN.d.ts → index-D6VLTbDB.d.ts} +40 -21
  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-B1BzNkh8-BUSTbeSg.d.ts → pagination-Bt3l7QaC-D_zI5zsu.d.ts} +8 -4
  26. package/dist/runtime/pagination-DcIkTOFs.d.ts +1 -0
  27. package/dist/runtime/{schema-D9RmboaS.js → schema-B6PK_ZwV.js} +17 -3
  28. package/dist/runtime/schema-B6PK_ZwV.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-bMQq9tXx.d.ts +8 -0
  33. package/package.json +7 -5
  34. package/src/cli/agent-files.ts +52 -13
  35. package/src/cli/app-configuration.ts +4 -1
  36. package/src/cli/auth-client.ts +286 -0
  37. package/src/cli/build/server.ts +83 -6
  38. package/src/cli/command.ts +65 -2
  39. package/src/cli/control-client.ts +19 -6
  40. package/src/cli/new-app.ts +16 -0
  41. package/src/client/errors.ts +7 -10
  42. package/src/client/files.ts +153 -0
  43. package/src/client/hooks.ts +2 -15
  44. package/src/client/index.ts +7 -0
  45. package/src/client/sync.ts +158 -30
  46. package/src/dev-runtime/database.ts +69 -57
  47. package/src/dev-runtime/files.ts +337 -0
  48. package/src/dev-runtime/functions.ts +14 -6
  49. package/src/dev-runtime/migrations.ts +14 -0
  50. package/src/dev-runtime/sync.ts +134 -22
  51. package/src/internal/api.ts +16 -1
  52. package/src/server/index.ts +8 -1
  53. package/dist/runtime/api-DtX8qPrq.js.map +0 -1
  54. package/dist/runtime/descriptor-t6BOEGw9-C1rwYIlx.js.map +0 -1
  55. package/dist/runtime/id-D570vudg.js +0 -26
  56. package/dist/runtime/id-D570vudg.js.map +0 -1
  57. package/dist/runtime/pagination-BKPko9Hm.d.ts +0 -1
  58. package/dist/runtime/schema-D9RmboaS.js.map +0 -1
@@ -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
 
@@ -0,0 +1,337 @@
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
+ fileIds: ReadonlyArray<FileId>,
49
+ ) => Effect.Effect<ReadonlyArray<{ readonly id: FileId; 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<{ readonly id: FileId; 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(
78
+ Array.from(new Set(files.map(({ file }) => file.id)), (id) => ({ id, url: "" })),
79
+ ),
80
+ prepare: (_invocationId, _functionAddress, _args, fileIds) =>
81
+ Effect.succeed(fileIds.map((id) => ({ id }))),
82
+ upload: () => Effect.die("Local application file storage is unavailable."),
83
+ releaseGrants: () => Effect.void,
84
+ readGrant: () => Effect.succeed(Option.none()),
85
+ reconcile: Effect.void,
86
+ releasePreparation: () => Effect.void,
87
+ }),
88
+ },
89
+ );
90
+
91
+ const sameFile = (left: RuntimeFileMetadata, right: RuntimeFileMetadata): boolean =>
92
+ left.id === right.id &&
93
+ left.format === right.format &&
94
+ left.name === right.name &&
95
+ left.size === right.size;
96
+
97
+ const validFilename = (name: string): boolean => {
98
+ const bytes = new TextEncoder().encode(name).byteLength;
99
+ const hasControlCharacter = Array.from(name).some((character) => {
100
+ const codePoint = character.codePointAt(0) ?? 0;
101
+ return codePoint <= 0x1f || codePoint === 0x7f;
102
+ });
103
+ return (
104
+ bytes > 0 &&
105
+ bytes <= fileLimits.filenameBytes &&
106
+ !hasControlCharacter &&
107
+ !name.includes("/") &&
108
+ !name.includes("\\")
109
+ );
110
+ };
111
+
112
+ export const localApplicationFilesLayer = (appDirectory: string) =>
113
+ Layer.effect(
114
+ LocalApplicationFiles,
115
+ Effect.gen(function* () {
116
+ const ids = yield* IdGenerator;
117
+ const fileSystem = yield* FileSystem.FileSystem;
118
+ const path = yield* Path.Path;
119
+ const sql = yield* SqlClient.SqlClient;
120
+ const directory = path.join(appDirectory, ".ignotum", "files");
121
+ yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie);
122
+ const preparations = new Map<InvocationId, Preparation>();
123
+ const uploadFiles = new Map<FileUploadToken, RuntimeFileMetadata>();
124
+ const grants = new Map<FileGrantToken, Grant>();
125
+ const objectPath = (fileId: FileId) => path.join(directory, fileId);
126
+
127
+ const prepare = Effect.fn("LocalApplicationFiles.prepare")(function* (
128
+ invocationId: InvocationId,
129
+ functionAddress: FunctionAddress,
130
+ args: Schema.Json,
131
+ fileIds: ReadonlyArray<FileId>,
132
+ ) {
133
+ if (fileIds.length === 0) throw new Error("A file preparation must contain a file ID.");
134
+ const selected = new Set(fileIds);
135
+ if (selected.size !== fileIds.length) {
136
+ throw new Error("A file preparation contains a duplicate file ID.");
137
+ }
138
+ const files = encodedFileOccurrencesOf(args).filter((occurrence) =>
139
+ selected.has(occurrence.file.id),
140
+ );
141
+ if (new Set(files.map((occurrence) => occurrence.file.id)).size !== selected.size) {
142
+ throw new Error("Prepared files do not match the mutation arguments.");
143
+ }
144
+ const existing = preparations.get(invocationId);
145
+ if (existing !== undefined) {
146
+ if (
147
+ existing.function !== functionAddress ||
148
+ encodeCanonicalJson(existing.args) !== encodeCanonicalJson(args) ||
149
+ encodeCanonicalJson(existing.files) !== encodeCanonicalJson(files)
150
+ ) {
151
+ throw new Error("The invocation ID already belongs to another file preparation.");
152
+ }
153
+ return fileIds.map((id) => {
154
+ const token = existing.uploads.get(id);
155
+ return token === undefined ? { id } : { id, url: `${fileUploadUrlPrefix}${token}` };
156
+ });
157
+ }
158
+ const distinct = new Map<FileId, RuntimeFileMetadata>();
159
+ for (const occurrence of files) {
160
+ if (!validFilename(occurrence.file.name)) {
161
+ throw new Error("Prepared files do not match the mutation arguments.");
162
+ }
163
+ const prior = distinct.get(occurrence.file.id);
164
+ if (prior !== undefined && !sameFile(prior, occurrence.file)) {
165
+ throw new Error("One file ID has conflicting metadata.");
166
+ }
167
+ distinct.set(occurrence.file.id, occurrence.file);
168
+ }
169
+ if (distinct.size > fileLimits.filesPerMutation) throw new Error("Too many files.");
170
+ const totalBytes = [...distinct.values()].reduce((total, file) => total + file.size, 0);
171
+ if (totalBytes > fileLimits.mutationBytes) throw new Error("File uploads are too large.");
172
+
173
+ const staged = yield* sql<{ readonly bytes: number }>`
174
+ SELECT COALESCE(SUM(size), 0) AS bytes FROM applicationFiles WHERE ready = 0
175
+ `;
176
+ let additionalStagedBytes = 0;
177
+ for (const file of distinct.values()) {
178
+ const rows = yield* sql<typeof StoredFile.Type>`
179
+ SELECT fileId, name, format, size, ready FROM applicationFiles WHERE fileId = ${file.id}
180
+ `;
181
+ const stored = rows[0];
182
+ if (stored === undefined) additionalStagedBytes += file.size;
183
+ else if (
184
+ !sameFile(
185
+ {
186
+ id: stored.fileId,
187
+ name: stored.name,
188
+ format: stored.format,
189
+ size: stored.size,
190
+ },
191
+ file,
192
+ )
193
+ ) {
194
+ throw new Error(`File '${file.id}' has conflicting metadata.`);
195
+ }
196
+ }
197
+ if ((staged[0]?.bytes ?? 0) + additionalStagedBytes > fileLimits.stagedBytesPerApp) {
198
+ throw new Error("The app has too many staged file bytes.");
199
+ }
200
+ const uploads = new Map<FileId, FileUploadToken>();
201
+ for (const file of distinct.values()) {
202
+ const rows = yield* sql<typeof StoredFile.Type>`
203
+ SELECT fileId, name, format, size, ready FROM applicationFiles WHERE fileId = ${file.id}
204
+ `;
205
+ const stored = rows[0];
206
+ if (stored !== undefined && stored.ready === 1) continue;
207
+ const token = yield* ids.generate(FileUploadToken);
208
+ uploads.set(file.id, token);
209
+ uploadFiles.set(token, file);
210
+ yield* sql`
211
+ INSERT INTO applicationFiles (fileId, name, format, size, ready)
212
+ VALUES (${file.id}, ${file.name}, ${file.format}, ${file.size}, 0)
213
+ ON CONFLICT(fileId) DO UPDATE SET
214
+ name = excluded.name, format = excluded.format, size = excluded.size
215
+ `;
216
+ }
217
+ preparations.set(invocationId, { function: functionAddress, args, files, uploads });
218
+ return fileIds.map((id) => {
219
+ const token = uploads.get(id);
220
+ return token === undefined ? { id } : { id, url: `${fileUploadUrlPrefix}${token}` };
221
+ });
222
+ });
223
+
224
+ const admit = Effect.fn("LocalApplicationFiles.admit")(function* (
225
+ invocationId: InvocationId,
226
+ functionAddress: FunctionAddress,
227
+ args: Schema.Json,
228
+ ) {
229
+ const preparation = preparations.get(invocationId);
230
+ if (
231
+ preparation !== undefined &&
232
+ (preparation.function !== functionAddress ||
233
+ encodeCanonicalJson(preparation.args) !== encodeCanonicalJson(args))
234
+ ) {
235
+ throw new Error("The mutation does not match its file preparation.");
236
+ }
237
+ for (const occurrence of preparation?.files ?? []) {
238
+ const rows = yield* sql<{ readonly ready: number }>`
239
+ SELECT ready FROM applicationFiles WHERE fileId = ${occurrence.file.id}
240
+ `;
241
+ if (rows[0]?.ready !== 1) throw new Error("A prepared file has not finished uploading.");
242
+ }
243
+ const occurrences = encodedFileOccurrencesOf(args);
244
+ for (const occurrence of occurrences) {
245
+ const rows = yield* sql<{ readonly ready: number }>`
246
+ SELECT ready FROM applicationFiles WHERE fileId = ${occurrence.file.id}
247
+ `;
248
+ if (rows[0]?.ready !== 1) throw new Error(`File '${occurrence.file.id}' is unavailable.`);
249
+ }
250
+ return occurrences;
251
+ });
252
+
253
+ const upload = Effect.fn("LocalApplicationFiles.upload")(function* (
254
+ token: FileUploadToken,
255
+ bytes: Uint8Array,
256
+ ) {
257
+ const file = uploadFiles.get(token);
258
+ if (file === undefined) throw new Error("The local file upload ticket is invalid.");
259
+ const validation = validateApplicationFile(bytes, file.format, file.size);
260
+ if (!validation.valid) throw new Error(validation.message);
261
+ yield* fileSystem.writeFile(objectPath(file.id), bytes).pipe(Effect.orDie);
262
+ yield* sql`UPDATE applicationFiles SET ready = 1 WHERE fileId = ${file.id}`;
263
+ uploadFiles.delete(token);
264
+ return "Stored";
265
+ });
266
+
267
+ const grantFiles = Effect.fn("LocalApplicationFiles.grants")(function* (
268
+ subscriptionId: SubscriptionId,
269
+ occurrences: ReadonlyArray<RuntimeFileOccurrenceValue>,
270
+ ) {
271
+ for (const [token, grant] of grants) {
272
+ if (grant.subscriptionId === subscriptionId) grants.delete(token);
273
+ }
274
+ const tokens = new Map<FileId, FileGrantToken>();
275
+ for (const occurrence of occurrences) {
276
+ if (tokens.has(occurrence.file.id)) continue;
277
+ const token = yield* ids.generate(FileGrantToken);
278
+ tokens.set(occurrence.file.id, token);
279
+ grants.set(token, { subscriptionId, file: occurrence.file });
280
+ }
281
+ return Array.from(tokens, ([id, token]) => ({
282
+ id,
283
+ url: `${fileGrantUrlPrefix}${token}`,
284
+ }));
285
+ });
286
+
287
+ const reconcile = Effect.gen(function* () {
288
+ const rows = yield* sql<typeof StoredDocumentFields.Type>`
289
+ SELECT fields FROM documents
290
+ `;
291
+ const referenced = new Set<FileId>();
292
+ for (const row of rows) {
293
+ const fields = yield* Schema.decodeEffect(JsonObjectString)(row.fields).pipe(
294
+ Effect.orDie,
295
+ );
296
+ for (const occurrence of encodedFileOccurrencesOf(fields))
297
+ referenced.add(occurrence.file.id);
298
+ }
299
+ const stored = yield* sql<typeof StoredFile.Type>`
300
+ SELECT fileId, name, format, size, ready FROM applicationFiles
301
+ `;
302
+ for (const file of stored) {
303
+ if (referenced.has(file.fileId)) continue;
304
+ yield* fileSystem.remove(objectPath(file.fileId), { force: true }).pipe(Effect.orDie);
305
+ yield* sql`DELETE FROM applicationFiles WHERE fileId = ${file.fileId}`;
306
+ }
307
+ });
308
+
309
+ return LocalApplicationFiles.of({
310
+ admit: (...args) => admit(...args).pipe(Effect.orDie),
311
+ grants: grantFiles,
312
+ readGrant: (token) => {
313
+ const grant = grants.get(token);
314
+ return grant === undefined
315
+ ? Effect.succeed(Option.none())
316
+ : fileSystem.readFile(objectPath(grant.file.id)).pipe(
317
+ Effect.orDie,
318
+ Effect.map((bytes) => Option.some({ file: grant.file, bytes })),
319
+ );
320
+ },
321
+ reconcile: reconcile.pipe(Effect.orDie),
322
+ releaseGrants: (subscriptionId) =>
323
+ Effect.sync(() => {
324
+ for (const [token, grant] of grants) {
325
+ if (grant.subscriptionId === subscriptionId) grants.delete(token);
326
+ }
327
+ }),
328
+ releasePreparation: (invocationId) =>
329
+ Effect.suspend(() => {
330
+ if (!preparations.delete(invocationId)) return Effect.void;
331
+ return reconcile.pipe(Effect.orDie);
332
+ }),
333
+ prepare: (...args) => prepare(...args).pipe(Effect.orDie),
334
+ upload: (...args) => upload(...args).pipe(Effect.orDie),
335
+ });
336
+ }),
337
+ );
@@ -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 {