ignotum 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -101
- package/dist/cli/bin.mjs +1119 -195
- package/dist/cli/bin.mjs.map +1 -1
- package/dist/runtime/{api-DtX8qPrq.js → api-DzcR7spt.js} +36 -13
- package/dist/runtime/api-DzcR7spt.js.map +1 -0
- package/dist/runtime/{api-Dp4J-xt-.d.ts → api-jCl8Hzry.d.ts} +7 -9
- package/dist/runtime/client.d.ts +12 -5
- package/dist/runtime/client.js +285 -60
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/{descriptor-t6BOEGw9-C1rwYIlx.js → descriptor-C5VA9qRl-BKbdenuU.js} +38 -7
- package/dist/runtime/descriptor-C5VA9qRl-BKbdenuU.js.map +1 -0
- package/dist/runtime/file-C1abuMgd.js +173 -0
- package/dist/runtime/file-C1abuMgd.js.map +1 -0
- package/dist/runtime/{id-Btwac71X-DhnKYsjY.d.ts → id-Cs82tq9Q-Caqfx54f.d.ts} +51 -5
- package/dist/runtime/{index-B5KSOjGN.d.ts → index-D6VLTbDB.d.ts} +40 -21
- package/dist/runtime/internal/api.d.ts +2 -2
- package/dist/runtime/internal/api.js +1 -1
- package/dist/runtime/internal/host.d.ts +8 -7
- package/dist/runtime/internal/host.js +21 -10
- package/dist/runtime/internal/host.js.map +1 -1
- package/dist/runtime/internal/server.d.ts +1 -1
- package/dist/runtime/internal/server.js +1 -1
- package/dist/runtime/internal/types.d.ts +1 -1
- package/dist/runtime/internal/types.js +1 -1
- package/dist/runtime/{pagination-B1BzNkh8-BUSTbeSg.d.ts → pagination-Bt3l7QaC-D_zI5zsu.d.ts} +8 -4
- package/dist/runtime/pagination-DcIkTOFs.d.ts +1 -0
- package/dist/runtime/{schema-D9RmboaS.js → schema-B6PK_ZwV.js} +17 -3
- package/dist/runtime/schema-B6PK_ZwV.js.map +1 -0
- package/dist/runtime/server.d.ts +3 -3
- package/dist/runtime/server.js +2 -2
- package/dist/runtime/server.js.map +1 -1
- package/dist/runtime/sync-bMQq9tXx.d.ts +8 -0
- package/package.json +7 -5
- package/src/cli/agent-files.ts +52 -13
- package/src/cli/app-configuration.ts +4 -1
- package/src/cli/auth-client.ts +286 -0
- package/src/cli/build/server.ts +83 -6
- package/src/cli/command.ts +65 -2
- package/src/cli/control-client.ts +19 -6
- package/src/cli/new-app.ts +16 -0
- package/src/client/errors.ts +7 -10
- package/src/client/files.ts +153 -0
- package/src/client/hooks.ts +2 -15
- package/src/client/index.ts +7 -0
- package/src/client/sync.ts +158 -30
- package/src/dev-runtime/database.ts +69 -57
- package/src/dev-runtime/files.ts +337 -0
- package/src/dev-runtime/functions.ts +14 -6
- package/src/dev-runtime/migrations.ts +14 -0
- package/src/dev-runtime/sync.ts +134 -22
- package/src/internal/api.ts +16 -1
- package/src/server/index.ts +8 -1
- package/dist/runtime/api-DtX8qPrq.js.map +0 -1
- package/dist/runtime/descriptor-t6BOEGw9-C1rwYIlx.js.map +0 -1
- package/dist/runtime/id-D570vudg.js +0 -26
- package/dist/runtime/id-D570vudg.js.map +0 -1
- package/dist/runtime/pagination-BKPko9Hm.d.ts +0 -1
- package/dist/runtime/schema-D9RmboaS.js.map +0 -1
|
@@ -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,
|
|
103
|
+
readonly delete: (tableName: string, id: string) => Result<void, DocumentNotFoundValue>;
|
|
102
104
|
readonly insert: (tableName: string, value: RuntimeValue) => Result<GeneratedIdType, never>;
|
|
103
|
-
readonly patch: (
|
|
104
|
-
|
|
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
|
-
|
|
729
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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)
|
|
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
|
-
|
|
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 {
|