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.
- package/README.md +85 -101
- package/dist/cli/bin.mjs +869 -175
- package/dist/cli/bin.mjs.map +1 -1
- package/dist/runtime/{api-DtX8qPrq.js → api-BTeMI5tx.js} +27 -3
- package/dist/runtime/api-BTeMI5tx.js.map +1 -0
- package/dist/runtime/{api-Dp4J-xt-.d.ts → api-D9lV-5av.d.ts} +7 -9
- package/dist/runtime/client.d.ts +12 -5
- package/dist/runtime/client.js +275 -52
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/{descriptor-t6BOEGw9-C1rwYIlx.js → descriptor-C5VA9qRl-C338iC6l.js} +38 -7
- package/dist/runtime/descriptor-C5VA9qRl-C338iC6l.js.map +1 -0
- package/dist/runtime/file-BXf63ulU.js +166 -0
- package/dist/runtime/file-BXf63ulU.js.map +1 -0
- package/dist/runtime/{id-Btwac71X-DhnKYsjY.d.ts → id-Btwac71X-DGj0DQuu.d.ts} +50 -4
- package/dist/runtime/{index-B5KSOjGN.d.ts → index-CF04_Dps.d.ts} +39 -20
- 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-BNFhAjns.d.ts +1 -0
- package/dist/runtime/{pagination-B1BzNkh8-BUSTbeSg.d.ts → pagination-DrOowBve-Bnipd34u.d.ts} +8 -4
- package/dist/runtime/{schema-D9RmboaS.js → schema-DJfwfq87.js} +17 -3
- package/dist/runtime/schema-DJfwfq87.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-mIXn9eKv.d.ts +8 -0
- package/package.json +4 -4
- package/src/cli/agent-files.ts +52 -13
- package/src/cli/app-configuration.ts +4 -1
- package/src/cli/build/server.ts +83 -6
- package/src/cli/new-app.ts +15 -0
- package/src/client/files.ts +168 -0
- package/src/client/hooks.ts +2 -15
- package/src/client/index.ts +7 -0
- package/src/client/sync.ts +137 -21
- package/src/dev-runtime/database.ts +69 -57
- package/src/dev-runtime/files.ts +338 -0
- package/src/dev-runtime/functions.ts +14 -6
- package/src/dev-runtime/migrations.ts +14 -0
- package/src/dev-runtime/sync.ts +123 -5
- 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,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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 {
|
package/src/dev-runtime/sync.ts
CHANGED
|
@@ -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*
|
|
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
|
|
464
|
-
|
|
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.` }
|
package/src/internal/api.ts
CHANGED
|
@@ -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>]
|
|
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
|
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
8
|
+
export type {
|
|
9
|
+
DefinedSchema,
|
|
10
|
+
FileFormat,
|
|
11
|
+
FileMetadata,
|
|
12
|
+
FileMimeType,
|
|
13
|
+
FileValue,
|
|
14
|
+
SchemaAuthoring,
|
|
15
|
+
} from "@ignotum/contracts/schema";
|