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
package/src/cli/new-app.ts
CHANGED
|
@@ -207,6 +207,17 @@ Open <http://127.0.0.1:3210>.
|
|
|
207
207
|
The app generator creates \`_generated\`. The dev server checks those files before it starts
|
|
208
208
|
and updates them when the schema or server functions change.
|
|
209
209
|
|
|
210
|
+
## Deploy
|
|
211
|
+
|
|
212
|
+
Set \`IGNOTUM_API_TOKEN\`, then choose a unique slug on the first deployment:
|
|
213
|
+
|
|
214
|
+
\`\`\`sh
|
|
215
|
+
npx ignotum deploy --app my-app
|
|
216
|
+
\`\`\`
|
|
217
|
+
|
|
218
|
+
The command prints the hosted URL and records the app link in \`.ignotum/app.json\`. Later
|
|
219
|
+
deployments use \`npx ignotum deploy\` without the \`--app\` flag.
|
|
220
|
+
|
|
210
221
|
## App files
|
|
211
222
|
|
|
212
223
|
- \`server/schema.ts\` defines the database tables.
|
|
@@ -216,6 +227,7 @@ and updates them when the schema or server functions change.
|
|
|
216
227
|
- Ignotum loads Tailwind CSS automatically. Custom CSS files are ordinary client modules.
|
|
217
228
|
- \`shared/utils.ts\` contains code shared across the app.
|
|
218
229
|
- \`_generated\` contains Ignotum's generated types and bindings. Do not edit it by hand.
|
|
230
|
+
- \`.agents/skills/ignotum\` contains the app skill and references used by coding agents.
|
|
219
231
|
|
|
220
232
|
Run the typechecker after a change:
|
|
221
233
|
|
|
@@ -224,6 +236,9 @@ npx ignotum codegen
|
|
|
224
236
|
npx tsc --noEmit
|
|
225
237
|
\`\`\`
|
|
226
238
|
|
|
239
|
+
The [Ignotum documentation](https://docs.ignotum.cloud) covers schema values, indexes, queries,
|
|
240
|
+
mutations, client hooks, deployment, guarantees, and hosted limits.
|
|
241
|
+
|
|
227
242
|
## Claude Code
|
|
228
243
|
|
|
229
244
|
If you use Claude Code, rename \`AGENTS.md\` to \`CLAUDE.md\` and \`.agents\` to \`.claude\` so it
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FileId,
|
|
3
|
+
fileGrantUrlOf,
|
|
4
|
+
fileIdOf,
|
|
5
|
+
fileFormats,
|
|
6
|
+
fileLimits,
|
|
7
|
+
fileMimeTypes,
|
|
8
|
+
grantFileValue,
|
|
9
|
+
isFileValue,
|
|
10
|
+
makeFileValue,
|
|
11
|
+
type FileFormat,
|
|
12
|
+
type FileValue,
|
|
13
|
+
type RuntimeFileOccurrence,
|
|
14
|
+
} from "@ignotum/contracts/schema/file";
|
|
15
|
+
import { IdGenerator } from "@ignotum/shared/id";
|
|
16
|
+
import { Effect, Predicate } from "effect";
|
|
17
|
+
|
|
18
|
+
export const Files = {
|
|
19
|
+
url: (file: FileValue): string => {
|
|
20
|
+
const url = fileGrantUrlOf(file);
|
|
21
|
+
if (url === undefined) {
|
|
22
|
+
throw new Error("This FileValue has no active query grant.");
|
|
23
|
+
}
|
|
24
|
+
return url;
|
|
25
|
+
},
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
const formatByMime: Partial<Record<string, FileFormat>> = {};
|
|
29
|
+
for (const format of fileFormats) formatByMime[fileMimeTypes[format]] = format;
|
|
30
|
+
|
|
31
|
+
interface NativeOccurrence {
|
|
32
|
+
readonly path: ReadonlyArray<string | number>;
|
|
33
|
+
readonly value: File;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const collectNativeFiles = (
|
|
37
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- This is the recursive parser for generated mutation arguments and native File values.
|
|
38
|
+
value: unknown,
|
|
39
|
+
path: ReadonlyArray<string | number>,
|
|
40
|
+
occurrences: Array<NativeOccurrence>,
|
|
41
|
+
): void => {
|
|
42
|
+
if (value instanceof File) {
|
|
43
|
+
occurrences.push({ path, value });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (isFileValue(value) || Predicate.isDate(value)) return;
|
|
47
|
+
if (globalThis.Array.isArray(value)) {
|
|
48
|
+
for (const [index, child] of value.entries())
|
|
49
|
+
collectNativeFiles(child, [...path, index], occurrences);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!Predicate.isObject(value)) return;
|
|
53
|
+
for (const [key, child] of Object.entries(value))
|
|
54
|
+
collectNativeFiles(child, [...path, key], occurrences);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const replaceNativeFiles = (
|
|
58
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Each node is classified before it is copied into the transport value.
|
|
59
|
+
value: unknown,
|
|
60
|
+
replacements: ReadonlyMap<File, FileValue>,
|
|
61
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- The generated function schema validates the reconstructed argument value on the server.
|
|
62
|
+
): unknown => {
|
|
63
|
+
if (value instanceof File) return replacements.get(value);
|
|
64
|
+
if (isFileValue(value) || Predicate.isDate(value)) return value;
|
|
65
|
+
if (globalThis.Array.isArray(value))
|
|
66
|
+
return value.map((child) => replaceNativeFiles(child, replacements));
|
|
67
|
+
if (!Predicate.isObject(value)) return value;
|
|
68
|
+
return Object.fromEntries(
|
|
69
|
+
Object.entries(value).map(([key, child]) => [key, replaceNativeFiles(child, replacements)]),
|
|
70
|
+
);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface PreparedMutationArguments {
|
|
74
|
+
readonly value: unknown;
|
|
75
|
+
readonly nativeFiles: ReadonlyMap<FileId, File>;
|
|
76
|
+
readonly occurrences: ReadonlyArray<RuntimeFileOccurrence>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const prepareMutationArguments = Effect.fn("SyncClient.prepareMutationArguments")(function* (
|
|
80
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Generated mutation argument types are erased only at this transport preparation boundary.
|
|
81
|
+
input: unknown,
|
|
82
|
+
) {
|
|
83
|
+
const ids = yield* IdGenerator;
|
|
84
|
+
const native: NativeOccurrence[] = [];
|
|
85
|
+
collectNativeFiles(input, [], native);
|
|
86
|
+
const unique = new Set(native.map(({ value }) => value));
|
|
87
|
+
if (unique.size > fileLimits.filesPerMutation) {
|
|
88
|
+
throw new Error(`A mutation may upload at most ${fileLimits.filesPerMutation} files.`);
|
|
89
|
+
}
|
|
90
|
+
const totalBytes = [...unique].reduce((total, file) => total + file.size, 0);
|
|
91
|
+
if (totalBytes > fileLimits.mutationBytes) {
|
|
92
|
+
throw new Error(`Mutation uploads may total at most ${fileLimits.mutationBytes} bytes.`);
|
|
93
|
+
}
|
|
94
|
+
const replacements = new Map<File, FileValue>();
|
|
95
|
+
const nativeFiles = new Map<FileId, File>();
|
|
96
|
+
for (const file of unique) {
|
|
97
|
+
const format = formatByMime[file.type.toLowerCase()];
|
|
98
|
+
if (format === undefined) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`File '${file.name}' has unsupported media type '${file.type || "unknown"}'.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (file.size === 0 || file.size > fileLimits.fileBytes) {
|
|
104
|
+
throw new Error(`File '${file.name}' must be between 1 and ${fileLimits.fileBytes} bytes.`);
|
|
105
|
+
}
|
|
106
|
+
if (new TextEncoder().encode(file.name).byteLength > fileLimits.filenameBytes) {
|
|
107
|
+
throw new Error(`File '${file.name}' has a filename that is too long.`);
|
|
108
|
+
}
|
|
109
|
+
const id = yield* ids.generate(FileId);
|
|
110
|
+
replacements.set(file, makeFileValue(id, { format, name: file.name, size: file.size }));
|
|
111
|
+
nativeFiles.set(id, file);
|
|
112
|
+
}
|
|
113
|
+
const value = replaceNativeFiles(input, replacements);
|
|
114
|
+
const occurrences = native.map(({ path, value: file }) => {
|
|
115
|
+
const replacement = replacements.get(file);
|
|
116
|
+
if (replacement === undefined) throw new Error("A native file replacement is missing.");
|
|
117
|
+
return {
|
|
118
|
+
path,
|
|
119
|
+
file: {
|
|
120
|
+
id: fileIdOf(replacement),
|
|
121
|
+
format: replacement.format,
|
|
122
|
+
name: replacement.name,
|
|
123
|
+
size: replacement.size,
|
|
124
|
+
},
|
|
125
|
+
} satisfies RuntimeFileOccurrence;
|
|
126
|
+
});
|
|
127
|
+
return { value, nativeFiles, occurrences } satisfies PreparedMutationArguments;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
interface FileGrantOccurrence extends RuntimeFileOccurrence {
|
|
131
|
+
readonly url: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const pathKey = (path: ReadonlyArray<string | number>): string => JSON.stringify(path);
|
|
135
|
+
|
|
136
|
+
// @effect-diagnostics-next-line missingPipeableSignature:off File grant attachment naturally takes the decoded value first.
|
|
137
|
+
export const applyFileGrants = (
|
|
138
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Query result types are erased at the wire decoding boundary and checked against grant paths below.
|
|
139
|
+
value: unknown,
|
|
140
|
+
grants: ReadonlyArray<FileGrantOccurrence>,
|
|
141
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- The traversal preserves the decoded query result shape while attaching private symbols.
|
|
142
|
+
): unknown => {
|
|
143
|
+
const byPath = new Map(grants.map((grant) => [pathKey(grant.path), grant] as const));
|
|
144
|
+
const visit = (
|
|
145
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- This recursive visitor validates a grant and file ID before replacement.
|
|
146
|
+
current: unknown,
|
|
147
|
+
path: ReadonlyArray<string | number>,
|
|
148
|
+
// oxlint-disable-next-line anti-slop/no-unknown-returns -- Recursive branches preserve their input shape.
|
|
149
|
+
): unknown => {
|
|
150
|
+
const grant = byPath.get(pathKey(path));
|
|
151
|
+
if (grant !== undefined) {
|
|
152
|
+
if (!isFileValue(current) || fileIdOf(current) !== grant.file.id) {
|
|
153
|
+
throw new Error("A query file grant does not match its result value.");
|
|
154
|
+
}
|
|
155
|
+
return grantFileValue(current, grant.url);
|
|
156
|
+
}
|
|
157
|
+
if (globalThis.Array.isArray(current)) {
|
|
158
|
+
return current.map((child, index) => visit(child, [...path, index]));
|
|
159
|
+
}
|
|
160
|
+
if (!Predicate.isObject(current) || Predicate.isDate(current) || isFileValue(current)) {
|
|
161
|
+
return current;
|
|
162
|
+
}
|
|
163
|
+
return Object.fromEntries(
|
|
164
|
+
Object.entries(current).map(([key, child]) => [key, visit(child, [...path, key])]),
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
return visit(value, []);
|
|
168
|
+
};
|
package/src/client/hooks.ts
CHANGED
|
@@ -10,11 +10,7 @@ import {
|
|
|
10
10
|
type PaginationPage,
|
|
11
11
|
} from "@ignotum/contracts/runtime/pagination";
|
|
12
12
|
import { encodeTransportObject } from "@ignotum/contracts/runtime/sync";
|
|
13
|
-
import {
|
|
14
|
-
ClientInfrastructureError,
|
|
15
|
-
InvalidMutationArguments,
|
|
16
|
-
reportClientError,
|
|
17
|
-
} from "./errors.js";
|
|
13
|
+
import { ClientInfrastructureError } from "./errors.js";
|
|
18
14
|
import { isQuerySkip, type QuerySkip } from "./query.js";
|
|
19
15
|
import { SyncClient, syncClientInternals, syncRuntime } from "./sync.js";
|
|
20
16
|
|
|
@@ -278,19 +274,10 @@ export const useMutation = <Args extends object | void, Success, Failure extends
|
|
|
278
274
|
return syncRuntime.runPromise<SettledResult<Success, Failure>, ClientInfrastructureError>(
|
|
279
275
|
Effect.gen(function* () {
|
|
280
276
|
const client = yield* SyncClient;
|
|
281
|
-
const jsonArgs = yield* Effect.try({
|
|
282
|
-
try: () => encodeTransportObject(input),
|
|
283
|
-
catch: (cause) =>
|
|
284
|
-
InvalidMutationArguments.make({
|
|
285
|
-
cause,
|
|
286
|
-
function: functionPath,
|
|
287
|
-
message: `Mutation arguments for ${functionPath} contain an unsupported value.`,
|
|
288
|
-
}),
|
|
289
|
-
}).pipe(Effect.tapError(reportClientError));
|
|
290
277
|
// SAFETY: generated function references bind the runtime path to the
|
|
291
278
|
// declared public result types validated by the server executor.
|
|
292
279
|
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- The untyped sync transport deliberately erases the generated reference's result parameters.
|
|
293
|
-
return (yield* client.mutate(functionPath,
|
|
280
|
+
return (yield* client.mutate(functionPath, input)) as unknown as SettledResult<
|
|
294
281
|
Success,
|
|
295
282
|
Failure
|
|
296
283
|
>;
|
package/src/client/index.ts
CHANGED
|
@@ -9,6 +9,13 @@ export const Result = { match: contractResult.match };
|
|
|
9
9
|
export type Result<Value, Error extends ErrorValue> = SettledResult<Value, Error>;
|
|
10
10
|
export type QueryResult<Value, Error extends ErrorValue> = ContractQueryResult<Value, Error>;
|
|
11
11
|
export type { InternalServerError } from "@ignotum/contracts/runtime/result";
|
|
12
|
+
export type {
|
|
13
|
+
FileFormat,
|
|
14
|
+
FileMetadata,
|
|
15
|
+
FileMimeType,
|
|
16
|
+
FileValue,
|
|
17
|
+
} from "@ignotum/contracts/schema/file";
|
|
18
|
+
export { Files } from "./files.js";
|
|
12
19
|
export { useMutation, usePaginatedQuery, useQuery } from "./hooks.js";
|
|
13
20
|
export type { PaginatedQueryOptions, PaginatedQueryValue, PaginationStatus } from "./hooks.js";
|
|
14
21
|
export { Query } from "./query.js";
|
package/src/client/sync.ts
CHANGED
|
@@ -16,6 +16,7 @@ import * as BrowserCrypto from "@effect/platform-browser/BrowserCrypto";
|
|
|
16
16
|
import { encodeCanonicalJson } from "@ignotum/contracts/json";
|
|
17
17
|
import { type AppStateRevision } from "@ignotum/contracts/runtime/hosted";
|
|
18
18
|
import { IdGenerator } from "@ignotum/shared/id";
|
|
19
|
+
import { encodeTransportObject } from "@ignotum/contracts/runtime/sync";
|
|
19
20
|
|
|
20
21
|
import type {
|
|
21
22
|
ErrorValue,
|
|
@@ -42,10 +43,12 @@ import {
|
|
|
42
43
|
InvalidClientMessage,
|
|
43
44
|
InvalidServerMessage,
|
|
44
45
|
ServerProtocolError,
|
|
46
|
+
InvalidMutationArguments,
|
|
45
47
|
clientErrorReporterLayer,
|
|
46
48
|
reportClientError,
|
|
47
49
|
} from "./errors.js";
|
|
48
50
|
import { syncPath } from "../internal/http-paths.js";
|
|
51
|
+
import { applyFileGrants, prepareMutationArguments } from "./files.js";
|
|
49
52
|
|
|
50
53
|
type Listener = () => void;
|
|
51
54
|
type SocketWriter = (chunk: string) => Effect.Effect<void, Socket.SocketError>;
|
|
@@ -195,14 +198,19 @@ const makeQueryCache = (
|
|
|
195
198
|
} satisfies QueryCache;
|
|
196
199
|
};
|
|
197
200
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
201
|
+
type SnapshotFiles = NonNullable<Extract<ServerMessage, { readonly type: "Snapshot" }>["files"]>;
|
|
202
|
+
|
|
203
|
+
const resultFromWire = Effect.fn("SyncClient.resultFromWire")(
|
|
204
|
+
(wire: WireResult, files: SnapshotFiles = []) =>
|
|
205
|
+
Effect.succeed(
|
|
206
|
+
wire.type === "Success"
|
|
207
|
+
? Result.succeed(
|
|
208
|
+
wire.value === undefined
|
|
209
|
+
? undefined
|
|
210
|
+
: applyFileGrants(decodeTransportValue(wire.value, wire.dates ?? []), files),
|
|
211
|
+
)
|
|
212
|
+
: failureFromWire(wire.error, wire.dates),
|
|
213
|
+
),
|
|
206
214
|
);
|
|
207
215
|
|
|
208
216
|
export const shouldReloadForCloseCode = (code: number): boolean =>
|
|
@@ -242,7 +250,8 @@ const socketUrl = (): string => {
|
|
|
242
250
|
interface SyncClientService {
|
|
243
251
|
readonly mutate: (
|
|
244
252
|
functionAddress: FunctionAddress,
|
|
245
|
-
|
|
253
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Generated reference argument types are deliberately erased at the client transport boundary.
|
|
254
|
+
args: unknown,
|
|
246
255
|
) => Effect.Effect<IgnotumResult<unknown, ErrorValue>, ClientInfrastructureError>;
|
|
247
256
|
readonly observe: (
|
|
248
257
|
functionAddress: FunctionAddress,
|
|
@@ -256,7 +265,11 @@ interface PendingInvocation {
|
|
|
256
265
|
ClientInfrastructureError
|
|
257
266
|
>;
|
|
258
267
|
readonly function: FunctionAddress;
|
|
259
|
-
readonly message: Extract<ClientMessage, { readonly type: "Invoke" }>;
|
|
268
|
+
readonly message: Extract<ClientMessage, { readonly type: "Invoke" | "PrepareMutation" }>;
|
|
269
|
+
readonly prepared: Deferred.Deferred<
|
|
270
|
+
Extract<ServerMessage, { readonly type: "MutationPrepared" }>["uploads"],
|
|
271
|
+
ClientInfrastructureError
|
|
272
|
+
>;
|
|
260
273
|
}
|
|
261
274
|
|
|
262
275
|
export class SyncClient extends Context.Service<SyncClient, SyncClientService>()(
|
|
@@ -319,6 +332,7 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
|
|
|
319
332
|
}
|
|
320
333
|
invocations = HashMap.remove(invocations, id);
|
|
321
334
|
return reportClientError(error).pipe(
|
|
335
|
+
Effect.andThen(Deferred.fail(pendingInvocation.prepared, error)),
|
|
322
336
|
Effect.andThen(Deferred.fail(pendingInvocation.deferred, error)),
|
|
323
337
|
Effect.asVoid,
|
|
324
338
|
);
|
|
@@ -331,13 +345,20 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
|
|
|
331
345
|
case "Snapshot": {
|
|
332
346
|
const entry = queryCache.getById(message.id);
|
|
333
347
|
if (entry === undefined) return;
|
|
334
|
-
yield* resultFromWire(message.result).pipe(
|
|
348
|
+
yield* resultFromWire(message.result, message.files ?? []).pipe(
|
|
335
349
|
Effect.tap((result) =>
|
|
336
350
|
Effect.sync(() => queryCache.setResult(message.id, result, message.revision)),
|
|
337
351
|
),
|
|
338
352
|
);
|
|
339
353
|
return;
|
|
340
354
|
}
|
|
355
|
+
case "MutationPrepared": {
|
|
356
|
+
const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
|
|
357
|
+
if (pendingInvocation !== undefined) {
|
|
358
|
+
yield* Deferred.succeed(pendingInvocation.prepared, message.uploads);
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
341
362
|
case "Result": {
|
|
342
363
|
const pendingInvocation = HashMap.getUnsafe(invocations, message.id);
|
|
343
364
|
if (pendingInvocation === undefined) return;
|
|
@@ -488,31 +509,126 @@ export class SyncClient extends Context.Service<SyncClient, SyncClientService>()
|
|
|
488
509
|
const mutate: SyncClientService["mutate"] = Effect.fn("SyncClient.mutate")(
|
|
489
510
|
function* (functionAddress, args) {
|
|
490
511
|
const invocationId = yield* ids.generate(InvocationId);
|
|
512
|
+
const preparedArguments = yield* prepareMutationArguments(args).pipe(
|
|
513
|
+
Effect.provideService(IdGenerator, ids),
|
|
514
|
+
Effect.mapError((cause) =>
|
|
515
|
+
InvalidMutationArguments.make({
|
|
516
|
+
cause,
|
|
517
|
+
function: functionAddress,
|
|
518
|
+
message: `Mutation arguments for ${functionAddress} contain an unsupported file or value.`,
|
|
519
|
+
}),
|
|
520
|
+
),
|
|
521
|
+
Effect.tapError(reportClientError),
|
|
522
|
+
);
|
|
523
|
+
const jsonArgs = yield* Effect.try({
|
|
524
|
+
try: () => encodeTransportObject(preparedArguments.value),
|
|
525
|
+
catch: (cause) =>
|
|
526
|
+
InvalidMutationArguments.make({
|
|
527
|
+
cause,
|
|
528
|
+
function: functionAddress,
|
|
529
|
+
message: `Mutation arguments for ${functionAddress} contain an unsupported value.`,
|
|
530
|
+
}),
|
|
531
|
+
}).pipe(Effect.tapError(reportClientError));
|
|
491
532
|
const deferred = yield* Deferred.make<
|
|
492
533
|
IgnotumResult<unknown, ErrorValue>,
|
|
493
534
|
ClientInfrastructureError
|
|
494
535
|
>();
|
|
495
|
-
const
|
|
536
|
+
const prepared = yield* Deferred.make<
|
|
537
|
+
Extract<ServerMessage, { readonly type: "MutationPrepared" }>["uploads"],
|
|
538
|
+
ClientInfrastructureError
|
|
539
|
+
>();
|
|
540
|
+
const invokeMessage: Extract<ClientMessage, { readonly type: "Invoke" }> = {
|
|
496
541
|
type: "Invoke",
|
|
497
542
|
id: invocationId,
|
|
498
543
|
kind: "Mutation",
|
|
499
544
|
function: functionAddress,
|
|
500
|
-
args,
|
|
545
|
+
args: jsonArgs,
|
|
501
546
|
};
|
|
547
|
+
const message: PendingInvocation["message"] =
|
|
548
|
+
preparedArguments.occurrences.length === 0
|
|
549
|
+
? invokeMessage
|
|
550
|
+
: {
|
|
551
|
+
type: "PrepareMutation",
|
|
552
|
+
id: invocationId,
|
|
553
|
+
kind: "Mutation",
|
|
554
|
+
function: functionAddress,
|
|
555
|
+
args: jsonArgs,
|
|
556
|
+
files: preparedArguments.occurrences,
|
|
557
|
+
};
|
|
502
558
|
invocations = HashMap.set(invocations, invocationId, {
|
|
503
559
|
deferred,
|
|
504
560
|
function: functionAddress,
|
|
505
561
|
message,
|
|
562
|
+
prepared,
|
|
506
563
|
});
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
564
|
+
return yield* Effect.gen(function* () {
|
|
565
|
+
if (writer !== undefined) {
|
|
566
|
+
yield* sendWith(writer, message).pipe(
|
|
567
|
+
Effect.catchTags({
|
|
568
|
+
ConnectionUnavailable: reportClientError,
|
|
569
|
+
InvalidClientMessage: (error) => rejectInvocation(invocationId, error),
|
|
570
|
+
}),
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
if (message.type === "PrepareMutation") {
|
|
574
|
+
const uploads = yield* Deferred.await(prepared);
|
|
575
|
+
const uploaded = new Set<string>();
|
|
576
|
+
for (const upload of uploads) {
|
|
577
|
+
if (upload.url === undefined || uploaded.has(upload.file.id)) continue;
|
|
578
|
+
const uploadUrl = upload.url;
|
|
579
|
+
uploaded.add(upload.file.id);
|
|
580
|
+
const file = preparedArguments.nativeFiles.get(upload.file.id);
|
|
581
|
+
if (file === undefined) {
|
|
582
|
+
return yield* InvalidServerMessage.make({
|
|
583
|
+
cause: new Error(`Missing native file '${upload.file.id}'.`),
|
|
584
|
+
message: "The server prepared an unknown application file.",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
const response = yield* Effect.tryPromise({
|
|
588
|
+
try: () =>
|
|
589
|
+
fetch(uploadUrl, {
|
|
590
|
+
method: "PUT",
|
|
591
|
+
body: file,
|
|
592
|
+
headers: { "content-type": file.type },
|
|
593
|
+
}),
|
|
594
|
+
catch: (cause) =>
|
|
595
|
+
ConnectionUnavailable.make({
|
|
596
|
+
cause,
|
|
597
|
+
message: `File '${file.name}' could not be uploaded.`,
|
|
598
|
+
}),
|
|
599
|
+
});
|
|
600
|
+
if (!response.ok) {
|
|
601
|
+
const detail = yield* Effect.promise(() => response.text());
|
|
602
|
+
return yield* InvalidMutationArguments.make({
|
|
603
|
+
cause: new Error(detail),
|
|
604
|
+
function: functionAddress,
|
|
605
|
+
message: `File '${file.name}' was rejected by application storage.`,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const pendingInvocation = HashMap.getUnsafe(invocations, invocationId);
|
|
610
|
+
if (pendingInvocation === undefined) return yield* Deferred.await(deferred);
|
|
611
|
+
invocations = HashMap.set(invocations, invocationId, {
|
|
612
|
+
...pendingInvocation,
|
|
613
|
+
message: invokeMessage,
|
|
614
|
+
});
|
|
615
|
+
if (writer !== undefined) {
|
|
616
|
+
yield* sendWith(writer, invokeMessage).pipe(
|
|
617
|
+
Effect.catchTags({
|
|
618
|
+
ConnectionUnavailable: reportClientError,
|
|
619
|
+
InvalidClientMessage: (error) => rejectInvocation(invocationId, error),
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return yield* Deferred.await(deferred);
|
|
625
|
+
}).pipe(
|
|
626
|
+
Effect.tapError(() =>
|
|
627
|
+
Effect.sync(() => {
|
|
628
|
+
invocations = HashMap.remove(invocations, invocationId);
|
|
512
629
|
}),
|
|
513
|
-
)
|
|
514
|
-
|
|
515
|
-
return yield* Deferred.await(deferred);
|
|
630
|
+
),
|
|
631
|
+
);
|
|
516
632
|
},
|
|
517
633
|
);
|
|
518
634
|
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
SchemaDefinitionTypeId,
|
|
48
48
|
type ValueDescriptor,
|
|
49
49
|
} from "@ignotum/contracts/schema";
|
|
50
|
+
import type { FileValue } from "@ignotum/contracts/schema/file";
|
|
50
51
|
|
|
51
52
|
import { DevelopmentDatabase } from "./migrations.js";
|
|
52
53
|
|
|
@@ -59,6 +60,7 @@ type RuntimeDocumentValue =
|
|
|
59
60
|
| number
|
|
60
61
|
| string
|
|
61
62
|
| Date
|
|
63
|
+
| FileValue
|
|
62
64
|
| ReadonlyArray<RuntimeDocumentValue>
|
|
63
65
|
| RuntimeObject;
|
|
64
66
|
type RuntimeValue = RuntimeObject;
|
|
@@ -98,10 +100,18 @@ export interface LocalDatabaseReader {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export interface LocalDatabaseWriter extends LocalDatabaseReader {
|
|
101
|
-
readonly delete: (tableName: string, id: string) => Result<void,
|
|
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
|
|