ignotum 0.0.0 → 0.0.2
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 +161 -0
- package/dist/cli/bin.d.mts +1 -0
- package/dist/cli/bin.mjs +1696 -0
- package/dist/cli/bin.mjs.map +1 -0
- package/dist/runtime/api-BXXIZc_Q.js +119 -0
- package/dist/runtime/api-BXXIZc_Q.js.map +1 -0
- package/dist/runtime/api-Cpx57mk3.d.ts +47 -0
- package/dist/runtime/client/jsx-dev-runtime.d.ts +2 -0
- package/dist/runtime/client/jsx-dev-runtime.js +2 -0
- package/dist/runtime/client/jsx-runtime.d.ts +2 -0
- package/dist/runtime/client/jsx-runtime.js +2 -0
- package/dist/runtime/client.d.ts +29 -0
- package/dist/runtime/client.js +364 -0
- package/dist/runtime/client.js.map +1 -0
- package/dist/runtime/index-BgWROoyk.d.ts +249 -0
- package/dist/runtime/internal/api.d.ts +2 -0
- package/dist/runtime/internal/api.js +2 -0
- package/dist/runtime/internal/server.d.ts +6 -0
- package/dist/runtime/internal/server.js +7 -0
- package/dist/runtime/internal/server.js.map +1 -0
- package/dist/runtime/internal/types.d.ts +2 -0
- package/dist/runtime/internal/types.js +2 -0
- package/dist/runtime/result-B2W-z2wG.js +136 -0
- package/dist/runtime/result-B2W-z2wG.js.map +1 -0
- package/dist/runtime/result-C1ZdsM6Y.d.ts +106 -0
- package/dist/runtime/schema-CNEVLF7D.js +116 -0
- package/dist/runtime/schema-CNEVLF7D.js.map +1 -0
- package/dist/runtime/server.d.ts +9 -0
- package/dist/runtime/server.js +9 -0
- package/dist/runtime/server.js.map +1 -0
- package/package.json +81 -2
- package/src/cli/agent-files.ts +35 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/client-plugin.ts +66 -0
- package/src/cli/codegen.ts +203 -0
- package/src/cli/command.ts +155 -0
- package/src/cli/dev.ts +206 -0
- package/src/cli/new-project.ts +377 -0
- package/src/cli/package-manager.ts +55 -0
- package/src/client/errors.ts +83 -0
- package/src/client/hooks.ts +141 -0
- package/src/client/index.ts +90 -0
- package/src/client/jsx-dev-runtime.ts +2 -0
- package/src/client/jsx-runtime.ts +2 -0
- package/src/client/query.ts +17 -0
- package/src/client/sync.ts +487 -0
- package/src/dev-runtime/database.ts +374 -0
- package/src/dev-runtime/dev-database.ts +199 -0
- package/src/dev-runtime/functions.ts +293 -0
- package/src/dev-runtime/id.ts +11 -0
- package/src/dev-runtime/sync.ts +473 -0
- package/src/internal/api.ts +141 -0
- package/src/internal/http-paths.ts +5 -0
- package/src/internal/server.ts +3 -0
- package/src/internal/types.ts +2 -0
- package/src/raw.d.ts +4 -0
- package/src/server/index.ts +8 -0
package/dist/cli/bin.mjs
ADDED
|
@@ -0,0 +1,1696 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { NodeHttpServer, NodeRuntime, NodeServices, NodeSocket } from "@effect/platform-node";
|
|
4
|
+
import { Array, Brand, Cause, Context, Crypto, DateTime, Effect, Effectable, FiberSet, FileSystem, Function, HashMap, Layer, ManagedRuntime, Option, PartitionedSemaphore, Path, Predicate, PubSub, Ref, Schema, Semaphore, String, Terminal } from "effect";
|
|
5
|
+
import { Argument, CliError, Command, Flag } from "effect/unstable/cli";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import prefresh from "@prefresh/vite";
|
|
8
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
9
|
+
import { createServer, defaultClientConditions, normalizePath } from "vite";
|
|
10
|
+
import { SqliteClient } from "@effect/sql-sqlite-node";
|
|
11
|
+
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
12
|
+
import { SqlClient, SqlSchema } from "effect/unstable/sql";
|
|
13
|
+
import { customAlphabet, nanoid } from "nanoid";
|
|
14
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
15
|
+
//#region package.json
|
|
16
|
+
var version = "0.0.2";
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region ../contracts/dist/value-Briqf-Ua.js
|
|
19
|
+
const TransportValueSchema = Schema.suspend(() => Schema.Union([
|
|
20
|
+
Schema.Null,
|
|
21
|
+
Schema.Boolean,
|
|
22
|
+
Schema.Finite,
|
|
23
|
+
Schema.String,
|
|
24
|
+
Schema.Date,
|
|
25
|
+
Schema.Array(TransportValueSchema),
|
|
26
|
+
Schema.Record(Schema.String, Schema.UndefinedOr(TransportValueSchema))
|
|
27
|
+
]));
|
|
28
|
+
const collectDatePaths = (value, path, paths) => {
|
|
29
|
+
if (Predicate.isDate(value)) {
|
|
30
|
+
Schema.decodeSync(Schema.Date)(value);
|
|
31
|
+
paths.push(path);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (globalThis.Array.isArray(value)) {
|
|
35
|
+
for (const [index, child] of value.entries()) collectDatePaths(child, [...path, index], paths);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (value === void 0 || Predicate.isNull(value) || Predicate.isString(value) || Predicate.isNumber(value) || Predicate.isBoolean(value)) return;
|
|
39
|
+
for (const [key, child] of Object.entries(value)) collectDatePaths(child, [...path, key], paths);
|
|
40
|
+
};
|
|
41
|
+
const datePathsOf = (value) => {
|
|
42
|
+
const paths = [];
|
|
43
|
+
collectDatePaths(value, [], paths);
|
|
44
|
+
return paths;
|
|
45
|
+
};
|
|
46
|
+
const datePathsOfObject = (value) => datePathsOf(Schema.decodeUnknownSync(TransportValueSchema)(value));
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region ../contracts/dist/runtime/sync.js
|
|
49
|
+
const FunctionNamePart = Schema.String.check(Schema.isPattern(/^[A-Za-z_$][A-Za-z0-9_$]*$/));
|
|
50
|
+
const ApiFunctionAddressParts = Schema.TemplateLiteralParser([
|
|
51
|
+
"api.",
|
|
52
|
+
FunctionNamePart,
|
|
53
|
+
".",
|
|
54
|
+
FunctionNamePart
|
|
55
|
+
]);
|
|
56
|
+
const FunctionAddress = Schema.TemplateLiteral([
|
|
57
|
+
"api.",
|
|
58
|
+
FunctionNamePart,
|
|
59
|
+
".",
|
|
60
|
+
FunctionNamePart
|
|
61
|
+
]);
|
|
62
|
+
const apiFunctionParts = (address) => {
|
|
63
|
+
const [, moduleName, , functionName] = Schema.decodeSync(ApiFunctionAddressParts)(address);
|
|
64
|
+
return {
|
|
65
|
+
functionName,
|
|
66
|
+
moduleName
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
const SubscriptionId = Schema.Natural.pipe(Schema.brand("ignotum/sync/SubscriptionId"));
|
|
70
|
+
const RequestId = Schema.Natural.pipe(Schema.brand("ignotum/sync/RequestId"));
|
|
71
|
+
const Subscribe = Schema.Struct({
|
|
72
|
+
type: Schema.Literal("Subscribe"),
|
|
73
|
+
id: SubscriptionId,
|
|
74
|
+
function: FunctionAddress,
|
|
75
|
+
args: Schema.Json
|
|
76
|
+
});
|
|
77
|
+
const Unsubscribe = Schema.Struct({
|
|
78
|
+
type: Schema.Literal("Unsubscribe"),
|
|
79
|
+
id: SubscriptionId
|
|
80
|
+
});
|
|
81
|
+
const Mutate = Schema.Struct({
|
|
82
|
+
type: Schema.Literal("Mutate"),
|
|
83
|
+
id: RequestId,
|
|
84
|
+
function: FunctionAddress,
|
|
85
|
+
args: Schema.Json
|
|
86
|
+
});
|
|
87
|
+
const ClientMessage = Schema.Union([
|
|
88
|
+
Subscribe,
|
|
89
|
+
Unsubscribe,
|
|
90
|
+
Mutate
|
|
91
|
+
]);
|
|
92
|
+
const QueryOperation = Schema.Struct({
|
|
93
|
+
type: Schema.Literal("Query"),
|
|
94
|
+
id: SubscriptionId
|
|
95
|
+
});
|
|
96
|
+
const MutateOperation = Schema.Struct({
|
|
97
|
+
type: Schema.Literal("Mutate"),
|
|
98
|
+
id: RequestId
|
|
99
|
+
});
|
|
100
|
+
const Operation = Schema.Union([QueryOperation, MutateOperation]);
|
|
101
|
+
const DatePath = Schema.Array(Schema.Union([Schema.String, Schema.Natural]));
|
|
102
|
+
const DatePaths = Schema.Array(DatePath);
|
|
103
|
+
const WireResult = Schema.Union([Schema.Struct({
|
|
104
|
+
type: Schema.Literal("Success"),
|
|
105
|
+
value: Schema.optional(Schema.Json),
|
|
106
|
+
dates: Schema.optional(DatePaths)
|
|
107
|
+
}), Schema.Struct({
|
|
108
|
+
type: Schema.Literal("Failure"),
|
|
109
|
+
error: Schema.Json,
|
|
110
|
+
dates: Schema.optional(DatePaths)
|
|
111
|
+
})]);
|
|
112
|
+
const QuerySnapshot = Schema.Struct({
|
|
113
|
+
type: Schema.Literal("QuerySnapshot"),
|
|
114
|
+
id: SubscriptionId,
|
|
115
|
+
result: WireResult
|
|
116
|
+
});
|
|
117
|
+
const MutationResult = Schema.Struct({
|
|
118
|
+
type: Schema.Literal("MutationResult"),
|
|
119
|
+
id: RequestId,
|
|
120
|
+
result: WireResult
|
|
121
|
+
});
|
|
122
|
+
const ProtocolErrorCode = Schema.Literals([
|
|
123
|
+
"DuplicateOperationId",
|
|
124
|
+
"FunctionUnavailable",
|
|
125
|
+
"InvalidArguments",
|
|
126
|
+
"InvalidMessage",
|
|
127
|
+
"UnknownFunction",
|
|
128
|
+
"WrongFunctionKind"
|
|
129
|
+
]);
|
|
130
|
+
const ProtocolError = Schema.Struct({
|
|
131
|
+
type: Schema.Literal("ProtocolError"),
|
|
132
|
+
operation: Schema.optional(Operation),
|
|
133
|
+
code: ProtocolErrorCode,
|
|
134
|
+
message: Schema.String
|
|
135
|
+
});
|
|
136
|
+
const ServerMessage = Schema.Union([
|
|
137
|
+
QuerySnapshot,
|
|
138
|
+
MutationResult,
|
|
139
|
+
ProtocolError
|
|
140
|
+
]);
|
|
141
|
+
const ClientMessageJson = Schema.fromJsonString(ClientMessage);
|
|
142
|
+
const ServerMessageJson = Schema.fromJsonString(ServerMessage);
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/cli/codegen.ts
|
|
145
|
+
const generatedHeader = "// Generated by `ignotum codegen`. Do not edit.";
|
|
146
|
+
var SchemaNotFound = class extends Schema.TaggedError()("SchemaNotFound", {
|
|
147
|
+
message: Schema.String,
|
|
148
|
+
path: Schema.String
|
|
149
|
+
}) {};
|
|
150
|
+
var InvalidFunctionModuleName = class extends Schema.TaggedError()("InvalidFunctionModuleName", {
|
|
151
|
+
message: Schema.String,
|
|
152
|
+
path: Schema.String
|
|
153
|
+
}) {};
|
|
154
|
+
var GeneratedFileConflict = class extends Schema.TaggedError()("GeneratedFileConflict", {
|
|
155
|
+
message: Schema.String,
|
|
156
|
+
path: Schema.String
|
|
157
|
+
}) {};
|
|
158
|
+
const isFunctionModuleFile = (fileName) => fileName.endsWith(".ts") && !fileName.endsWith(".test.ts") && !fileName.endsWith(".spec.ts") && fileName !== "index.ts" && fileName !== "schema.ts" && !fileName.startsWith("_");
|
|
159
|
+
const moduleNameFromFile = (filePath, fileName) => {
|
|
160
|
+
const moduleName = fileName.slice(0, -3);
|
|
161
|
+
return Schema.decodeEffect(FunctionNamePart)(moduleName).pipe(Effect.mapError(() => InvalidFunctionModuleName.make({
|
|
162
|
+
message: `Server function file names must be valid TypeScript identifiers. Rename ${fileName}.`,
|
|
163
|
+
path: filePath
|
|
164
|
+
})));
|
|
165
|
+
};
|
|
166
|
+
const renderServerBindings = () => `${generatedHeader}
|
|
167
|
+
|
|
168
|
+
import { bindSchema } from "ignotum/internal/server";
|
|
169
|
+
|
|
170
|
+
import schema from "../server/schema.js";
|
|
171
|
+
|
|
172
|
+
export const { mutation, query, values } = bindSchema(schema);
|
|
173
|
+
`;
|
|
174
|
+
const renderTypes = () => `${generatedHeader}
|
|
175
|
+
|
|
176
|
+
import type {
|
|
177
|
+
Document as DocumentFor,
|
|
178
|
+
Id as IdFor,
|
|
179
|
+
SchemaDefinitionOf,
|
|
180
|
+
} from "ignotum/internal/types";
|
|
181
|
+
|
|
182
|
+
import type schema from "../server/schema.js";
|
|
183
|
+
|
|
184
|
+
export type DataModel = SchemaDefinitionOf<typeof schema>;
|
|
185
|
+
export type Doc<TableName extends Extract<keyof DataModel, string>> = DocumentFor<
|
|
186
|
+
DataModel,
|
|
187
|
+
TableName
|
|
188
|
+
>;
|
|
189
|
+
export type Id<TableName extends Extract<keyof DataModel, string>> = IdFor<TableName>;
|
|
190
|
+
`;
|
|
191
|
+
const renderApi = (moduleNames) => {
|
|
192
|
+
const modules = Array.match(moduleNames, {
|
|
193
|
+
onEmpty: () => " // No server function modules found.",
|
|
194
|
+
onNonEmpty: (names) => Array.join(Array.map(names, (moduleName) => ` readonly ${moduleName}: typeof import("../server/${moduleName}.js");`), "\n")
|
|
195
|
+
});
|
|
196
|
+
return `${generatedHeader}
|
|
197
|
+
|
|
198
|
+
import { createApi } from "ignotum/internal/api";
|
|
199
|
+
|
|
200
|
+
type Modules = {
|
|
201
|
+
${modules}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const api = createApi<Modules>();
|
|
205
|
+
`;
|
|
206
|
+
};
|
|
207
|
+
const inspectOutput = Effect.fn("Codegen.inspectOutput")(function* (output) {
|
|
208
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
209
|
+
if (!(yield* fileSystem.exists(output.path))) return "write";
|
|
210
|
+
const current = yield* fileSystem.readFileString(output.path);
|
|
211
|
+
if (current === output.content) return "unchanged";
|
|
212
|
+
if (!current.startsWith(generatedHeader)) return yield* GeneratedFileConflict.make({
|
|
213
|
+
message: `Refusing to overwrite ${output.path} because it was not created by Ignotum.`,
|
|
214
|
+
path: output.path
|
|
215
|
+
});
|
|
216
|
+
return "write";
|
|
217
|
+
});
|
|
218
|
+
const writeOutput = Effect.fn("Codegen.writeOutput")(function* (output) {
|
|
219
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
220
|
+
const directory = (yield* Path.Path).dirname(output.path);
|
|
221
|
+
yield* fileSystem.makeDirectory(directory, { recursive: true });
|
|
222
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
223
|
+
const temporaryPath = yield* fileSystem.makeTempFileScoped({
|
|
224
|
+
directory,
|
|
225
|
+
prefix: ".ignotum-",
|
|
226
|
+
suffix: ".ts"
|
|
227
|
+
});
|
|
228
|
+
yield* fileSystem.writeFileString(temporaryPath, output.content);
|
|
229
|
+
yield* fileSystem.rename(temporaryPath, output.path);
|
|
230
|
+
}));
|
|
231
|
+
});
|
|
232
|
+
const generate = Effect.fn("Codegen.generate")(function* (projectDirectory) {
|
|
233
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
234
|
+
const path = yield* Path.Path;
|
|
235
|
+
const serverDirectory = path.join(projectDirectory, "server");
|
|
236
|
+
const schemaPath = path.join(serverDirectory, "schema.ts");
|
|
237
|
+
if (!(yield* fileSystem.exists(schemaPath))) return yield* SchemaNotFound.make({
|
|
238
|
+
message: `No Ignotum schema found at ${schemaPath}.`,
|
|
239
|
+
path: schemaPath
|
|
240
|
+
});
|
|
241
|
+
const entries = yield* fileSystem.readDirectory(serverDirectory);
|
|
242
|
+
const functionFiles = Array.sort(String.Order)(Array.filter(entries, isFunctionModuleFile));
|
|
243
|
+
const functionModules = yield* Effect.forEach(functionFiles, (fileName) => moduleNameFromFile(path.join(serverDirectory, fileName), fileName));
|
|
244
|
+
const outputs = [
|
|
245
|
+
{
|
|
246
|
+
content: renderServerBindings(),
|
|
247
|
+
path: path.join(projectDirectory, "_generated", "server.ts")
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
content: renderApi(functionModules),
|
|
251
|
+
path: path.join(projectDirectory, "_generated", "api.ts")
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
content: renderTypes(),
|
|
255
|
+
path: path.join(projectDirectory, "_generated", "types.ts")
|
|
256
|
+
}
|
|
257
|
+
];
|
|
258
|
+
const inspections = yield* Effect.forEach(outputs, inspectOutput);
|
|
259
|
+
const inspectedOutputs = Array.zip(outputs, inspections);
|
|
260
|
+
const outputsToWrite = Array.map(Array.filter(inspectedOutputs, ([, inspection]) => inspection === "write"), ([output]) => output);
|
|
261
|
+
const unchanged = Array.map(Array.filter(inspectedOutputs, ([, inspection]) => inspection === "unchanged"), ([output]) => output.path);
|
|
262
|
+
const written = Array.map(outputsToWrite, (output) => output.path);
|
|
263
|
+
yield* Effect.forEach(outputsToWrite, writeOutput, { discard: true });
|
|
264
|
+
return {
|
|
265
|
+
functionModules,
|
|
266
|
+
unchanged,
|
|
267
|
+
written
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/dev-runtime/dev-database.ts
|
|
272
|
+
const databaseFileNames = [
|
|
273
|
+
"state.db",
|
|
274
|
+
"state.db-shm",
|
|
275
|
+
"state.db-wal",
|
|
276
|
+
"state.db-journal"
|
|
277
|
+
];
|
|
278
|
+
const lockFileName = "lock.json";
|
|
279
|
+
const DevDatabaseLockRecord = Schema.Struct({
|
|
280
|
+
pid: Schema.Int,
|
|
281
|
+
token: Schema.String
|
|
282
|
+
});
|
|
283
|
+
const DevDatabaseLockRecordJson = Schema.fromJsonString(DevDatabaseLockRecord);
|
|
284
|
+
var DevDatabaseResetFailed = class extends Schema.TaggedError()("DevDatabaseResetFailed", {
|
|
285
|
+
cause: Schema.Defect(),
|
|
286
|
+
message: Schema.String,
|
|
287
|
+
path: Schema.String
|
|
288
|
+
}) {};
|
|
289
|
+
var DevDatabaseInUse = class extends Schema.TaggedError()("DevDatabaseInUse", {
|
|
290
|
+
message: Schema.String,
|
|
291
|
+
path: Schema.String,
|
|
292
|
+
pid: Schema.Int
|
|
293
|
+
}) {};
|
|
294
|
+
var DevDatabaseLockFailed = class extends Schema.TaggedError()("DevDatabaseLockFailed", {
|
|
295
|
+
cause: Schema.Defect(),
|
|
296
|
+
message: Schema.String,
|
|
297
|
+
path: Schema.String
|
|
298
|
+
}) {};
|
|
299
|
+
const devDatabasePath = Effect.fn("DevDatabase.path")(function* (projectDirectory) {
|
|
300
|
+
return (yield* Path.Path).join(projectDirectory, ".ignotum", "dev", "state.db");
|
|
301
|
+
});
|
|
302
|
+
const processIsRunning = (pid) => {
|
|
303
|
+
try {
|
|
304
|
+
process.kill(pid, 0);
|
|
305
|
+
return true;
|
|
306
|
+
} catch (cause) {
|
|
307
|
+
return !(Predicate.hasProperty(cause, "code") && cause.code === "ESRCH");
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const acquireDevDatabaseLock = Effect.fn("DevDatabase.acquireLock")(function* (projectDirectory) {
|
|
311
|
+
const crypto = yield* Crypto.Crypto;
|
|
312
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
313
|
+
const path = yield* Path.Path;
|
|
314
|
+
const databasePath = yield* devDatabasePath(projectDirectory);
|
|
315
|
+
const directory = path.dirname(databasePath);
|
|
316
|
+
const lockPath = path.join(directory, lockFileName);
|
|
317
|
+
const record = {
|
|
318
|
+
pid: process.pid,
|
|
319
|
+
token: yield* crypto.randomUUIDv4
|
|
320
|
+
};
|
|
321
|
+
const encoded = yield* Schema.encodeEffect(DevDatabaseLockRecordJson)(record).pipe(Effect.mapError((cause) => DevDatabaseLockFailed.make({
|
|
322
|
+
cause,
|
|
323
|
+
message: `Could not encode the development database lock at ${lockPath}.`,
|
|
324
|
+
path: lockPath
|
|
325
|
+
})));
|
|
326
|
+
yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.mapError((cause) => DevDatabaseLockFailed.make({
|
|
327
|
+
cause,
|
|
328
|
+
message: `Could not create the development state directory at ${directory}.`,
|
|
329
|
+
path: lockPath
|
|
330
|
+
})));
|
|
331
|
+
const createLock = () => fileSystem.writeFileString(lockPath, encoded, { flag: "wx" }).pipe(Effect.matchEffect({
|
|
332
|
+
onFailure: (cause) => cause.reason._tag === "AlreadyExists" ? Effect.succeed(false) : DevDatabaseLockFailed.make({
|
|
333
|
+
cause,
|
|
334
|
+
message: `Could not create the development database lock at ${lockPath}.`,
|
|
335
|
+
path: lockPath
|
|
336
|
+
}),
|
|
337
|
+
onSuccess: () => Effect.succeed(true)
|
|
338
|
+
}));
|
|
339
|
+
const readLock = Effect.fn("DevDatabase.readLock")(function* () {
|
|
340
|
+
const content = yield* fileSystem.readFileString(lockPath).pipe(Effect.mapError((cause) => DevDatabaseLockFailed.make({
|
|
341
|
+
cause,
|
|
342
|
+
message: `Could not read the development database lock at ${lockPath}.`,
|
|
343
|
+
path: lockPath
|
|
344
|
+
})));
|
|
345
|
+
return yield* Schema.decodeEffect(DevDatabaseLockRecordJson)(content).pipe(Effect.mapError((cause) => DevDatabaseLockFailed.make({
|
|
346
|
+
cause,
|
|
347
|
+
message: `The development database lock at ${lockPath} is invalid. Remove it and try again.`,
|
|
348
|
+
path: lockPath
|
|
349
|
+
})));
|
|
350
|
+
});
|
|
351
|
+
const failInUse = (current) => DevDatabaseInUse.make({
|
|
352
|
+
message: `Another Ignotum development server is using ${databasePath} with process ${current.pid}.`,
|
|
353
|
+
path: lockPath,
|
|
354
|
+
pid: current.pid
|
|
355
|
+
});
|
|
356
|
+
const acquire = Effect.gen(function* () {
|
|
357
|
+
if (yield* createLock()) return record;
|
|
358
|
+
const current = yield* readLock();
|
|
359
|
+
if (processIsRunning(current.pid)) return yield* failInUse(current);
|
|
360
|
+
yield* fileSystem.remove(lockPath).pipe(Effect.mapError((cause) => DevDatabaseLockFailed.make({
|
|
361
|
+
cause,
|
|
362
|
+
message: `Could not remove the stale development database lock at ${lockPath}.`,
|
|
363
|
+
path: lockPath
|
|
364
|
+
})));
|
|
365
|
+
if (yield* createLock()) return record;
|
|
366
|
+
return yield* failInUse(yield* readLock());
|
|
367
|
+
});
|
|
368
|
+
const release = (owned) => Effect.gen(function* () {
|
|
369
|
+
if (!(yield* fileSystem.exists(lockPath))) return;
|
|
370
|
+
const content = yield* fileSystem.readFileString(lockPath);
|
|
371
|
+
const current = Schema.decodeOption(DevDatabaseLockRecordJson)(content);
|
|
372
|
+
if (Option.isNone(current) || current.value.token !== owned.token) return;
|
|
373
|
+
yield* fileSystem.remove(lockPath);
|
|
374
|
+
}).pipe(Effect.catchCause((cause) => Effect.logWarning(`Could not release the development database lock at ${lockPath}.`).pipe(Effect.annotateLogs({ cause }))));
|
|
375
|
+
yield* Effect.acquireRelease(acquire, release);
|
|
376
|
+
});
|
|
377
|
+
const resetDevDatabase = Effect.fn("DevDatabase.reset")(function* (projectDirectory) {
|
|
378
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
379
|
+
yield* acquireDevDatabaseLock(projectDirectory);
|
|
380
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
381
|
+
const path = yield* Path.Path;
|
|
382
|
+
const directory = path.join(projectDirectory, ".ignotum", "dev");
|
|
383
|
+
const databasePath = yield* devDatabasePath(projectDirectory);
|
|
384
|
+
let removed = 0;
|
|
385
|
+
yield* Effect.forEach(databaseFileNames, Effect.fn("DevDatabase.removeFile")(function* (fileName) {
|
|
386
|
+
const filePath = path.join(directory, fileName);
|
|
387
|
+
if (!(yield* fileSystem.exists(filePath))) return;
|
|
388
|
+
yield* fileSystem.remove(filePath);
|
|
389
|
+
removed += 1;
|
|
390
|
+
}), { discard: true }).pipe(Effect.mapError((cause) => DevDatabaseResetFailed.make({
|
|
391
|
+
cause,
|
|
392
|
+
message: `Could not reset ${databasePath}. Stop the Ignotum dev server and try again.`,
|
|
393
|
+
path: databasePath
|
|
394
|
+
})));
|
|
395
|
+
return {
|
|
396
|
+
databasePath,
|
|
397
|
+
removed
|
|
398
|
+
};
|
|
399
|
+
}));
|
|
400
|
+
});
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/cli/client-plugin.ts
|
|
403
|
+
const clientEntryId = "virtual:ignotum/client-entry";
|
|
404
|
+
const resolvedClientEntryId = `\0${clientEntryId}`;
|
|
405
|
+
const document = `<!doctype html>
|
|
406
|
+
<html lang="en">
|
|
407
|
+
<head>
|
|
408
|
+
<meta charset="UTF-8" />
|
|
409
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
410
|
+
<title>Ignotum</title>
|
|
411
|
+
</head>
|
|
412
|
+
<body>
|
|
413
|
+
<div id="app"></div>
|
|
414
|
+
<script type="module">
|
|
415
|
+
import "${clientEntryId}"
|
|
416
|
+
<\/script>
|
|
417
|
+
</body>
|
|
418
|
+
</html>`;
|
|
419
|
+
const clientEntry = `import "preact/debug"
|
|
420
|
+
import { render } from "preact"
|
|
421
|
+
import { jsx } from "preact/jsx-runtime"
|
|
422
|
+
|
|
423
|
+
import "/styles.css"
|
|
424
|
+
import App from "/App.tsx"
|
|
425
|
+
|
|
426
|
+
const root = document.getElementById("app")
|
|
427
|
+
|
|
428
|
+
if (root === null) {
|
|
429
|
+
throw new Error("Ignotum mount element is missing.")
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
render(jsx(App, {}), root)
|
|
433
|
+
`;
|
|
434
|
+
const ignotumClientPlugin = () => ({
|
|
435
|
+
name: "ignotum:client",
|
|
436
|
+
configureServer: (server) => {
|
|
437
|
+
server.middlewares.use((request, response, next) => {
|
|
438
|
+
const acceptsHtml = request.headers.accept?.includes("text/html") === true;
|
|
439
|
+
const isNavigation = request.method === "GET" || request.method === "HEAD";
|
|
440
|
+
if (!acceptsHtml || !isNavigation) {
|
|
441
|
+
next();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const requestUrl = request.originalUrl ?? request.url ?? "/";
|
|
445
|
+
server.transformIndexHtml(requestUrl, document).then((html) => {
|
|
446
|
+
response.statusCode = 200;
|
|
447
|
+
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
448
|
+
response.end(request.method === "HEAD" ? void 0 : html);
|
|
449
|
+
}).catch(next);
|
|
450
|
+
});
|
|
451
|
+
},
|
|
452
|
+
load: (id) => id === resolvedClientEntryId ? clientEntry : void 0,
|
|
453
|
+
resolveId: (id) => id === clientEntryId ? resolvedClientEntryId : void 0
|
|
454
|
+
});
|
|
455
|
+
//#endregion
|
|
456
|
+
//#region ../runtime/dist/functions.js
|
|
457
|
+
var FunctionRuntime = class extends Context.Service()("@ignotum/runtime/functions/FunctionRuntime") {};
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region ../runtime/dist/sync.js
|
|
460
|
+
var QueryInvalidation = class extends Context.Service()("@ignotum/runtime/sync/QueryInvalidation") {};
|
|
461
|
+
const syncPath = `/_ignotum/sync`;
|
|
462
|
+
const isIgnotumPath = (pathname) => pathname === "/_ignotum" || pathname.startsWith(`/_ignotum/`);
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region ../contracts/dist/runtime/id.js
|
|
465
|
+
const IdAlphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
466
|
+
const GeneratedId = Schema.String.check(Schema.isPattern(/^[0-9a-z]{24}$/)).pipe(Schema.brand("ignotum/id"));
|
|
467
|
+
//#endregion
|
|
468
|
+
//#region ../contracts/dist/runtime/result.js
|
|
469
|
+
const ResultTypeId = Symbol.for("ignotum/runtime/result/Result");
|
|
470
|
+
const CompletedResultTypeId = Symbol.for("ignotum/runtime/result/Completed");
|
|
471
|
+
const PendingResultTypeId = Symbol.for("ignotum/runtime/result/Pending");
|
|
472
|
+
const ErrorBrand = "ignotum/error";
|
|
473
|
+
Brand.nominal();
|
|
474
|
+
const ErrorValueSchema = Schema.StructWithRest(Schema.Struct({ _tag: Schema.String }), [Schema.Record(Schema.String, Schema.Json)]).pipe(Schema.brand(ErrorBrand));
|
|
475
|
+
var InternalServerErrorCause = class extends Schema.TaggedError()("InternalServerError", { requestId: Schema.String }) {};
|
|
476
|
+
const InternalServerErrorSchema = InternalServerErrorCause.pipe(Schema.brand(ErrorBrand));
|
|
477
|
+
const effectOf = (value) => Predicate.hasProperty(value, ResultTypeId) ? value[ResultTypeId] : Effect.succeed(value);
|
|
478
|
+
function makeResultEffectBase() {
|
|
479
|
+
const Base = function() {};
|
|
480
|
+
Base.prototype = Effectable.Prototype({
|
|
481
|
+
label: "IgnotumResult",
|
|
482
|
+
evaluate() {
|
|
483
|
+
return this[ResultTypeId];
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
return Base;
|
|
487
|
+
}
|
|
488
|
+
const ResultEffectBase = makeResultEffectBase();
|
|
489
|
+
var ResultOperation = class ResultOperation extends ResultEffectBase {
|
|
490
|
+
[ResultTypeId];
|
|
491
|
+
constructor(effect, completed) {
|
|
492
|
+
super();
|
|
493
|
+
this[ResultTypeId] = effect;
|
|
494
|
+
if (completed !== void 0) Object.defineProperty(this, CompletedResultTypeId, { value: completed });
|
|
495
|
+
}
|
|
496
|
+
catch(cases) {
|
|
497
|
+
const effectCases = {};
|
|
498
|
+
for (const [tag, handler] of Object.entries(cases)) if (Predicate.isFunction(handler)) effectCases[tag] = (failure) => effectOf(handler(failure));
|
|
499
|
+
const runtimeEffect = this[ResultTypeId];
|
|
500
|
+
const caught = Effect.catchTags(runtimeEffect, effectCases);
|
|
501
|
+
return new ResultOperation(caught);
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
var PendingQueryResult = class {
|
|
505
|
+
[PendingResultTypeId] = PendingResultTypeId;
|
|
506
|
+
};
|
|
507
|
+
const inspectResult = (value) => {
|
|
508
|
+
if (!Predicate.hasProperty(value, CompletedResultTypeId)) return void 0;
|
|
509
|
+
return value[CompletedResultTypeId];
|
|
510
|
+
};
|
|
511
|
+
const resultFromEffect = (effect) => new ResultOperation(effect);
|
|
512
|
+
const isFailureResult = (result) => Predicate.hasProperty(result, CompletedResultTypeId) && Predicate.isObject(result[CompletedResultTypeId]) && Predicate.hasProperty(result[CompletedResultTypeId], "type") && result[CompletedResultTypeId].type === "Failure";
|
|
513
|
+
Function.dual(2, (result, matchers) => {
|
|
514
|
+
if (result instanceof PendingQueryResult) {
|
|
515
|
+
if (matchers.pending === void 0) throw new Error("A pending Result requires a pending matcher.");
|
|
516
|
+
return matchers.pending();
|
|
517
|
+
}
|
|
518
|
+
if (!Predicate.hasProperty(result, ResultTypeId)) throw new Error("Unknown Result implementation.");
|
|
519
|
+
const inspected = inspectResult(result);
|
|
520
|
+
if (inspected?.type === "Success") {
|
|
521
|
+
const value = inspected.value;
|
|
522
|
+
return matchers.value(value);
|
|
523
|
+
}
|
|
524
|
+
if (!isFailureResult(result)) throw new Error("Unknown or incomplete Result operation.");
|
|
525
|
+
const error = result[CompletedResultTypeId].error;
|
|
526
|
+
if (error._tag === "InternalServerError") {
|
|
527
|
+
const internalError = Schema.decodeUnknownSync(InternalServerErrorSchema)(error);
|
|
528
|
+
const handler = matchers.internalError;
|
|
529
|
+
if (handler === void 0) throw internalError;
|
|
530
|
+
return handler(internalError);
|
|
531
|
+
}
|
|
532
|
+
const errorMatcher = matchers.error;
|
|
533
|
+
if (errorMatcher === void 0) throw new Error(`No matcher was provided for ${error._tag}.`);
|
|
534
|
+
if (Predicate.isFunction(errorMatcher)) return errorMatcher(error);
|
|
535
|
+
const handlers = errorMatcher;
|
|
536
|
+
const handler = Object.hasOwn(handlers, error._tag) ? handlers[error._tag] : void 0;
|
|
537
|
+
if (handler === void 0) throw new Error(`No matcher was provided for ${error._tag}.`);
|
|
538
|
+
return handler(error);
|
|
539
|
+
});
|
|
540
|
+
const documentNotFound = (table, id) => Brand.nominal()({
|
|
541
|
+
_tag: "DocumentNotFound",
|
|
542
|
+
table,
|
|
543
|
+
id
|
|
544
|
+
});
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region ../contracts/dist/runtime/schema.js
|
|
547
|
+
const FunctionSchemaTypeId = Symbol.for("ignotum/runtime/schema/FunctionSchema");
|
|
548
|
+
const getFunctionSchema = (value) => value[FunctionSchemaTypeId];
|
|
549
|
+
//#endregion
|
|
550
|
+
//#region ../contracts/dist/schema/index.js
|
|
551
|
+
const SchemaDefinitionTypeId = Symbol.for("ignotum/schema/SchemaDefinition");
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region ../runtime/dist/id.js
|
|
554
|
+
var IdGenerator = class extends Context.Service()("@ignotum/runtime/id/IdGenerator") {};
|
|
555
|
+
//#endregion
|
|
556
|
+
//#region src/dev-runtime/database.ts
|
|
557
|
+
const StoredDocument = Schema.Struct({
|
|
558
|
+
id: GeneratedId,
|
|
559
|
+
tableName: Schema.String,
|
|
560
|
+
createdAt: Schema.Int,
|
|
561
|
+
updatedAt: Schema.Int,
|
|
562
|
+
fields: Schema.String
|
|
563
|
+
});
|
|
564
|
+
const DocumentLookup = Schema.Struct({
|
|
565
|
+
id: Schema.String,
|
|
566
|
+
tableName: Schema.String
|
|
567
|
+
});
|
|
568
|
+
const TableLookup = Schema.Struct({ tableName: Schema.String });
|
|
569
|
+
var UnknownDatabaseTable = class extends Schema.TaggedError()("UnknownDatabaseTable", {
|
|
570
|
+
message: Schema.String,
|
|
571
|
+
tableName: Schema.String
|
|
572
|
+
}) {};
|
|
573
|
+
var DocumentSchemaMismatch = class extends Schema.TaggedError()("DocumentSchemaMismatch", {
|
|
574
|
+
cause: Schema.Defect(),
|
|
575
|
+
id: Schema.NullOr(Schema.String),
|
|
576
|
+
message: Schema.String,
|
|
577
|
+
operation: Schema.Literals([
|
|
578
|
+
"collect",
|
|
579
|
+
"get",
|
|
580
|
+
"insert",
|
|
581
|
+
"patch",
|
|
582
|
+
"replace"
|
|
583
|
+
]),
|
|
584
|
+
tableName: Schema.String
|
|
585
|
+
}) {};
|
|
586
|
+
const schemaMismatch = (tableName, id, operation, cause) => DocumentSchemaMismatch.make({
|
|
587
|
+
cause,
|
|
588
|
+
id,
|
|
589
|
+
message: `Document fields for ${tableName} did not match its schema during ${operation}.`,
|
|
590
|
+
operation,
|
|
591
|
+
tableName
|
|
592
|
+
});
|
|
593
|
+
const tableFor = Effect.fn("LocalDatabase.tableFor")(function* (schema, tableName) {
|
|
594
|
+
const table = schema[SchemaDefinitionTypeId][tableName];
|
|
595
|
+
if (table === void 0) return yield* UnknownDatabaseTable.make({
|
|
596
|
+
message: `Unknown database table ${tableName}.`,
|
|
597
|
+
tableName
|
|
598
|
+
});
|
|
599
|
+
return table;
|
|
600
|
+
});
|
|
601
|
+
const fieldsCodec = (table) => Schema.fromJsonString(Schema.make(Schema.toCodecJson(table.schema).ast));
|
|
602
|
+
const encodeFields = Effect.fn("LocalDatabase.encodeFields")(function* (schema, tableName, value, operation, id) {
|
|
603
|
+
const table = yield* tableFor(schema, tableName);
|
|
604
|
+
return yield* Schema.encodeEffect(fieldsCodec(table), { onExcessProperty: "error" })(value).pipe(Effect.mapError((cause) => schemaMismatch(tableName, id, operation, cause)));
|
|
605
|
+
});
|
|
606
|
+
const decodeFields = Effect.fn("LocalDatabase.decodeFields")(function* (schema, row, operation) {
|
|
607
|
+
const table = yield* tableFor(schema, row.tableName);
|
|
608
|
+
return yield* Schema.decodeEffect(fieldsCodec(table), { onExcessProperty: "error" })(row.fields).pipe(Effect.mapError((cause) => schemaMismatch(row.tableName, row.id, operation, cause)));
|
|
609
|
+
});
|
|
610
|
+
const toRuntimeDocument = Effect.fn("LocalDatabase.toRuntimeDocument")(function* (schema, row, operation) {
|
|
611
|
+
return {
|
|
612
|
+
...yield* decodeFields(schema, row, operation),
|
|
613
|
+
id: row.id,
|
|
614
|
+
createdAt: DateTime.toDate(DateTime.makeUnsafe(row.createdAt)),
|
|
615
|
+
updatedAt: DateTime.toDate(DateTime.makeUnsafe(row.updatedAt))
|
|
616
|
+
};
|
|
617
|
+
});
|
|
618
|
+
const nextUpdatedAt = (now, previous) => Math.max(DateTime.toEpochMillis(now), previous + 1);
|
|
619
|
+
var LocalDatabase = class LocalDatabase extends Context.Service()("ignotum/dev-runtime/database/LocalDatabase") {
|
|
620
|
+
static layer = Layer.effect(LocalDatabase, Effect.gen(function* () {
|
|
621
|
+
const ids = yield* IdGenerator;
|
|
622
|
+
const sql = yield* SqlClient.SqlClient;
|
|
623
|
+
yield* sql`
|
|
624
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
625
|
+
id TEXT PRIMARY KEY NOT NULL
|
|
626
|
+
CHECK (length(id) = 24 AND id NOT GLOB '*[^0-9a-z]*'),
|
|
627
|
+
tableName TEXT NOT NULL,
|
|
628
|
+
createdAt INTEGER NOT NULL,
|
|
629
|
+
updatedAt INTEGER NOT NULL,
|
|
630
|
+
fields TEXT NOT NULL CHECK (json_valid(fields))
|
|
631
|
+
) STRICT
|
|
632
|
+
`;
|
|
633
|
+
yield* sql`
|
|
634
|
+
CREATE INDEX IF NOT EXISTS documents_table_name_idx
|
|
635
|
+
ON documents (tableName)
|
|
636
|
+
`;
|
|
637
|
+
const findStored = SqlSchema.findOneOption({
|
|
638
|
+
Request: DocumentLookup,
|
|
639
|
+
Result: StoredDocument,
|
|
640
|
+
execute: ({ id, tableName }) => sql`
|
|
641
|
+
SELECT id, tableName, createdAt, updatedAt, fields
|
|
642
|
+
FROM documents
|
|
643
|
+
WHERE id = ${id} AND tableName = ${tableName}
|
|
644
|
+
LIMIT 1
|
|
645
|
+
`
|
|
646
|
+
});
|
|
647
|
+
const collectStored = SqlSchema.findAll({
|
|
648
|
+
Request: TableLookup,
|
|
649
|
+
Result: StoredDocument,
|
|
650
|
+
execute: ({ tableName }) => sql`
|
|
651
|
+
SELECT id, tableName, createdAt, updatedAt, fields
|
|
652
|
+
FROM documents
|
|
653
|
+
WHERE tableName = ${tableName}
|
|
654
|
+
ORDER BY createdAt ASC, id ASC
|
|
655
|
+
`
|
|
656
|
+
});
|
|
657
|
+
const find = Effect.fn("LocalDatabase.find")(function* (schema, tableName, id) {
|
|
658
|
+
yield* tableFor(schema, tableName);
|
|
659
|
+
const row = yield* findStored({
|
|
660
|
+
id,
|
|
661
|
+
tableName
|
|
662
|
+
});
|
|
663
|
+
if (Option.isNone(row)) return void 0;
|
|
664
|
+
return yield* toRuntimeDocument(schema, row.value, "get");
|
|
665
|
+
});
|
|
666
|
+
const collect = Effect.fn("LocalDatabase.collect")(function* (schema, tableName) {
|
|
667
|
+
yield* tableFor(schema, tableName);
|
|
668
|
+
const rows = yield* collectStored({ tableName });
|
|
669
|
+
return yield* Effect.forEach(rows, (row) => toRuntimeDocument(schema, row, "collect"));
|
|
670
|
+
});
|
|
671
|
+
const insert = Effect.fn("LocalDatabase.insert")(function* (schema, tableName, value) {
|
|
672
|
+
const fields = yield* encodeFields(schema, tableName, value, "insert", null);
|
|
673
|
+
const id = yield* ids.generate;
|
|
674
|
+
const now = DateTime.toEpochMillis(yield* DateTime.now);
|
|
675
|
+
yield* sql`
|
|
676
|
+
INSERT INTO documents (id, tableName, createdAt, updatedAt, fields)
|
|
677
|
+
VALUES (${id}, ${tableName}, ${now}, ${now}, ${fields})
|
|
678
|
+
`;
|
|
679
|
+
return id;
|
|
680
|
+
});
|
|
681
|
+
const deleteDocument = Effect.fn("LocalDatabase.delete")(function* (schema, tableName, id) {
|
|
682
|
+
yield* tableFor(schema, tableName);
|
|
683
|
+
yield* sql`DELETE FROM documents WHERE id = ${id} AND tableName = ${tableName}`;
|
|
684
|
+
});
|
|
685
|
+
const patch = Effect.fn("LocalDatabase.patch")(function* (schema, tableName, id, value) {
|
|
686
|
+
yield* tableFor(schema, tableName);
|
|
687
|
+
const stored = yield* findStored({
|
|
688
|
+
id,
|
|
689
|
+
tableName
|
|
690
|
+
});
|
|
691
|
+
if (Option.isNone(stored)) return;
|
|
692
|
+
const current = yield* decodeFields(schema, stored.value, "patch");
|
|
693
|
+
const fields = yield* encodeFields(schema, tableName, {
|
|
694
|
+
...current,
|
|
695
|
+
...value
|
|
696
|
+
}, "patch", id);
|
|
697
|
+
const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
|
|
698
|
+
yield* sql`
|
|
699
|
+
UPDATE documents
|
|
700
|
+
SET fields = ${fields}, updatedAt = ${updatedAt}
|
|
701
|
+
WHERE id = ${id} AND tableName = ${tableName}
|
|
702
|
+
`;
|
|
703
|
+
});
|
|
704
|
+
const replace = Effect.fn("LocalDatabase.replace")(function* (schema, tableName, id, value) {
|
|
705
|
+
yield* tableFor(schema, tableName);
|
|
706
|
+
const stored = yield* findStored({
|
|
707
|
+
id,
|
|
708
|
+
tableName
|
|
709
|
+
});
|
|
710
|
+
if (Option.isNone(stored)) return;
|
|
711
|
+
const fields = yield* encodeFields(schema, tableName, value, "replace", id);
|
|
712
|
+
const updatedAt = nextUpdatedAt(yield* DateTime.now, stored.value.updatedAt);
|
|
713
|
+
yield* sql`
|
|
714
|
+
UPDATE documents
|
|
715
|
+
SET fields = ${fields}, updatedAt = ${updatedAt}
|
|
716
|
+
WHERE id = ${id} AND tableName = ${tableName}
|
|
717
|
+
`;
|
|
718
|
+
});
|
|
719
|
+
const makeReader = (schema) => Object.freeze({
|
|
720
|
+
find: (tableName, id) => resultFromEffect(find(schema, tableName, id).pipe(Effect.orDie)),
|
|
721
|
+
get: (tableName, id) => resultFromEffect(find(schema, tableName, id).pipe(Effect.orDie, Effect.flatMap((document) => document === void 0 ? Effect.fail(documentNotFound(tableName, id)) : Effect.succeed(document)))),
|
|
722
|
+
query: (tableName) => Object.freeze({ collect: () => resultFromEffect(collect(schema, tableName).pipe(Effect.orDie)) })
|
|
723
|
+
});
|
|
724
|
+
const makeWriter = (schema) => {
|
|
725
|
+
const reader = makeReader(schema);
|
|
726
|
+
return Object.freeze({
|
|
727
|
+
...reader,
|
|
728
|
+
delete: (tableName, id) => resultFromEffect(deleteDocument(schema, tableName, id).pipe(Effect.orDie)),
|
|
729
|
+
insert: (tableName, value) => resultFromEffect(insert(schema, tableName, value).pipe(Effect.orDie)),
|
|
730
|
+
patch: (tableName, id, value) => resultFromEffect(patch(schema, tableName, id, value).pipe(Effect.orDie)),
|
|
731
|
+
replace: (tableName, id, value) => resultFromEffect(replace(schema, tableName, id, value).pipe(Effect.orDie))
|
|
732
|
+
});
|
|
733
|
+
};
|
|
734
|
+
return LocalDatabase.of({
|
|
735
|
+
queryTransaction: (schema, use) => sql.withTransaction(Effect.gen(function* () {
|
|
736
|
+
return yield* use(Object.freeze({ db: makeReader(schema) }));
|
|
737
|
+
})),
|
|
738
|
+
mutationTransaction: (schema, use) => sql.withTransaction(Effect.gen(function* () {
|
|
739
|
+
return yield* use(Object.freeze({ db: makeWriter(schema) }));
|
|
740
|
+
}))
|
|
741
|
+
});
|
|
742
|
+
}));
|
|
743
|
+
};
|
|
744
|
+
//#endregion
|
|
745
|
+
//#region src/dev-runtime/id.ts
|
|
746
|
+
const idGeneratorLayer = Layer.sync(IdGenerator, () => {
|
|
747
|
+
const nanoid = customAlphabet(IdAlphabet, 24);
|
|
748
|
+
return IdGenerator.of({ generate: Effect.sync(() => GeneratedId.make(nanoid())) });
|
|
749
|
+
});
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region ../contracts/dist/runtime/functions.js
|
|
752
|
+
const FunctionKind = Schema.Literals(["Mutation", "Query"]);
|
|
753
|
+
var UnknownFunction = class extends Schema.TaggedError()("UnknownFunction", {
|
|
754
|
+
function: FunctionAddress,
|
|
755
|
+
message: Schema.String
|
|
756
|
+
}) {};
|
|
757
|
+
var WrongFunctionKind = class extends Schema.TaggedError()("WrongFunctionKind", {
|
|
758
|
+
actual: FunctionKind,
|
|
759
|
+
expected: FunctionKind,
|
|
760
|
+
function: FunctionAddress,
|
|
761
|
+
message: Schema.String
|
|
762
|
+
}) {};
|
|
763
|
+
var InvalidArguments = class extends Schema.TaggedError()("InvalidArguments", {
|
|
764
|
+
function: FunctionAddress,
|
|
765
|
+
message: Schema.String
|
|
766
|
+
}) {};
|
|
767
|
+
var FunctionUnavailable = class extends Schema.TaggedError()("FunctionUnavailable", {
|
|
768
|
+
cause: Schema.Defect(),
|
|
769
|
+
function: FunctionAddress,
|
|
770
|
+
message: Schema.String
|
|
771
|
+
}) {};
|
|
772
|
+
//#endregion
|
|
773
|
+
//#region src/dev-runtime/functions.ts
|
|
774
|
+
const inspectRuntimeFunctionDefinition = (value) => {
|
|
775
|
+
const schema = getFunctionSchema(value);
|
|
776
|
+
if (schema === void 0 || !Predicate.hasProperty(value, "_tag") || value._tag !== "Mutation" && value._tag !== "Query" || Predicate.hasProperty(value, "args") && value.args !== void 0 && !Predicate.isObject(value.args) || Predicate.hasProperty(value, "returns") && value.returns !== void 0 && !Schema.isSchema(value.returns) || Predicate.hasProperty(value, "errors") && value.errors !== void 0 && !Schema.isSchema(value.errors) || !Predicate.hasProperty(value, "handler") || !Predicate.isFunction(value.handler)) return;
|
|
777
|
+
if (Predicate.hasProperty(value, "args") && Predicate.isObject(value.args) && !Object.values(value.args).every(Schema.isSchema)) return;
|
|
778
|
+
return {
|
|
779
|
+
definition: value,
|
|
780
|
+
schema
|
|
781
|
+
};
|
|
782
|
+
};
|
|
783
|
+
var FunctionRegistry = class FunctionRegistry extends Context.Service()("ignotum/dev-runtime/functions/FunctionRegistry") {
|
|
784
|
+
static layer(server, projectDirectory) {
|
|
785
|
+
return Layer.effect(FunctionRegistry, Effect.gen(function* () {
|
|
786
|
+
const path = yield* Path.Path;
|
|
787
|
+
return FunctionRegistry.of({ resolve: Effect.fn("FunctionRegistry.resolve")(function* (functionAddress, kind, args) {
|
|
788
|
+
const { functionName, moduleName } = apiFunctionParts(functionAddress);
|
|
789
|
+
const modulePath = normalizePath(path.join(projectDirectory, "server", `${moduleName}.ts`));
|
|
790
|
+
const candidate = (yield* Effect.tryPromise({
|
|
791
|
+
try: () => server.ssrLoadModule(modulePath),
|
|
792
|
+
catch: (cause) => FunctionUnavailable.make({
|
|
793
|
+
cause,
|
|
794
|
+
function: functionAddress,
|
|
795
|
+
message: `Could not load ${functionAddress}.`
|
|
796
|
+
})
|
|
797
|
+
}))[functionName];
|
|
798
|
+
const inspected = Predicate.isObject(candidate) ? inspectRuntimeFunctionDefinition(candidate) : void 0;
|
|
799
|
+
if (inspected === void 0) return yield* UnknownFunction.make({
|
|
800
|
+
function: functionAddress,
|
|
801
|
+
message: `Unknown server function ${functionAddress}.`
|
|
802
|
+
});
|
|
803
|
+
const definition = inspected.definition;
|
|
804
|
+
if (definition._tag !== kind) return yield* WrongFunctionKind.make({
|
|
805
|
+
actual: definition._tag,
|
|
806
|
+
expected: kind,
|
|
807
|
+
function: functionAddress,
|
|
808
|
+
message: `${functionAddress} is a ${definition._tag.toLowerCase()}, not a ${kind.toLowerCase()}.`
|
|
809
|
+
});
|
|
810
|
+
return {
|
|
811
|
+
args: yield* Schema.decodeEffect(Schema.toCodecJson(Schema.Struct(definition.args ?? {})))(args).pipe(Effect.mapError(() => InvalidArguments.make({
|
|
812
|
+
function: functionAddress,
|
|
813
|
+
message: `Invalid arguments for ${functionAddress}.`
|
|
814
|
+
}))),
|
|
815
|
+
definition,
|
|
816
|
+
schema: inspected.schema
|
|
817
|
+
};
|
|
818
|
+
}) });
|
|
819
|
+
}));
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
var MutationApplicationFailure = class extends Schema.TaggedError()("MutationApplicationFailure", { error: Schema.Json }) {};
|
|
823
|
+
var FunctionExecutor = class FunctionExecutor extends Context.Service()("ignotum/dev-runtime/functions/FunctionExecutor") {
|
|
824
|
+
static layer = Layer.effect(FunctionExecutor, Effect.gen(function* () {
|
|
825
|
+
const database = yield* LocalDatabase;
|
|
826
|
+
const mutationSemaphore = yield* Semaphore.make(1);
|
|
827
|
+
const encodeSuccess = (definition, value) => definition.returns === void 0 ? Schema.encodeUnknownEffect(Schema.Undefined)(value).pipe(Effect.orDie, Effect.as({ type: "Success" })) : Schema.encodeEffect(Schema.toCodecJson(definition.returns))(value).pipe(Effect.orDie, Effect.map((encoded) => {
|
|
828
|
+
const result = {
|
|
829
|
+
type: "Success",
|
|
830
|
+
value: encoded
|
|
831
|
+
};
|
|
832
|
+
const dates = datePathsOf(value);
|
|
833
|
+
return dates.length === 0 ? result : {
|
|
834
|
+
...result,
|
|
835
|
+
dates
|
|
836
|
+
};
|
|
837
|
+
}));
|
|
838
|
+
const encodeFailure = (definition, error) => Schema.encodeEffect(Schema.toCodecJson(definition.errors ?? ErrorValueSchema))(error).pipe(Effect.orDie, Effect.map((encoded) => {
|
|
839
|
+
const result = {
|
|
840
|
+
type: "Failure",
|
|
841
|
+
error: encoded
|
|
842
|
+
};
|
|
843
|
+
const dates = datePathsOfObject(error);
|
|
844
|
+
return dates.length === 0 ? result : {
|
|
845
|
+
...result,
|
|
846
|
+
dates
|
|
847
|
+
};
|
|
848
|
+
}));
|
|
849
|
+
return FunctionExecutor.of({ execute: Effect.fn("FunctionExecutor.execute")(function* (functionAddress, kind, resolved) {
|
|
850
|
+
const invoke = (context) => Effect.gen(() => resolved.definition.handler(context, resolved.args)).pipe(Effect.matchEffect({
|
|
851
|
+
onFailure: (error) => encodeFailure(resolved.definition, error),
|
|
852
|
+
onSuccess: (value) => encodeSuccess(resolved.definition, value)
|
|
853
|
+
}));
|
|
854
|
+
return yield* (kind === "Query" ? database.queryTransaction(resolved.schema, invoke) : mutationSemaphore.withPermits(1)(database.mutationTransaction(resolved.schema, (context) => invoke(context).pipe(Effect.flatMap((result) => result.type === "Failure" ? Effect.fail(MutationApplicationFailure.make({ error: result.error })) : Effect.succeed(result)))).pipe(Effect.catchTags({ MutationApplicationFailure: ({ error }) => Effect.succeed({
|
|
855
|
+
type: "Failure",
|
|
856
|
+
error
|
|
857
|
+
}) })))).pipe(Effect.orDie, Effect.catchCauseIf((cause) => !Cause.hasInterruptsOnly(cause), (cause) => {
|
|
858
|
+
const requestId = nanoid();
|
|
859
|
+
return Effect.logError(`${functionAddress} failed during execution.`).pipe(Effect.annotateLogs({
|
|
860
|
+
cause,
|
|
861
|
+
function: functionAddress,
|
|
862
|
+
requestId
|
|
863
|
+
}), Effect.as({
|
|
864
|
+
type: "Failure",
|
|
865
|
+
error: {
|
|
866
|
+
_tag: "InternalServerError",
|
|
867
|
+
requestId
|
|
868
|
+
}
|
|
869
|
+
}));
|
|
870
|
+
}));
|
|
871
|
+
}) });
|
|
872
|
+
}));
|
|
873
|
+
};
|
|
874
|
+
/** Adapts the Vite/SQLite development implementation to the shared runtime contract. */
|
|
875
|
+
const functionRuntimeLayer = Layer.effect(FunctionRuntime, Effect.gen(function* () {
|
|
876
|
+
const registry = yield* FunctionRegistry;
|
|
877
|
+
const executor = yield* FunctionExecutor;
|
|
878
|
+
return FunctionRuntime.of({ prepare: Effect.fn("DevFunctionRuntime.prepare")(function* (functionAddress, kind, args) {
|
|
879
|
+
const resolved = yield* registry.resolve(functionAddress, kind, args);
|
|
880
|
+
return { execute: executor.execute(functionAddress, kind, resolved) };
|
|
881
|
+
}) });
|
|
882
|
+
}));
|
|
883
|
+
//#endregion
|
|
884
|
+
//#region src/dev-runtime/sync.ts
|
|
885
|
+
const queryInvalidationLayer = Layer.effect(QueryInvalidation, Effect.gen(function* () {
|
|
886
|
+
const pubsub = yield* PubSub.unbounded();
|
|
887
|
+
return QueryInvalidation.of({
|
|
888
|
+
publish: PubSub.publish(pubsub, void 0),
|
|
889
|
+
subscribe: PubSub.subscribe(pubsub)
|
|
890
|
+
});
|
|
891
|
+
}));
|
|
892
|
+
const operationForQuery = (subscriptionId) => ({
|
|
893
|
+
type: "Query",
|
|
894
|
+
id: subscriptionId
|
|
895
|
+
});
|
|
896
|
+
const protocolError = (code, message, operation) => {
|
|
897
|
+
if (operation === void 0) return {
|
|
898
|
+
type: "ProtocolError",
|
|
899
|
+
code,
|
|
900
|
+
message
|
|
901
|
+
};
|
|
902
|
+
return {
|
|
903
|
+
type: "ProtocolError",
|
|
904
|
+
code,
|
|
905
|
+
message,
|
|
906
|
+
operation
|
|
907
|
+
};
|
|
908
|
+
};
|
|
909
|
+
const runSession = Effect.fn("SyncServer.runSession")(function* (socket) {
|
|
910
|
+
const runtime = yield* FunctionRuntime;
|
|
911
|
+
const invalidation = yield* QueryInvalidation;
|
|
912
|
+
const subscriptions = yield* Ref.make(HashMap.empty());
|
|
913
|
+
const invocationFibers = yield* FiberSet.make();
|
|
914
|
+
const querySemaphore = yield* PartitionedSemaphore.make({ permits: 1 });
|
|
915
|
+
const messageSemaphore = yield* Semaphore.make(1);
|
|
916
|
+
const writeSemaphore = yield* Semaphore.make(1);
|
|
917
|
+
const write = yield* socket.writer;
|
|
918
|
+
const send = Effect.fn("SyncServer.send")(function* (message) {
|
|
919
|
+
yield* writeSemaphore.withPermits(1)(Schema.encodeEffect(ServerMessageJson)(message).pipe(Effect.flatMap(write)));
|
|
920
|
+
});
|
|
921
|
+
const sendResolutionError = (operation, error, deliver = send) => {
|
|
922
|
+
const code = error._tag;
|
|
923
|
+
return deliver(protocolError(code, error.message, operation));
|
|
924
|
+
};
|
|
925
|
+
const isActive = (subscriptionId, subscription) => Ref.get(subscriptions).pipe(Effect.map((current) => Option.getOrUndefined(HashMap.get(current, subscriptionId)) === subscription));
|
|
926
|
+
const sendIfActive = Effect.fn("SyncServer.sendIfActive")(function* (subscriptionId, subscription, message) {
|
|
927
|
+
if (yield* isActive(subscriptionId, subscription)) yield* send(message);
|
|
928
|
+
});
|
|
929
|
+
const executeQuery = Effect.fn("SyncServer.executeQuery")(function* (subscriptionId, subscription, previouslyPrepared) {
|
|
930
|
+
if (!(yield* isActive(subscriptionId, subscription))) return;
|
|
931
|
+
const operation = operationForQuery(subscriptionId);
|
|
932
|
+
const deliver = (message) => sendIfActive(subscriptionId, subscription, message);
|
|
933
|
+
const prepared = previouslyPrepared ?? (yield* runtime.prepare(subscription.function, "Query", subscription.args).pipe(Effect.catchTags({
|
|
934
|
+
FunctionUnavailable: (error) => sendResolutionError(operation, error, deliver),
|
|
935
|
+
InvalidArguments: (error) => sendResolutionError(operation, error, deliver),
|
|
936
|
+
UnknownFunction: (error) => sendResolutionError(operation, error, deliver),
|
|
937
|
+
WrongFunctionKind: (error) => sendResolutionError(operation, error, deliver)
|
|
938
|
+
})));
|
|
939
|
+
if (prepared === void 0) return;
|
|
940
|
+
yield* deliver({
|
|
941
|
+
type: "QuerySnapshot",
|
|
942
|
+
id: subscriptionId,
|
|
943
|
+
result: yield* prepared.execute
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
const scheduleQuery = (subscriptionId, subscription, previouslyPrepared) => querySemaphore.withPermits(subscriptionId, 1)(executeQuery(subscriptionId, subscription, previouslyPrepared)).pipe(Effect.catchCause((cause) => Effect.logError("A query refresh fiber failed.").pipe(Effect.annotateLogs({
|
|
947
|
+
cause,
|
|
948
|
+
function: subscription.function,
|
|
949
|
+
subscriptionId
|
|
950
|
+
}))), FiberSet.run(invocationFibers), Effect.asVoid);
|
|
951
|
+
const refresh = Effect.fn("SyncServer.refresh")(function* () {
|
|
952
|
+
const current = yield* Ref.get(subscriptions);
|
|
953
|
+
yield* Effect.forEach(HashMap.toEntries(current), ([subscriptionId, subscription]) => scheduleQuery(subscriptionId, subscription));
|
|
954
|
+
});
|
|
955
|
+
const invalidations = yield* invalidation.subscribe;
|
|
956
|
+
yield* PubSub.take(invalidations).pipe(Effect.flatMap(refresh), Effect.forever, Effect.forkScoped);
|
|
957
|
+
const handleSubscribe = Effect.fn("SyncServer.handleSubscribe")(function* (message) {
|
|
958
|
+
const operation = operationForQuery(message.id);
|
|
959
|
+
const current = yield* Ref.get(subscriptions);
|
|
960
|
+
if (HashMap.has(current, message.id)) {
|
|
961
|
+
yield* send(protocolError("DuplicateOperationId", `Subscription ${message.id} already exists.`, operation));
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const prepared = yield* runtime.prepare(message.function, "Query", message.args).pipe(Effect.catchTags({
|
|
965
|
+
FunctionUnavailable: (error) => sendResolutionError(operation, error),
|
|
966
|
+
InvalidArguments: (error) => sendResolutionError(operation, error),
|
|
967
|
+
UnknownFunction: (error) => sendResolutionError(operation, error),
|
|
968
|
+
WrongFunctionKind: (error) => sendResolutionError(operation, error)
|
|
969
|
+
}));
|
|
970
|
+
if (prepared === void 0) return;
|
|
971
|
+
const subscription = {
|
|
972
|
+
args: message.args,
|
|
973
|
+
function: message.function
|
|
974
|
+
};
|
|
975
|
+
yield* Ref.update(subscriptions, HashMap.set(message.id, subscription));
|
|
976
|
+
yield* scheduleQuery(message.id, subscription, prepared);
|
|
977
|
+
});
|
|
978
|
+
const handleMutate = Effect.fn("SyncServer.handleMutate")(function* (message) {
|
|
979
|
+
const operation = {
|
|
980
|
+
type: "Mutate",
|
|
981
|
+
id: message.id
|
|
982
|
+
};
|
|
983
|
+
const prepared = yield* runtime.prepare(message.function, "Mutation", message.args).pipe(Effect.catchTags({
|
|
984
|
+
FunctionUnavailable: (error) => sendResolutionError(operation, error),
|
|
985
|
+
InvalidArguments: (error) => sendResolutionError(operation, error),
|
|
986
|
+
UnknownFunction: (error) => sendResolutionError(operation, error),
|
|
987
|
+
WrongFunctionKind: (error) => sendResolutionError(operation, error)
|
|
988
|
+
}));
|
|
989
|
+
if (prepared === void 0) return;
|
|
990
|
+
yield* prepared.execute.pipe(Effect.flatMap((result) => {
|
|
991
|
+
const response = send({
|
|
992
|
+
type: "MutationResult",
|
|
993
|
+
id: message.id,
|
|
994
|
+
result
|
|
995
|
+
});
|
|
996
|
+
return result.type === "Success" ? response.pipe(Effect.ensuring(invalidation.publish)) : response;
|
|
997
|
+
}), Effect.catchCause((cause) => Effect.logError("A mutation invocation fiber failed.").pipe(Effect.annotateLogs({
|
|
998
|
+
cause,
|
|
999
|
+
function: message.function,
|
|
1000
|
+
requestId: message.id
|
|
1001
|
+
}))), FiberSet.run(invocationFibers), Effect.asVoid);
|
|
1002
|
+
});
|
|
1003
|
+
const handleMessage = Effect.fn("SyncServer.handleMessage")(function* (text) {
|
|
1004
|
+
const message = yield* Schema.decodeEffect(ClientMessageJson)(text).pipe(Effect.catch(() => send(protocolError("InvalidMessage", "The WebSocket frame is not valid Ignotum JSON."))));
|
|
1005
|
+
if (message === void 0) return;
|
|
1006
|
+
switch (message.type) {
|
|
1007
|
+
case "Subscribe":
|
|
1008
|
+
yield* handleSubscribe(message);
|
|
1009
|
+
return;
|
|
1010
|
+
case "Unsubscribe":
|
|
1011
|
+
yield* Ref.update(subscriptions, HashMap.remove(message.id));
|
|
1012
|
+
return;
|
|
1013
|
+
case "Mutate":
|
|
1014
|
+
yield* handleMutate(message);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
yield* socket.runString((text) => messageSemaphore.withPermits(1)(handleMessage(text)));
|
|
1019
|
+
});
|
|
1020
|
+
var SyncHandlers = class extends Context.Service()("ignotum/dev-runtime/sync/SyncHandlers") {};
|
|
1021
|
+
const makeHandlersLayer = (server, projectDirectory, databasePath) => {
|
|
1022
|
+
const databaseLayer = LocalDatabase.layer.pipe(Layer.provide(Layer.merge(idGeneratorLayer, SqliteClient.layer({ filename: databasePath }))));
|
|
1023
|
+
const executorLayer = FunctionExecutor.layer.pipe(Layer.provide(databaseLayer));
|
|
1024
|
+
const devFunctionRuntimeLayer = functionRuntimeLayer.pipe(Layer.provide(Layer.merge(FunctionRegistry.layer(server, projectDirectory).pipe(Layer.provide(NodeServices.layer)), executorLayer)));
|
|
1025
|
+
const dependencies = Layer.mergeAll(devFunctionRuntimeLayer, queryInvalidationLayer, NodeServices.layer);
|
|
1026
|
+
return Layer.effect(SyncHandlers, Effect.gen(function* () {
|
|
1027
|
+
const invalidation = yield* QueryInvalidation;
|
|
1028
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
1029
|
+
const path = yield* Path.Path;
|
|
1030
|
+
const scope = yield* Effect.scope;
|
|
1031
|
+
const reloadSemaphore = yield* Semaphore.make(1);
|
|
1032
|
+
const webSocketServer = yield* Effect.acquireRelease(Effect.sync(() => new NodeSocket.NodeWS.WebSocketServer({
|
|
1033
|
+
maxPayload: 1048576,
|
|
1034
|
+
noServer: true
|
|
1035
|
+
})), (instance) => Effect.callback((resume) => {
|
|
1036
|
+
instance.close(() => resume(Effect.void));
|
|
1037
|
+
}));
|
|
1038
|
+
const httpApp = Effect.gen(function* () {
|
|
1039
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
1040
|
+
const pathname = new URL(request.url, "http://ignotum.local").pathname;
|
|
1041
|
+
return yield* HttpServerResponse.json(pathname === syncPath ? {
|
|
1042
|
+
code: "UpgradeRequired",
|
|
1043
|
+
message: `Connect to ${syncPath} with WebSocket.`
|
|
1044
|
+
} : {
|
|
1045
|
+
code: "NotFound",
|
|
1046
|
+
message: "Unknown Ignotum API route."
|
|
1047
|
+
}, { status: pathname === syncPath ? 426 : 404 });
|
|
1048
|
+
});
|
|
1049
|
+
const socketApp = Effect.gen(function* () {
|
|
1050
|
+
const socket = yield* (yield* HttpServerRequest.HttpServerRequest).upgrade;
|
|
1051
|
+
yield* runSession(socket);
|
|
1052
|
+
return HttpServerResponse.empty();
|
|
1053
|
+
});
|
|
1054
|
+
const http = yield* NodeHttpServer.makeHandler(httpApp, { scope });
|
|
1055
|
+
const upgrade = yield* NodeHttpServer.makeUpgradeHandler(Effect.succeed(webSocketServer), socketApp, { scope });
|
|
1056
|
+
const serverDirectory = path.join(projectDirectory, "server");
|
|
1057
|
+
const reloadUnsafe = Effect.fn("SyncServer.reload")(function* (file, event) {
|
|
1058
|
+
const relative = path.relative(serverDirectory, file);
|
|
1059
|
+
if (relative.startsWith("..") || path.isAbsolute(relative) || !file.endsWith(".ts")) return;
|
|
1060
|
+
if (event !== "change") yield* generate(projectDirectory).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path));
|
|
1061
|
+
yield* invalidation.publish;
|
|
1062
|
+
});
|
|
1063
|
+
const reload = (file, event) => reloadSemaphore.withPermits(1)(reloadUnsafe(file, event)).pipe(Effect.catchCause((cause) => Effect.logError("Could not reload Ignotum server functions.").pipe(Effect.annotateLogs({
|
|
1064
|
+
cause,
|
|
1065
|
+
event,
|
|
1066
|
+
file
|
|
1067
|
+
}))));
|
|
1068
|
+
return SyncHandlers.of({
|
|
1069
|
+
http,
|
|
1070
|
+
reload,
|
|
1071
|
+
upgrade
|
|
1072
|
+
});
|
|
1073
|
+
})).pipe(Layer.provide(dependencies));
|
|
1074
|
+
};
|
|
1075
|
+
const isApiPath = (request) => {
|
|
1076
|
+
const pathname = new URL(request.url ?? "/", "http://ignotum.local").pathname;
|
|
1077
|
+
return isIgnotumPath(pathname);
|
|
1078
|
+
};
|
|
1079
|
+
const isAppOrigin = (host, origin) => {
|
|
1080
|
+
if (host === void 0 || origin === void 0) return false;
|
|
1081
|
+
return Option.match(Schema.decodeOption(Schema.URLFromString)(origin), {
|
|
1082
|
+
onNone: () => false,
|
|
1083
|
+
onSome: (url) => url.origin === origin && url.host === host
|
|
1084
|
+
});
|
|
1085
|
+
};
|
|
1086
|
+
const rejectUpgrade = (socket) => {
|
|
1087
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", () => socket.destroy());
|
|
1088
|
+
};
|
|
1089
|
+
const ignotumSyncPlugin = Function.dual(2, (projectDirectory, databasePath) => ({
|
|
1090
|
+
name: "ignotum:sync",
|
|
1091
|
+
configureServer: (server) => {
|
|
1092
|
+
if (server.httpServer === null) throw new Error("Ignotum sync requires Vite's Node HTTP server.");
|
|
1093
|
+
const runtime = ManagedRuntime.make(makeHandlersLayer(server, projectDirectory, databasePath));
|
|
1094
|
+
const httpServer = server.httpServer;
|
|
1095
|
+
return runtime.runPromise(SyncHandlers).then((handlers) => {
|
|
1096
|
+
const onUpgrade = (request, socket, head) => {
|
|
1097
|
+
if (new URL(request.url ?? "/", "http://ignotum.local").pathname !== syncPath) return;
|
|
1098
|
+
if (!isAppOrigin(request.headers.host, request.headers.origin)) {
|
|
1099
|
+
rejectUpgrade(socket);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
handlers.upgrade(request, socket, head);
|
|
1103
|
+
};
|
|
1104
|
+
const onAdd = (file) => runtime.runFork(handlers.reload(file, "add"));
|
|
1105
|
+
const onChange = (file) => runtime.runFork(handlers.reload(file, "change"));
|
|
1106
|
+
const onUnlink = (file) => runtime.runFork(handlers.reload(file, "unlink"));
|
|
1107
|
+
const cleanup = () => {
|
|
1108
|
+
httpServer.off("upgrade", onUpgrade);
|
|
1109
|
+
server.watcher.off("add", onAdd);
|
|
1110
|
+
server.watcher.off("change", onChange);
|
|
1111
|
+
server.watcher.off("unlink", onUnlink);
|
|
1112
|
+
runtime.dispose();
|
|
1113
|
+
};
|
|
1114
|
+
httpServer.on("upgrade", onUpgrade);
|
|
1115
|
+
httpServer.once("close", cleanup);
|
|
1116
|
+
server.watcher.on("add", onAdd);
|
|
1117
|
+
server.watcher.on("change", onChange);
|
|
1118
|
+
server.watcher.on("unlink", onUnlink);
|
|
1119
|
+
server.middlewares.use((request, response, next) => {
|
|
1120
|
+
if (!isApiPath(request)) {
|
|
1121
|
+
next();
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
handlers.http(request, response);
|
|
1125
|
+
});
|
|
1126
|
+
}, (error) => {
|
|
1127
|
+
runtime.dispose();
|
|
1128
|
+
throw error;
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}));
|
|
1132
|
+
//#endregion
|
|
1133
|
+
//#region src/cli/dev.ts
|
|
1134
|
+
const requiredClientFiles = ["App.tsx", "styles.css"];
|
|
1135
|
+
const preactSpecifiers = [
|
|
1136
|
+
"preact/jsx-dev-runtime",
|
|
1137
|
+
"preact/jsx-runtime",
|
|
1138
|
+
"preact/devtools",
|
|
1139
|
+
"preact/debug",
|
|
1140
|
+
"preact/hooks",
|
|
1141
|
+
"preact"
|
|
1142
|
+
];
|
|
1143
|
+
const prefreshSpecifiers = ["@prefresh/core", "@prefresh/utils"];
|
|
1144
|
+
var ClientFileNotFound = class extends Schema.TaggedError()("ClientFileNotFound", {
|
|
1145
|
+
message: Schema.String,
|
|
1146
|
+
path: Schema.String
|
|
1147
|
+
}) {};
|
|
1148
|
+
var ClientRuntimeResolutionFailed = class extends Schema.TaggedError()("ClientRuntimeResolutionFailed", {
|
|
1149
|
+
cause: Schema.Defect(),
|
|
1150
|
+
message: Schema.String
|
|
1151
|
+
}) {};
|
|
1152
|
+
var ViteStartupFailed = class extends Schema.TaggedError()("ViteStartupFailed", {
|
|
1153
|
+
cause: Schema.Defect(),
|
|
1154
|
+
message: Schema.String
|
|
1155
|
+
}) {};
|
|
1156
|
+
const validateClientFiles = Effect.fn("Dev.validateClientFiles")(function* (projectDirectory) {
|
|
1157
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
1158
|
+
const path = yield* Path.Path;
|
|
1159
|
+
const clientDirectory = path.join(projectDirectory, "client");
|
|
1160
|
+
yield* Effect.forEach(requiredClientFiles, Effect.fn("Dev.validateClientFile")(function* (fileName) {
|
|
1161
|
+
const filePath = path.join(clientDirectory, fileName);
|
|
1162
|
+
if (!(yield* fileSystem.exists(filePath))) return yield* ClientFileNotFound.make({
|
|
1163
|
+
message: `Required client file not found: ${filePath}`,
|
|
1164
|
+
path: filePath
|
|
1165
|
+
});
|
|
1166
|
+
}), { discard: true });
|
|
1167
|
+
});
|
|
1168
|
+
const resolveClientAliases = Effect.fn("Dev.resolveClientAliases")(function* () {
|
|
1169
|
+
return yield* Effect.try({
|
|
1170
|
+
try: () => {
|
|
1171
|
+
const prefreshRequire = createRequire(import.meta.resolve("@prefresh/vite"));
|
|
1172
|
+
return [
|
|
1173
|
+
{
|
|
1174
|
+
find: /^tailwindcss$/,
|
|
1175
|
+
replacement: normalizePath(fileURLToPath(import.meta.resolve("tailwindcss/index.css")))
|
|
1176
|
+
},
|
|
1177
|
+
...preactSpecifiers.map((specifier) => ({
|
|
1178
|
+
find: specifier,
|
|
1179
|
+
replacement: normalizePath(fileURLToPath(import.meta.resolve(specifier)))
|
|
1180
|
+
})),
|
|
1181
|
+
...prefreshSpecifiers.map((specifier) => ({
|
|
1182
|
+
find: specifier,
|
|
1183
|
+
replacement: normalizePath(prefreshRequire.resolve(specifier))
|
|
1184
|
+
}))
|
|
1185
|
+
];
|
|
1186
|
+
},
|
|
1187
|
+
catch: (cause) => ClientRuntimeResolutionFailed.make({
|
|
1188
|
+
cause,
|
|
1189
|
+
message: "Ignotum could not resolve its client runtime."
|
|
1190
|
+
})
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
const acquireViteServer = Effect.fn("Dev.acquireViteServer")(function* (options) {
|
|
1194
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
1195
|
+
const path = yield* Path.Path;
|
|
1196
|
+
const aliases = yield* resolveClientAliases();
|
|
1197
|
+
const clientDirectory = path.join(options.projectDirectory, "client");
|
|
1198
|
+
const databasePath = yield* devDatabasePath(options.projectDirectory);
|
|
1199
|
+
const stateDirectory = path.dirname(databasePath);
|
|
1200
|
+
const sourceClientEntry = fileURLToPath(new URL("../../src/client/index.ts", import.meta.url));
|
|
1201
|
+
const sourceContractsEntry = fileURLToPath(new URL("../../../contracts/src/schema/index.ts", import.meta.url));
|
|
1202
|
+
const hasSourceClient = yield* fileSystem.exists(sourceClientEntry);
|
|
1203
|
+
const hasSourceContracts = yield* fileSystem.exists(sourceContractsEntry);
|
|
1204
|
+
const hasWorkspaceSourceExports = hasSourceClient && hasSourceContracts;
|
|
1205
|
+
yield* fileSystem.makeDirectory(stateDirectory, { recursive: true });
|
|
1206
|
+
return yield* Effect.acquireRelease(Effect.tryPromise({
|
|
1207
|
+
try: () => prefresh().then((prefreshPlugin) => createServer({
|
|
1208
|
+
appType: "spa",
|
|
1209
|
+
configFile: false,
|
|
1210
|
+
mode: "development",
|
|
1211
|
+
root: clientDirectory,
|
|
1212
|
+
plugins: [
|
|
1213
|
+
ignotumSyncPlugin(options.projectDirectory, databasePath),
|
|
1214
|
+
ignotumClientPlugin(),
|
|
1215
|
+
prefreshPlugin,
|
|
1216
|
+
tailwindcss()
|
|
1217
|
+
],
|
|
1218
|
+
oxc: { jsx: {
|
|
1219
|
+
importSource: "ignotum/client",
|
|
1220
|
+
runtime: "automatic"
|
|
1221
|
+
} },
|
|
1222
|
+
resolve: {
|
|
1223
|
+
alias: aliases,
|
|
1224
|
+
conditions: [...hasWorkspaceSourceExports ? ["@ignotum/source"] : [], ...defaultClientConditions],
|
|
1225
|
+
dedupe: ["preact"],
|
|
1226
|
+
tsconfigPaths: true
|
|
1227
|
+
},
|
|
1228
|
+
server: {
|
|
1229
|
+
forwardConsole: {
|
|
1230
|
+
logLevels: ["warn", "error"],
|
|
1231
|
+
unhandledErrors: true
|
|
1232
|
+
},
|
|
1233
|
+
host: options.host,
|
|
1234
|
+
open: options.open,
|
|
1235
|
+
port: options.port,
|
|
1236
|
+
strictPort: true
|
|
1237
|
+
}
|
|
1238
|
+
})),
|
|
1239
|
+
catch: (cause) => ViteStartupFailed.make({
|
|
1240
|
+
cause,
|
|
1241
|
+
message: "Ignotum could not create the Vite development server."
|
|
1242
|
+
})
|
|
1243
|
+
}), (server) => Effect.promise(() => server.close()));
|
|
1244
|
+
});
|
|
1245
|
+
const listen = Effect.fn("Dev.listen")(function* (server) {
|
|
1246
|
+
yield* Effect.tryPromise({
|
|
1247
|
+
try: () => server.listen(),
|
|
1248
|
+
catch: (cause) => ViteStartupFailed.make({
|
|
1249
|
+
cause,
|
|
1250
|
+
message: "Ignotum could not start the Vite development server."
|
|
1251
|
+
})
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
const dev$1 = Effect.fn("Dev.run")(function* (options) {
|
|
1255
|
+
yield* validateClientFiles(options.projectDirectory);
|
|
1256
|
+
yield* generate(options.projectDirectory);
|
|
1257
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
1258
|
+
yield* acquireDevDatabaseLock(options.projectDirectory);
|
|
1259
|
+
const server = yield* acquireViteServer(options);
|
|
1260
|
+
yield* listen(server);
|
|
1261
|
+
yield* Effect.sync(() => {
|
|
1262
|
+
server.printUrls();
|
|
1263
|
+
server.bindCLIShortcuts({ print: true });
|
|
1264
|
+
});
|
|
1265
|
+
return yield* Effect.never;
|
|
1266
|
+
}));
|
|
1267
|
+
});
|
|
1268
|
+
const agentProjectFiles = [
|
|
1269
|
+
{
|
|
1270
|
+
content: "# AGENTS.md\n\nThis is an Ignotum app. Before changing it, read `.agents/skills/ignotum/SKILL.md`.\n\nThe skill explains how to inspect the project, which bundled reference to read, and how to check\nyour changes.\n",
|
|
1271
|
+
path: "AGENTS.md"
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
content: "---\nname: ignotum\ndescription: Build and modify an Ignotum app. Use when working on its schema, server functions, generated API, client UI, or local development workflow.\n---\n\n# Working on an Ignotum app\n\nAn Ignotum app defines a database schema, server functions, and a JSX client. Ignotum supplies the\ndatabase runtime, generated bindings, realtime query updates, Vite dev server, JSX runtime, and\nTailwind setup.\n\nThe current release supports local development. Do not assume that authentication, files, actions,\nworkflows, scheduled jobs, or production deployment APIs exist.\n\n## Start with the app\n\nInspect `server/schema.ts`, the function modules in `server`, the files in `client`, `package.json`,\nand `tsconfig.json` before changing code.\n\nTreat `_generated` as compiler output. Read it when you need to understand an inferred type, but do\nnot edit it. After code changes, regenerate the bindings and run the typechecker:\n\n```sh\nnpx ignotum codegen\nnpx tsc --noEmit\n```\n\nFor normal Ignotum app work, do not add an HTML file, Vite configuration, Tailwind configuration,\nAPI routes, or direct database setup.\n\n## Read the relevant reference\n\nThe bundled references are the Ignotum user guides. Read only the guides needed for the task:\n\n- For project creation and installation, read [getting started](references/getting-started.md) or\n [manual setup](references/manual-setup.md).\n- For tables, fields, IDs, and generated document types, read\n [schema syntax](references/schema.md).\n- For validators and their TypeScript types, read [values](references/values.md).\n- For queries, mutations, database access, Results, and application errors, read\n [server functions](references/server-functions.md).\n- For hooks, query state, mutations, JSX, and Tailwind, read [client](references/client.md).\n- For development commands, code generation, flags, and local data, read\n [dev server](references/dev-server.md).\n- For the complete guide list, read the [documentation index](references/index.md).\n\n## Rules that cross guide boundaries\n\n- Define tables in `server/schema.ts`.\n- Put queries and mutations in TypeScript files directly inside `server`.\n- Import server builders from `@/_generated/server.js` and client references from\n `@/_generated/api.js`. Keep the `.js` suffix.\n- Put code used by both the client and server in `shared` and import it through `@/shared`.\n- Never edit `_generated`.\n- Use JSX and hooks from `ignotum/client`, not React or Preact packages directly.\n- Keep the app inside Ignotum's current model unless the user explicitly asks to move beyond it.\n",
|
|
1275
|
+
path: ".agents/skills/ignotum/SKILL.md"
|
|
1276
|
+
},
|
|
1277
|
+
...[
|
|
1278
|
+
["client.md", "# Client\n\nIgnotum apps use JSX, hooks from `ignotum/client`, and Tailwind CSS. Import server function\nreferences from `@/_generated/api.js`:\n\n```tsx\nimport { Query, Result, useMutation, useQuery } from \"ignotum/client\";\n\nimport { api } from \"@/_generated/api.js\";\n```\n\n## Tailwind CSS\n\nEvery app has `client/styles.css` with the Tailwind import:\n\n```css\n@import \"tailwindcss\";\n```\n\nStyle JSX with Tailwind utility classes. Use the JSX `class` attribute:\n\n```tsx\nexport default function App() {\n return (\n <main class=\"mx-auto max-w-xl px-6 py-16\">\n <h1 class=\"text-2xl font-semibold text-zinc-950\">Todos</h1>\n </main>\n );\n}\n```\n\nThe dev server loads `styles.css` and configures Tailwind. The app does not need a Tailwind\nconfiguration file.\n\n## Run a query\n\n`useQuery` takes a generated query reference. Pass the typed arguments when the query declares\nthem:\n\n```tsx\nconst todos = useQuery(api.todos.list);\nconst todo = useQuery(api.todos.get, { id });\nconst selectedTodo = useQuery(api.todos.get, id === undefined ? Query.skip : { id });\n```\n\n`Query.skip` keeps a query pending without opening a subscription. Use it when the arguments are\nnot available yet.\n\nThe first value is pending. Match every state with `Result.match`:\n\n```tsx\nreturn Result.match(todos, {\n pending: () => <p class=\"text-zinc-500\">Loading...</p>,\n value: (items) => (\n <ul>\n {items.map((todo) => (\n <li key={todo.id}>{todo.text}</li>\n ))}\n </ul>\n ),\n});\n```\n\nIgnotum keeps an active query up to date. A successful mutation refreshes subscribed queries in\nevery open client.\n\n## Run a mutation\n\n`useMutation` takes a generated mutation reference and returns a function:\n\n```tsx\nconst createTodo = useMutation(api.todos.create);\n\nvoid createTodo({ text }).then(\n Result.match({\n value: (id) => console.log(id),\n error: {\n InvalidTodoText: ({ text }) => console.log(`Invalid text: ${text}`),\n TodoLimitReached: ({ limit }) => console.log(`The limit is ${limit}`),\n },\n internalError: ({ requestId }) => console.log(`Request ${requestId} failed.`),\n }),\n);\n```\n\nAn argument-free mutation returns a zero-argument function:\n\n```tsx\nconst clearTodos = useMutation(api.todos.clear);\nvoid clearTodos();\n```\n\nThe client `Result` API only inspects completed server responses. Server-only operations such as\n`Result.fail`, `Result.succeed`, `Result.try`, `yield*`, and `.catch()` are not available here.\n\nApplication errors are handled by `error`, either with one function or an exhaustive map keyed by\n`_tag`. An `InternalServerError` contains a request ID and is thrown when `Result.match` has no\n`internalError` handler. During rendering, it reaches the nearest UI error boundary. A mutation can\nhandle it locally with `internalError`, as above. Connection and protocol failures reject mutation\npromises; query connection failures go to the nearest UI error boundary.\n"],
|
|
1279
|
+
["dev-server.md", "# Dev server\n\nRun the dev server from the project root:\n\n```sh\nnpx ignotum dev\n```\n\nIt expects these files:\n\n```text\nclient/App.tsx\nclient/styles.css\nserver/schema.ts\n```\n\nThe dev server generates the client and server bindings, mounts the default export from\n`client/App.tsx`, loads Tailwind from `client/styles.css`, and serves the app at\n<http://127.0.0.1:3210>. It reloads client and server changes and updates active queries after\nserver changes and successful mutations.\n\nYou do not need an HTML file, Vite configuration, or Tailwind configuration.\n\n## Flags\n\nUse flags to change the address or open the browser:\n\n```sh\nnpx ignotum dev --host 0.0.0.0 --port 3000 --open\n```\n\nThe defaults are host `127.0.0.1`, port `3210`, and no automatic browser opening.\n\n## Code generation\n\nThe dev server runs code generation when it starts. It updates generated files when you add or\nremove a server function file.\n\nRun code generation before typechecking without the dev server:\n\n```sh\nnpx ignotum codegen\nnpx tsc --noEmit\n```\n\nIgnotum creates:\n\n- `_generated/server.ts` with schema-bound `query`, `mutation`, and `values` exports;\n- `_generated/api.ts` with client references such as `api.todos.list`;\n- `_generated/types.ts` with `DataModel`, `Doc`, and `Id`.\n\nDo not edit generated files.\n\n## Local data\n\nData persists between dev-server restarts. Stop the server and reset that data with:\n\n```sh\nnpx ignotum dev db reset\n```\n"],
|
|
1280
|
+
["getting-started.md", "# Getting started\n\nIgnotum requires Node.js 22.18 or newer.\n\nCreate an app:\n\n```sh\nnpx ignotum new my-app\ncd my-app\nnpx ignotum dev\n```\n\nOpen <http://127.0.0.1:3210>. The generated app is a small counter with a schema, a query, a\nmutation, and a JSX client.\n\n`ignotum new` installs dependencies with pnpm when it is available. It falls back to npm only when\npnpm is not installed. It generates `_generated`, initializes a Git repository, and creates an\n`Init` commit containing the generated files after the rest of the setup finishes.\n\nPass `.` to create the app in the current directory. The directory must be empty:\n\n```sh\nnpx ignotum new .\n```\n\nUse `--no-git` to skip Git or `--no-install` to skip dependency installation. You can install the\ndependencies later with the same pnpm and npm fallback behavior:\n\n```sh\nnpx ignotum install\n```\n\nRead [schema syntax](schema.md), [server functions](server-functions.md), and the\n[client guide](client.md) to build the app. The [manual setup](manual-setup.md) recreates the counter\napp without `ignotum new`.\n"],
|
|
1281
|
+
["index.md", "# Ignotum\n\nAn Ignotum app has a schema, server functions, and a client. Ignotum is opinionated about the\nclient tooling. Every app uses JSX, hooks from `ignotum/client`, and Tailwind CSS.\n\nThe current release supports local development.\n\n- [Getting started](getting-started.md) creates and runs a counter app with `ignotum new`.\n- [Manual setup](manual-setup.md) recreates the generated counter app by hand.\n- [Schema syntax](schema.md) covers tables, fields, IDs, and generated document types.\n- [Values](values.md) lists every value validator and its TypeScript type.\n- [Server functions](server-functions.md) covers queries, mutations, database access, and errors.\n- [Client](client.md) covers queries, mutations, results, and Tailwind styling.\n- [Dev server](dev-server.md) covers local development, code generation, flags, and data reset.\n"],
|
|
1282
|
+
["manual-setup.md", "# Manual setup\n\nIgnotum requires Node.js 22.18 or newer. This guide recreates the counter app from `ignotum new`\nwithout running the project generator. It uses pnpm to install dependencies.\n\nCreate the project directory:\n\n```sh\nmkdir my-ignotum-app\ncd my-ignotum-app\n```\n\nCreate this structure:\n\n```text\nmy-ignotum-app/\n client/\n App.tsx\n styles.css\n server/\n counter.ts\n schema.ts\n shared/\n utils.ts\n package.json\n tsconfig.json\n```\n\n## Configure the package\n\nAdd `package.json`:\n\n```json\n{\n \"name\": \"my-ignotum-app\",\n \"private\": true,\n \"version\": \"0.0.0\",\n \"type\": \"module\",\n \"scripts\": {\n \"typecheck\": \"ignotum codegen && tsc --noEmit\"\n },\n \"dependencies\": {\n \"ignotum\": \"latest\"\n },\n \"devDependencies\": {\n \"typescript\": \"^7.0.2\"\n },\n \"engines\": {\n \"node\": \">=22.18.0\"\n }\n}\n```\n\nInstall the dependencies:\n\n```sh\npnpm install\n```\n\n## Configure TypeScript\n\nAdd `tsconfig.json`:\n\n```json\n{\n \"compilerOptions\": {\n \"target\": \"ES2023\",\n \"lib\": [\"ES2023\", \"DOM\", \"DOM.Iterable\"],\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"jsx\": \"react-jsx\",\n \"jsxImportSource\": \"ignotum/client\",\n \"strict\": true,\n \"noEmit\": true,\n \"skipLibCheck\": true,\n \"paths\": {\n \"@/*\": [\"./*\"]\n }\n },\n \"include\": [\"_generated\", \"client\", \"server\", \"shared\"]\n}\n```\n\n## Add shared code\n\nAdd `shared/utils.ts`:\n\n```ts\nexport const counterIncrement = 1;\n```\n\nBoth the client and server can import files in `shared` through `@/shared`.\n\n## Define the schema\n\nAdd `server/schema.ts`:\n\n```ts\nimport { defineSchema } from \"ignotum/server\";\n\nexport default defineSchema(({ table, values }) => ({\n counters: table({\n value: values.number(),\n }),\n}));\n```\n\n## Add the server functions\n\nAdd `server/counter.ts`:\n\n```ts\nimport { mutation, query, values } from \"@/_generated/server.js\";\nimport { counterIncrement } from \"@/shared/utils.js\";\n\nexport const get = query({\n returns: values.number(),\n\n handler: function* (ctx) {\n const counters = yield* ctx.db.query(\"counters\").collect();\n return counters[0]?.value ?? 0;\n },\n});\n\nexport const increment = mutation({\n returns: values.number(),\n\n handler: function* (ctx) {\n const counters = yield* ctx.db.query(\"counters\").collect();\n const counter = counters[0];\n const value = (counter?.value ?? 0) + counterIncrement;\n\n if (counter === undefined) {\n yield* ctx.db.insert(\"counters\", { value });\n } else {\n yield* ctx.db.patch(\"counters\", counter.id, { value });\n }\n\n return value;\n },\n});\n```\n\nKeep the `.js` suffix on imports from `@/_generated`, even though the generated files use\nTypeScript.\n\n## Add the client\n\nAdd `client/styles.css`:\n\n```css\n@import \"tailwindcss\";\n```\n\nAdd `client/App.tsx`:\n\n```tsx\nimport { Result, useMutation, useQuery } from \"ignotum/client\";\n\nimport { api } from \"@/_generated/api.js\";\nimport { counterIncrement } from \"@/shared/utils.js\";\n\nexport default function App() {\n const count = useQuery(api.counter.get);\n const increment = useMutation(api.counter.increment);\n\n return (\n <main class=\"mx-auto max-w-sm px-6 py-20 text-center\">\n <h1 class=\"text-2xl font-semibold\">Counter</h1>\n {Result.match(count, {\n pending: () => <p class=\"mt-6\">Loading...</p>,\n value: (value) => (\n <>\n <p class=\"my-6 text-5xl tabular-nums\">{value}</p>\n <button\n class=\"rounded bg-zinc-900 px-4 py-2 text-white\"\n type=\"button\"\n onClick={() => void increment()}\n >\n Increment by {counterIncrement}\n </button>\n </>\n ),\n })}\n </main>\n );\n}\n```\n\nIgnotum loads Tailwind from `client/styles.css`. You do not need an HTML file, Vite configuration,\nor Tailwind configuration.\n\n## Run the app\n\nStart the dev server:\n\n```sh\nnpx ignotum dev\n```\n\nOpen <http://127.0.0.1:3210>. Ignotum creates `_generated` before starting the app.\n\nRead [schema syntax](schema.md), [server functions](server-functions.md), and the\n[client guide](client.md) to continue building the app.\n"],
|
|
1283
|
+
["schema.md", "# Schema syntax\n\nDefine the data model in `server/schema.ts`. The keys returned from `defineSchema` are table\nnames, and each `table` call defines that table's fields:\n\n```ts\nimport { defineSchema } from \"ignotum/server\";\n\nexport default defineSchema(({ table, values }) => ({\n users: table({\n name: values.string(),\n }),\n todos: table({\n text: values.string(),\n completed: values.boolean(),\n ownerId: values.optional(values.id(\"users\")),\n }),\n}));\n```\n\n## Field values\n\nRead [values](values.md) for the complete validator list and the TypeScript type produced by each\none. `values.id` only accepts a table declared in the same schema. Arrays and objects can be nested,\nand their contents can use any value validator.\n\n## System fields\n\nIgnotum adds three fields to every stored document:\n\n| Field | Type |\n| ----------- | ------------------------- |\n| `id` | The ID type for its table |\n| `createdAt` | `Date` |\n| `updatedAt` | `Date` |\n\nDo not declare these fields in a table. Do not pass them to `insert`, `patch`, or `replace`.\n\n## Generated types\n\n`_generated/types.ts` exports the data model, document, and ID types:\n\n```ts\nimport type { DataModel, Doc, Id } from \"@/_generated/types.js\";\n\ntype Todo = Doc<\"todos\">;\ntype TodoId = Id<\"todos\">;\n```\n\n`Doc<\"todos\">` includes the fields from the `todos` table and its three system fields. An\n`Id<\"todos\">` cannot be passed where an `Id<\"users\">` is required.\n"],
|
|
1284
|
+
["server-functions.md", "# Server functions\n\nPut queries and mutations in `.ts` files directly inside `server`. The file name becomes the API\nmodule, and each exported function keeps its export name:\n\n```text\nserver/todos.ts -> api.todos.list\nserver/users.ts -> api.users.get\n```\n\nIgnotum ignores `schema.ts`, `index.ts`, test files, and names beginning with `_`.\n\nImport schema-bound builders from the generated server file. Import `Result` only when the module\nintroduces or catches typed errors:\n\n```ts\nimport { Result } from \"ignotum/server\";\n\nimport { mutation, query, values } from \"@/_generated/server.js\";\n```\n\nKeep the `.js` suffix on generated imports.\n\n## Define a function\n\nA function has optional argument, return, and public error schemas, plus a generator handler:\n\n```ts\nexport const getTitle = query({\n args: {\n id: values.id(\"todos\"),\n },\n returns: values.string(),\n\n handler: function* (ctx, args) {\n const todo = yield* ctx.db.get(\"todos\", args.id);\n return todo.text;\n },\n});\n```\n\nOmit `args` when the function takes no arguments. Omit `returns` when it returns nothing. An\nomitted `returns` only permits a `void` handler; Ignotum never infers an unchecked return schema.\n`yield*` waits for an Ignotum operation and propagates its typed application errors. Return\nsuccessful values with ordinary `return`.\n\nIgnotum validates arguments before running the handler. It also validates returned values and\npublic application errors before sending them to a client.\n\nAn argument-free query and a mutation with no return value can stay small:\n\n```ts\nexport const list = query({\n returns: values.array(Todo),\n\n handler: function* (ctx) {\n return yield* ctx.db.query(\"todos\").collect();\n },\n});\n\nexport const remove = mutation({\n args: { id: values.id(\"todos\") },\n\n handler: function* (ctx, args) {\n yield* ctx.db.delete(\"todos\", args.id);\n },\n});\n```\n\nCall an argument-free query as `useQuery(api.todos.list)`. An argument-free mutation returns a\nzero-argument function:\n\n```ts\nconst clear = useMutation(api.todos.clear);\nvoid clear();\n```\n\n## Database reads\n\nUse `find` when a missing document is a normal result:\n\n```ts\nconst todo = yield * ctx.db.find(\"todos\", args.id);\n// Todo | undefined\n```\n\nUse `get` when the document should exist:\n\n```ts\nconst todo = yield * ctx.db.get(\"todos\", args.id);\n// Todo\n```\n\nA missing `get` fails with a typed `DocumentNotFound` value containing `table` and `id`. Collect a\nwhole table through a query:\n\n```ts\nconst todos = yield * ctx.db.query(\"todos\").collect();\n```\n\nQuery handlers only receive read methods.\n\n## Database writes\n\nMutation handlers receive the read methods and these writes:\n\n```ts\nconst id =\n yield *\n ctx.db.insert(\"todos\", {\n text: \"Learn Ignotum\",\n completed: false,\n });\n\nyield * ctx.db.patch(\"todos\", id, { completed: true });\n\nyield *\n ctx.db.replace(\"todos\", id, {\n text: \"Build an app\",\n completed: false,\n });\n\nyield * ctx.db.delete(\"todos\", id);\n```\n\n`patch` changes only supplied fields. `replace` requires every non-optional table field. Ignotum\nrolls back a mutation when its handler fails with a typed application error or encounters an\ninternal failure.\n\n## Application errors\n\nDefine an application error with `values.error`. Its name becomes `_tag`:\n\n```ts\nconst TodoNotFound = values.error(\"TodoNotFound\", {\n id: values.id(\"todos\"),\n});\n```\n\n`Result.fail` deliberately stops the operation with a typed error:\n\n```ts\nyield * Result.fail(TodoNotFound({ id: args.id }));\n```\n\nThe `errors` field is optional. If omitted, Ignotum infers the handler's remaining application\nerrors. If supplied, it is the public contract and the handler must conform to it:\n\n```ts\nexport const toggle = mutation({\n args: {\n id: values.id(\"todos\"),\n },\n returns: values.boolean(),\n errors: TodoNotFound,\n\n handler: function* (ctx, args) {\n const todo = yield* ctx.db.get(\"todos\", args.id).catch({\n DocumentNotFound: (error) => Result.fail(TodoNotFound({ id: error.id })),\n });\n\n const completed = !todo.completed;\n yield* ctx.db.patch(\"todos\", args.id, { completed });\n return completed;\n },\n});\n```\n\nCombine public errors with `values.union`:\n\n```ts\nerrors: values.union(InvalidTodoText, TodoLimitReached),\n```\n\n## Catch and recover\n\nEvery Result operation has a partial, tag-based `catch`. Handlers receive the narrowed error type.\nUnmatched errors continue through the channel:\n\n```ts\nconst settings =\n yield *\n loadSettings().catch({\n SettingsNotFound: () => defaultSettings,\n });\n```\n\nReturn a plain value to recover. Return `Result.fail(...)` to map one error to another. Unknown tag\nnames fail the TypeScript check.\n\nUse `Result.try` for one catch boundary around several operations:\n\n```ts\nconst author =\n yield *\n Result.try(function* () {\n const membership = yield* ctx.db.get(\"memberships\", topic.membershipId);\n return yield* ctx.db.get(\"users\", membership.userId);\n }).catch({\n DocumentNotFound: () => Result.fail(InvalidAuthor({ topicId: topic.id })),\n });\n```\n\nThere is no async variant. Ignotum operations always use `yield*` in server code.\n\n## Standalone results\n\n`Result.succeed` remains useful for helpers that return a Result:\n\n```ts\nfunction validateName(name: string) {\n if (name.length === 0) {\n return Result.fail(InvalidName({}));\n }\n\n return Result.succeed(name.trim());\n}\n```\n\nA handler can use the helper with `const name = yield* validateName(args.name)`. Normal handlers do\nnot wrap successful returns in `Result.succeed`.\n\n## Internal failures and defects\n\nDatabase outages, internal runtime failures, and thrown JavaScript exceptions are not application\nerrors. Ignotum logs their full cause and sends only:\n\n```ts\n{\n _tag: \"InternalServerError\",\n requestId: \"...\",\n}\n```\n\nEvery generated client function includes `InternalServerError` in its Result error union. The\nrequest ID links the client-visible failure to server logs without exposing private details.\n`InternalServerError` is reserved by Ignotum: never define it with `values.error` or include it in a\nfunction's `errors` schema.\n"],
|
|
1285
|
+
["values.md", "# Values\n\nUse `values` validators to describe table fields, function arguments, return values, and application\nerrors. Each validator checks values at runtime and supplies the matching TypeScript type.\n\n| Validator | TypeScript type | Notes |\n| ----------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- |\n| `values.string()` | `string` | |\n| `values.number()` | `number` | Accepts finite JavaScript numbers, including integers. |\n| `values.integer()` | `number` | Accepts safe integers. |\n| `values.boolean()` | `boolean` | |\n| `values.date()` | `Date` | Accepts valid JavaScript dates. |\n| `values.null()` | `null` | Accepts only `null`. |\n| `values.literal(value)` | The exact type of `value` | Accepts one string, finite number, or boolean value. |\n| `values.literals(first, second, ...rest)` | A union of the supplied literal types | Requires at least two string, finite number, or boolean values. |\n| `values.id(\"todos\")` | `Id<\"todos\">` | Accepts an ID for a table declared in the same schema. IDs for different tables are different types. |\n| `values.optional(value)` | `T \\| undefined` | Makes an object or table field optional. The field may be omitted. |\n| `values.nullable(value)` | `T \\| null` | The value remains required unless it is also wrapped with `optional`. |\n| `values.array(value)` | `ReadonlyArray<T>` | Every item must match `value`. |\n| `values.object(fields)` | An object matching `fields` | Defines an object with known field names. |\n| `values.record(value)` | `Readonly<Record<string, T>>` | Defines an object with dynamic string keys whose values all match `value`. |\n| `values.union(...values)` | A union of the supplied types | Accepts a value matching any supplied validator. |\n| `values.never()` | `never` | No value can pass this validator. |\n| `values.error(\"Name\", fields)` | A tagged error object | Defines an application error whose `_tag` is the supplied name. |\n\n`T` means the TypeScript type produced by the wrapped validator.\n\n## Examples\n\n```ts\nconst TodoStatus = values.literals(\"pending\", \"completed\");\n\nconst Todo = values.object({\n status: TodoStatus,\n scheduledAt: values.nullable(values.date()),\n scores: values.record(values.integer()),\n title: values.string(),\n});\n```\n\nUse `optional` when a field may be absent. Use `nullable` when a present field may contain `null`:\n\n```ts\nvalues.object({\n nickname: values.optional(values.string()),\n deletedAt: values.nullable(values.date()),\n});\n```\n"]
|
|
1286
|
+
].map(([name, content]) => ({
|
|
1287
|
+
content,
|
|
1288
|
+
path: `.agents/skills/ignotum/references/${name}`
|
|
1289
|
+
}))
|
|
1290
|
+
];
|
|
1291
|
+
//#endregion
|
|
1292
|
+
//#region src/cli/package-manager.ts
|
|
1293
|
+
var DependencyInstallationFailed = class extends Schema.TaggedError()("DependencyInstallationFailed", {
|
|
1294
|
+
cause: Schema.optional(Schema.Defect()),
|
|
1295
|
+
message: Schema.String,
|
|
1296
|
+
path: Schema.String
|
|
1297
|
+
}) {};
|
|
1298
|
+
const installWith = Effect.fn("PackageManager.installWith")(function* (projectDirectory, packageManager) {
|
|
1299
|
+
const exitCode = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).exitCode(ChildProcess.make(packageManager, ["install"], {
|
|
1300
|
+
cwd: projectDirectory,
|
|
1301
|
+
stderr: "inherit",
|
|
1302
|
+
stdin: "inherit",
|
|
1303
|
+
stdout: "inherit"
|
|
1304
|
+
}));
|
|
1305
|
+
if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* DependencyInstallationFailed.make({
|
|
1306
|
+
message: `${packageManager} install exited with code ${exitCode} in ${projectDirectory}.`,
|
|
1307
|
+
path: projectDirectory
|
|
1308
|
+
});
|
|
1309
|
+
return packageManager;
|
|
1310
|
+
});
|
|
1311
|
+
const installDependencies = Effect.fn("PackageManager.installDependencies")(function* (projectDirectory) {
|
|
1312
|
+
return yield* installWith(projectDirectory, "pnpm").pipe(Effect.catchReasons("PlatformError", { NotFound: () => installWith(projectDirectory, "npm") }), Effect.catchTags({ PlatformError: (cause) => DependencyInstallationFailed.make({
|
|
1313
|
+
cause,
|
|
1314
|
+
message: `Could not run pnpm or npm in ${projectDirectory}.`,
|
|
1315
|
+
path: projectDirectory
|
|
1316
|
+
}) }));
|
|
1317
|
+
});
|
|
1318
|
+
//#endregion
|
|
1319
|
+
//#region src/cli/new-project.ts
|
|
1320
|
+
var ProjectDirectoryNotEmpty = class extends Schema.TaggedError()("ProjectDirectoryNotEmpty", {
|
|
1321
|
+
message: Schema.String,
|
|
1322
|
+
path: Schema.String
|
|
1323
|
+
}) {};
|
|
1324
|
+
var InvalidProjectName = class extends Schema.TaggedError()("InvalidProjectName", {
|
|
1325
|
+
message: Schema.String,
|
|
1326
|
+
path: Schema.String
|
|
1327
|
+
}) {};
|
|
1328
|
+
var GitInitializationFailed = class extends Schema.TaggedError()("GitInitializationFailed", {
|
|
1329
|
+
cause: Schema.optional(Schema.Defect()),
|
|
1330
|
+
message: Schema.String,
|
|
1331
|
+
path: Schema.String
|
|
1332
|
+
}) {};
|
|
1333
|
+
const packageJson = (projectName) => `${JSON.stringify({
|
|
1334
|
+
name: projectName,
|
|
1335
|
+
private: true,
|
|
1336
|
+
version: "0.0.0",
|
|
1337
|
+
type: "module",
|
|
1338
|
+
scripts: { typecheck: "ignotum codegen && tsc --noEmit" },
|
|
1339
|
+
dependencies: { ignotum: "latest" },
|
|
1340
|
+
devDependencies: { typescript: "^7.0.2" },
|
|
1341
|
+
engines: { node: ">=22.18.0" }
|
|
1342
|
+
}, null, 2)}\n`;
|
|
1343
|
+
const tsconfig = `{
|
|
1344
|
+
"compilerOptions": {
|
|
1345
|
+
"target": "ES2023",
|
|
1346
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
1347
|
+
"module": "NodeNext",
|
|
1348
|
+
"moduleResolution": "NodeNext",
|
|
1349
|
+
"jsx": "react-jsx",
|
|
1350
|
+
"jsxImportSource": "ignotum/client",
|
|
1351
|
+
"strict": true,
|
|
1352
|
+
"noEmit": true,
|
|
1353
|
+
"skipLibCheck": true,
|
|
1354
|
+
"paths": {
|
|
1355
|
+
"@/*": ["./*"]
|
|
1356
|
+
}
|
|
1357
|
+
},
|
|
1358
|
+
"include": ["_generated", "client", "server", "shared"]
|
|
1359
|
+
}
|
|
1360
|
+
`;
|
|
1361
|
+
const gitignore = `node_modules/
|
|
1362
|
+
.ignotum/
|
|
1363
|
+
|
|
1364
|
+
.env
|
|
1365
|
+
.env.*
|
|
1366
|
+
!.env.example
|
|
1367
|
+
|
|
1368
|
+
.DS_Store
|
|
1369
|
+
Thumbs.db
|
|
1370
|
+
`;
|
|
1371
|
+
const schema = `import { defineSchema } from "ignotum/server";
|
|
1372
|
+
|
|
1373
|
+
export default defineSchema(({ table, values }) => ({
|
|
1374
|
+
counters: table({
|
|
1375
|
+
value: values.number(),
|
|
1376
|
+
}),
|
|
1377
|
+
}));
|
|
1378
|
+
`;
|
|
1379
|
+
const counterFunctions = `import { mutation, query, values } from "@/_generated/server.js";
|
|
1380
|
+
import { counterIncrement } from "@/shared/utils.js";
|
|
1381
|
+
|
|
1382
|
+
export const get = query({
|
|
1383
|
+
returns: values.number(),
|
|
1384
|
+
|
|
1385
|
+
handler: function* (ctx) {
|
|
1386
|
+
const counters = yield* ctx.db.query("counters").collect();
|
|
1387
|
+
return counters[0]?.value ?? 0;
|
|
1388
|
+
},
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
export const increment = mutation({
|
|
1392
|
+
returns: values.number(),
|
|
1393
|
+
|
|
1394
|
+
handler: function* (ctx) {
|
|
1395
|
+
const counters = yield* ctx.db.query("counters").collect();
|
|
1396
|
+
const counter = counters[0];
|
|
1397
|
+
const value = (counter?.value ?? 0) + counterIncrement;
|
|
1398
|
+
|
|
1399
|
+
if (counter === undefined) {
|
|
1400
|
+
yield* ctx.db.insert("counters", { value });
|
|
1401
|
+
} else {
|
|
1402
|
+
yield* ctx.db.patch("counters", counter.id, { value });
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
return value;
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
`;
|
|
1409
|
+
const app = `import { Result, useMutation, useQuery } from "ignotum/client";
|
|
1410
|
+
|
|
1411
|
+
import { api } from "@/_generated/api.js";
|
|
1412
|
+
import { counterIncrement } from "@/shared/utils.js";
|
|
1413
|
+
|
|
1414
|
+
export default function App() {
|
|
1415
|
+
const count = useQuery(api.counter.get);
|
|
1416
|
+
const increment = useMutation(api.counter.increment);
|
|
1417
|
+
|
|
1418
|
+
return (
|
|
1419
|
+
<main class="mx-auto max-w-sm px-6 py-20 text-center">
|
|
1420
|
+
<h1 class="text-2xl font-semibold">Counter</h1>
|
|
1421
|
+
{Result.match(count, {
|
|
1422
|
+
pending: () => <p class="mt-6">Loading...</p>,
|
|
1423
|
+
value: (value) => (
|
|
1424
|
+
<>
|
|
1425
|
+
<p class="my-6 text-5xl tabular-nums">{value}</p>
|
|
1426
|
+
<button
|
|
1427
|
+
class="rounded bg-zinc-900 px-4 py-2 text-white"
|
|
1428
|
+
type="button"
|
|
1429
|
+
onClick={() => void increment()}
|
|
1430
|
+
>
|
|
1431
|
+
Increment by {counterIncrement}
|
|
1432
|
+
</button>
|
|
1433
|
+
</>
|
|
1434
|
+
),
|
|
1435
|
+
})}
|
|
1436
|
+
</main>
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
`;
|
|
1440
|
+
const utils = `export const counterIncrement = 1;
|
|
1441
|
+
`;
|
|
1442
|
+
const styles = `@import "tailwindcss";
|
|
1443
|
+
`;
|
|
1444
|
+
const readme = (projectName) => `# ${projectName}
|
|
1445
|
+
|
|
1446
|
+
A small Ignotum counter app.
|
|
1447
|
+
|
|
1448
|
+
## Getting started
|
|
1449
|
+
|
|
1450
|
+
You need Node.js 22.18 or newer. The project generator installs dependencies by default. If you
|
|
1451
|
+
created the project with \`--no-install\`, install them now:
|
|
1452
|
+
|
|
1453
|
+
\`\`\`sh
|
|
1454
|
+
npx ignotum install
|
|
1455
|
+
\`\`\`
|
|
1456
|
+
|
|
1457
|
+
This command uses pnpm when it is installed and otherwise uses npm.
|
|
1458
|
+
|
|
1459
|
+
Start the app:
|
|
1460
|
+
|
|
1461
|
+
\`\`\`sh
|
|
1462
|
+
npx ignotum dev
|
|
1463
|
+
\`\`\`
|
|
1464
|
+
|
|
1465
|
+
Open <http://127.0.0.1:3210>.
|
|
1466
|
+
|
|
1467
|
+
The project generator creates \`_generated\`. The dev server checks those files before it starts
|
|
1468
|
+
and updates them when the schema or server functions change.
|
|
1469
|
+
|
|
1470
|
+
## Project files
|
|
1471
|
+
|
|
1472
|
+
- \`server/schema.ts\` defines the database tables.
|
|
1473
|
+
- \`server/counter.ts\` defines the query and mutation used by the counter.
|
|
1474
|
+
- \`client/App.tsx\` is the UI.
|
|
1475
|
+
- \`client/styles.css\` loads Tailwind CSS.
|
|
1476
|
+
- \`shared/utils.ts\` contains code shared across the app.
|
|
1477
|
+
- \`_generated\` contains Ignotum's generated types and bindings. Do not edit it by hand.
|
|
1478
|
+
|
|
1479
|
+
Run the typechecker after a change:
|
|
1480
|
+
|
|
1481
|
+
\`\`\`sh
|
|
1482
|
+
npx ignotum codegen
|
|
1483
|
+
npx tsc --noEmit
|
|
1484
|
+
\`\`\`
|
|
1485
|
+
|
|
1486
|
+
## Claude Code
|
|
1487
|
+
|
|
1488
|
+
If you use Claude Code, rename \`AGENTS.md\` to \`CLAUDE.md\` and \`.agents\` to \`.claude\` so it
|
|
1489
|
+
can find the project instructions and Ignotum skill.
|
|
1490
|
+
`;
|
|
1491
|
+
const projectFiles = (projectName) => [
|
|
1492
|
+
{
|
|
1493
|
+
content: app,
|
|
1494
|
+
path: "client/App.tsx"
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
content: styles,
|
|
1498
|
+
path: "client/styles.css"
|
|
1499
|
+
},
|
|
1500
|
+
{
|
|
1501
|
+
content: counterFunctions,
|
|
1502
|
+
path: "server/counter.ts"
|
|
1503
|
+
},
|
|
1504
|
+
{
|
|
1505
|
+
content: schema,
|
|
1506
|
+
path: "server/schema.ts"
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
content: utils,
|
|
1510
|
+
path: "shared/utils.ts"
|
|
1511
|
+
},
|
|
1512
|
+
{
|
|
1513
|
+
content: packageJson(projectName),
|
|
1514
|
+
path: "package.json"
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
content: tsconfig,
|
|
1518
|
+
path: "tsconfig.json"
|
|
1519
|
+
},
|
|
1520
|
+
{
|
|
1521
|
+
content: gitignore,
|
|
1522
|
+
path: ".gitignore"
|
|
1523
|
+
},
|
|
1524
|
+
{
|
|
1525
|
+
content: readme(projectName),
|
|
1526
|
+
path: "README.md"
|
|
1527
|
+
},
|
|
1528
|
+
...agentProjectFiles
|
|
1529
|
+
];
|
|
1530
|
+
const writeProjectFile = Effect.fn("NewProject.writeProjectFile")(function* (projectDirectory, file) {
|
|
1531
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
1532
|
+
const path = yield* Path.Path;
|
|
1533
|
+
const filePath = path.join(projectDirectory, file.path);
|
|
1534
|
+
yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true });
|
|
1535
|
+
yield* fileSystem.writeFileString(filePath, file.content, { flag: "wx" });
|
|
1536
|
+
});
|
|
1537
|
+
const gitExitCode = Effect.fn("NewProject.gitExitCode")(function* (projectDirectory, args) {
|
|
1538
|
+
return yield* (yield* ChildProcessSpawner.ChildProcessSpawner).exitCode(ChildProcess.make("git", args, {
|
|
1539
|
+
cwd: projectDirectory,
|
|
1540
|
+
stderr: "ignore",
|
|
1541
|
+
stdout: "ignore"
|
|
1542
|
+
})).pipe(Effect.mapError((cause) => GitInitializationFailed.make({
|
|
1543
|
+
cause,
|
|
1544
|
+
message: `Could not run Git in ${projectDirectory}.`,
|
|
1545
|
+
path: projectDirectory
|
|
1546
|
+
})));
|
|
1547
|
+
});
|
|
1548
|
+
const runGit = Effect.fn("NewProject.runGit")(function* (projectDirectory, args, action) {
|
|
1549
|
+
const exitCode = yield* gitExitCode(projectDirectory, args);
|
|
1550
|
+
if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* GitInitializationFailed.make({
|
|
1551
|
+
message: `Git exited with code ${exitCode} while ${action} in ${projectDirectory}.`,
|
|
1552
|
+
path: projectDirectory
|
|
1553
|
+
});
|
|
1554
|
+
});
|
|
1555
|
+
const initializeGit = Effect.fn("NewProject.initializeGit")(function* (projectDirectory) {
|
|
1556
|
+
yield* runGit(projectDirectory, ["init", "--quiet"], "initializing the repository");
|
|
1557
|
+
yield* runGit(projectDirectory, ["add", "--all"], "staging the initial files");
|
|
1558
|
+
const [hasName, hasEmail] = yield* Effect.all([gitExitCode(projectDirectory, ["config", "user.name"]), gitExitCode(projectDirectory, ["config", "user.email"])], { concurrency: "unbounded" }).pipe(Effect.map((exitCodes) => exitCodes.map((exitCode) => exitCode === ChildProcessSpawner.ExitCode(0))));
|
|
1559
|
+
yield* runGit(projectDirectory, [
|
|
1560
|
+
...hasName === true && hasEmail === true ? [] : [
|
|
1561
|
+
"-c",
|
|
1562
|
+
"user.name=Ignotum",
|
|
1563
|
+
"-c",
|
|
1564
|
+
"user.email=ignotum@localhost"
|
|
1565
|
+
],
|
|
1566
|
+
"commit",
|
|
1567
|
+
"--quiet",
|
|
1568
|
+
"--no-gpg-sign",
|
|
1569
|
+
"-m",
|
|
1570
|
+
"Init"
|
|
1571
|
+
], "creating the initial commit");
|
|
1572
|
+
});
|
|
1573
|
+
const createProject = Effect.fn("NewProject.createProject")(function* (options) {
|
|
1574
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
1575
|
+
const path = yield* Path.Path;
|
|
1576
|
+
const projectDirectory = path.resolve(options.currentDirectory, options.directory);
|
|
1577
|
+
const projectName = String.kebabCase(path.basename(projectDirectory));
|
|
1578
|
+
if (projectName.length === 0) return yield* InvalidProjectName.make({
|
|
1579
|
+
message: `Could not derive a package name from ${projectDirectory}.`,
|
|
1580
|
+
path: projectDirectory
|
|
1581
|
+
});
|
|
1582
|
+
if (yield* fileSystem.exists(projectDirectory)) {
|
|
1583
|
+
if ((yield* fileSystem.stat(projectDirectory)).type !== "Directory") return yield* ProjectDirectoryNotEmpty.make({
|
|
1584
|
+
message: `${projectDirectory} already exists and is not an empty directory.`,
|
|
1585
|
+
path: projectDirectory
|
|
1586
|
+
});
|
|
1587
|
+
if ((yield* fileSystem.readDirectory(projectDirectory)).length > 0) return yield* ProjectDirectoryNotEmpty.make({
|
|
1588
|
+
message: `${projectDirectory} is not empty. Choose an empty directory.`,
|
|
1589
|
+
path: projectDirectory
|
|
1590
|
+
});
|
|
1591
|
+
} else yield* fileSystem.makeDirectory(projectDirectory, { recursive: true });
|
|
1592
|
+
yield* Effect.forEach(projectFiles(projectName), (file) => writeProjectFile(projectDirectory, file), { discard: true });
|
|
1593
|
+
yield* generate(projectDirectory);
|
|
1594
|
+
const packageManager = options.install ? yield* installDependencies(projectDirectory) : null;
|
|
1595
|
+
if (options.git) yield* initializeGit(projectDirectory);
|
|
1596
|
+
return {
|
|
1597
|
+
directory: projectDirectory,
|
|
1598
|
+
gitInitialized: options.git,
|
|
1599
|
+
packageManager,
|
|
1600
|
+
projectName
|
|
1601
|
+
};
|
|
1602
|
+
});
|
|
1603
|
+
//#endregion
|
|
1604
|
+
//#region src/cli/command.ts
|
|
1605
|
+
const DevPort = Schema.Int.check(Schema.isBetween({
|
|
1606
|
+
minimum: 1,
|
|
1607
|
+
maximum: 65535
|
|
1608
|
+
}));
|
|
1609
|
+
const codegen = Command.make("codegen", {}, Effect.fn("codegen")(function* () {
|
|
1610
|
+
const path = yield* Path.Path;
|
|
1611
|
+
const terminal = yield* Terminal.Terminal;
|
|
1612
|
+
const result = yield* generate(path.resolve(".")).pipe(Effect.mapError((error) => CliError.UserError.make({
|
|
1613
|
+
cause: error,
|
|
1614
|
+
userMessage: error.message
|
|
1615
|
+
})));
|
|
1616
|
+
yield* terminal.display(`Generated ${result.written.length} file${result.written.length === 1 ? "" : "s"}; ${result.unchanged.length} unchanged.\n`);
|
|
1617
|
+
}));
|
|
1618
|
+
const resetDevDatabaseCommand = Command.make("reset", {}, Effect.fn("dev db reset")(function* () {
|
|
1619
|
+
const path = yield* Path.Path;
|
|
1620
|
+
const terminal = yield* Terminal.Terminal;
|
|
1621
|
+
const result = yield* resetDevDatabase(path.resolve(".")).pipe(Effect.mapError((error) => CliError.UserError.make({
|
|
1622
|
+
cause: error,
|
|
1623
|
+
userMessage: error.message
|
|
1624
|
+
})));
|
|
1625
|
+
const message = result.removed === 0 ? `Development database is already empty: ${result.databasePath}\n` : `Reset development database: ${result.databasePath}\n`;
|
|
1626
|
+
yield* terminal.display(message);
|
|
1627
|
+
})).pipe(Command.withDescription("Delete the local SQLite development database."));
|
|
1628
|
+
const devDatabase = Command.make("db").pipe(Command.withDescription("Manage the local development database."), Command.withSubcommands([resetDevDatabaseCommand]));
|
|
1629
|
+
const dev = Command.make("dev", {
|
|
1630
|
+
host: Flag.string("host").pipe(Flag.withDefault("127.0.0.1")),
|
|
1631
|
+
open: Flag.boolean("open").pipe(Flag.withDefault(false)),
|
|
1632
|
+
port: Flag.integer("port").pipe(Flag.withSchema(DevPort), Flag.withDefault(3210))
|
|
1633
|
+
}, Effect.fn("dev")(function* ({ host, open, port }) {
|
|
1634
|
+
const path = yield* Path.Path;
|
|
1635
|
+
return yield* dev$1({
|
|
1636
|
+
host,
|
|
1637
|
+
open,
|
|
1638
|
+
port,
|
|
1639
|
+
projectDirectory: path.resolve(".")
|
|
1640
|
+
}).pipe(Effect.mapError((error) => CliError.UserError.make({
|
|
1641
|
+
cause: error,
|
|
1642
|
+
userMessage: error.message
|
|
1643
|
+
})));
|
|
1644
|
+
})).pipe(Command.withDescription("Run the local Ignotum development server."), Command.withSubcommands([devDatabase]));
|
|
1645
|
+
const install = Command.make("install", {}, Effect.fn("install")(function* () {
|
|
1646
|
+
const path = yield* Path.Path;
|
|
1647
|
+
const terminal = yield* Terminal.Terminal;
|
|
1648
|
+
const packageManager = yield* installDependencies(path.resolve(".")).pipe(Effect.mapError((error) => CliError.UserError.make({
|
|
1649
|
+
cause: error,
|
|
1650
|
+
userMessage: error.message
|
|
1651
|
+
})));
|
|
1652
|
+
yield* terminal.display(`Installed dependencies with ${packageManager}.\n`);
|
|
1653
|
+
})).pipe(Command.withDescription("Install dependencies with pnpm, or npm when pnpm is unavailable."));
|
|
1654
|
+
const newProject = Command.make("new", {
|
|
1655
|
+
directory: Argument.string("directory").pipe(Argument.withDescription("Directory to create. Use \".\" for the current directory.")),
|
|
1656
|
+
git: Flag.boolean("git").pipe(Flag.withDescription("Initialize a Git repository. Use --no-git to skip it."), Flag.withDefault(true)),
|
|
1657
|
+
install: Flag.boolean("install").pipe(Flag.withDescription("Install dependencies. Use --no-install to skip it."), Flag.withDefault(true))
|
|
1658
|
+
}, Effect.fn("new")(function* ({ directory, git, install }) {
|
|
1659
|
+
const path = yield* Path.Path;
|
|
1660
|
+
const terminal = yield* Terminal.Terminal;
|
|
1661
|
+
const currentDirectory = path.resolve(".");
|
|
1662
|
+
const result = yield* createProject({
|
|
1663
|
+
currentDirectory,
|
|
1664
|
+
directory,
|
|
1665
|
+
git,
|
|
1666
|
+
install
|
|
1667
|
+
}).pipe(Effect.mapError((error) => CliError.UserError.make({
|
|
1668
|
+
cause: error,
|
|
1669
|
+
userMessage: error.message
|
|
1670
|
+
})));
|
|
1671
|
+
const changeDirectory = result.directory === currentDirectory ? "" : ` cd ${directory}\n`;
|
|
1672
|
+
const installCommand = result.packageManager === null ? " npx ignotum install\n" : "";
|
|
1673
|
+
yield* terminal.display(`Created ${result.projectName} in ${result.directory}.\n\nNext steps:\n${changeDirectory}${installCommand} npx ignotum dev\n`);
|
|
1674
|
+
})).pipe(Command.withDescription("Create a new Ignotum app in an empty directory."), Command.withExamples([{
|
|
1675
|
+
command: "ignotum new my-app",
|
|
1676
|
+
description: "Create an app in a new directory."
|
|
1677
|
+
}, {
|
|
1678
|
+
command: "ignotum new . --no-git --no-install",
|
|
1679
|
+
description: "Create an app in the current directory without Git or dependency installation."
|
|
1680
|
+
}]));
|
|
1681
|
+
const ignotum = Command.make("ignotum").pipe(Command.withSubcommands([
|
|
1682
|
+
newProject,
|
|
1683
|
+
install,
|
|
1684
|
+
codegen,
|
|
1685
|
+
dev
|
|
1686
|
+
]));
|
|
1687
|
+
const main = () => {
|
|
1688
|
+
ignotum.pipe(Command.run({ version }), Effect.provide(NodeServices.layer), NodeRuntime.runMain);
|
|
1689
|
+
};
|
|
1690
|
+
//#endregion
|
|
1691
|
+
//#region src/cli/bin.ts
|
|
1692
|
+
main();
|
|
1693
|
+
//#endregion
|
|
1694
|
+
export {};
|
|
1695
|
+
|
|
1696
|
+
//# sourceMappingURL=bin.mjs.map
|