@gmickel/gno 1.23.0 → 1.24.0

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.
@@ -0,0 +1,490 @@
1
+ /**
2
+ * Core-only verified folder setup orchestration.
3
+ *
4
+ * @module src/core/folder-setup
5
+ */
6
+
7
+ import type { Collection } from "../config";
8
+ import type { SyncService } from "../ingestion";
9
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
10
+
11
+ import { DEFAULT_EXCLUDES, loadConfig } from "../config";
12
+ import { defaultSyncService, withContentTypeRules } from "../ingestion";
13
+ import { verifyLexicalActivation } from "./activation-verifier";
14
+ import { applyConfigChange } from "./config-mutation";
15
+ import {
16
+ type CollectionSelection,
17
+ type FolderSetupError,
18
+ type FolderSetupErrorCode,
19
+ normalizeSetupExcludes,
20
+ preflightFolder,
21
+ resolveSetupFolder,
22
+ resolveSetupStoreIndex,
23
+ selectFolderCollection,
24
+ setupError,
25
+ setupExcludesMatch,
26
+ setupFilterDisagreement,
27
+ setupInjectedFailure,
28
+ validateSetupOutputPaths,
29
+ } from "./folder-setup-planning";
30
+ import {
31
+ createSetupReceipt,
32
+ failSetupStage,
33
+ type FolderSetupReceipt,
34
+ getSetupReceiptPath,
35
+ passSetupStage,
36
+ persistSetupReceipt,
37
+ setupFingerprint,
38
+ type SetupFailure,
39
+ type SetupStageName,
40
+ startSetupStage,
41
+ } from "./setup-receipt";
42
+
43
+ export type {
44
+ FolderSetupError,
45
+ FolderSetupErrorCode,
46
+ } from "./folder-setup-planning";
47
+
48
+ export type FolderSetupFailurePoint =
49
+ | "after_config_save"
50
+ | "after_store_sync"
51
+ | "after_lexical_index"
52
+ | "after_lexical_proof";
53
+
54
+ export interface FolderSetupOptions {
55
+ folder: string;
56
+ store: SqliteAdapter;
57
+ configPath: string;
58
+ dataDir: string;
59
+ indexName?: string;
60
+ name?: string;
61
+ exclude?: string[];
62
+ secretRiskAuthorized?: boolean;
63
+ /** Test-only deterministic interruption hook. */
64
+ failureInjection?: FolderSetupFailurePoint;
65
+ /** Test-only concurrency seam before the serialized config boundary. */
66
+ beforeConfigBoundary?: () => Promise<void>;
67
+ /** Test-only receipt persistence seam. */
68
+ receiptWriter?: (receipt: FolderSetupReceipt) => Promise<void>;
69
+ syncService?: SyncService;
70
+ now?: () => Date;
71
+ }
72
+
73
+ export type FolderSetupResult =
74
+ | { ok: true; receipt: FolderSetupReceipt }
75
+ | {
76
+ ok: false;
77
+ error: FolderSetupError;
78
+ receipt: FolderSetupReceipt | null;
79
+ };
80
+
81
+ class SetupAbort extends Error {
82
+ readonly result: FolderSetupResult;
83
+
84
+ constructor(result: FolderSetupResult) {
85
+ super("Folder setup aborted");
86
+ this.result = result;
87
+ }
88
+ }
89
+
90
+ function nowIso(options: FolderSetupOptions): string {
91
+ return (options.now ?? (() => new Date()))().toISOString();
92
+ }
93
+
94
+ function stageFailure(
95
+ stage: SetupStageName,
96
+ error: FolderSetupError
97
+ ): SetupFailure {
98
+ return { stage, ...error };
99
+ }
100
+
101
+ async function writeReceipt(
102
+ receipt: FolderSetupReceipt,
103
+ stage: SetupStageName,
104
+ options: FolderSetupOptions
105
+ ): Promise<FolderSetupResult | null> {
106
+ try {
107
+ await (options.receiptWriter ?? persistSetupReceipt)(receipt);
108
+ return null;
109
+ } catch {
110
+ const error = setupError(
111
+ "receipt_write_failed",
112
+ `Failed to persist setup receipt: ${receipt.paths.receipt}`,
113
+ "Check local data-directory permissions and retry."
114
+ );
115
+ failSetupStage(receipt, stageFailure(stage, error), nowIso(options));
116
+ return { ok: false, error, receipt };
117
+ }
118
+ }
119
+
120
+ async function persistFailure(
121
+ receipt: FolderSetupReceipt,
122
+ stage: SetupStageName,
123
+ error: FolderSetupError,
124
+ options: FolderSetupOptions
125
+ ): Promise<FolderSetupResult> {
126
+ failSetupStage(receipt, stageFailure(stage, error), nowIso(options));
127
+ const writeFailure = await writeReceipt(receipt, stage, options);
128
+ return writeFailure ?? { ok: false, error, receipt };
129
+ }
130
+
131
+ export async function setupFolder(
132
+ options: FolderSetupOptions
133
+ ): Promise<FolderSetupResult> {
134
+ const resolved = await resolveSetupFolder(options.folder);
135
+ if ("error" in resolved) {
136
+ return { ok: false, error: resolved.error, receipt: null };
137
+ }
138
+ const folder = resolved.folder;
139
+
140
+ const storeIdentity = await resolveSetupStoreIndex({
141
+ store: options.store,
142
+ requestedIndexName: options.indexName,
143
+ });
144
+ if ("code" in storeIdentity) {
145
+ return { ok: false, error: storeIdentity, receipt: null };
146
+ }
147
+ const receiptPath = getSetupReceiptPath({
148
+ dataDir: options.dataDir,
149
+ indexName: storeIdentity.indexName,
150
+ folderRealpath: folder,
151
+ });
152
+ const configLockPath = `${options.configPath}.setup.lock`;
153
+ const unsafeOutput = await validateSetupOutputPaths(folder, [
154
+ { label: "Data directory", path: options.dataDir },
155
+ { label: "Setup receipt", path: receiptPath },
156
+ { label: "Config", path: options.configPath },
157
+ { label: "Config lock", path: configLockPath },
158
+ { label: "Index database", path: storeIdentity.dbPath },
159
+ ]);
160
+ if (unsafeOutput) {
161
+ return { ok: false, error: unsafeOutput, receipt: null };
162
+ }
163
+
164
+ const requestedExcludes = normalizeSetupExcludes(
165
+ options.exclude?.length ? options.exclude : DEFAULT_EXCLUDES
166
+ );
167
+ const loaded = await loadConfig(options.configPath);
168
+ let initialSelection: CollectionSelection | FolderSetupError | null = null;
169
+ let effectiveExcludes = requestedExcludes;
170
+ if (loaded.ok) {
171
+ initialSelection = await selectFolderCollection(
172
+ loaded.value,
173
+ folder,
174
+ options.name,
175
+ requestedExcludes
176
+ );
177
+ if (
178
+ !("code" in initialSelection) &&
179
+ initialSelection.disposition === "reused"
180
+ ) {
181
+ if (
182
+ options.exclude !== undefined &&
183
+ !setupExcludesMatch(
184
+ requestedExcludes,
185
+ initialSelection.collection.exclude
186
+ )
187
+ ) {
188
+ initialSelection = setupFilterDisagreement(initialSelection.collection);
189
+ } else {
190
+ effectiveExcludes = normalizeSetupExcludes(
191
+ initialSelection.collection.exclude
192
+ );
193
+ }
194
+ }
195
+ }
196
+
197
+ const receipt = createSetupReceipt({
198
+ now: nowIso(options),
199
+ folder,
200
+ indexName: storeIdentity.indexName,
201
+ requestedName: options.name,
202
+ excludes: effectiveExcludes,
203
+ secretRiskAuthorized: options.secretRiskAuthorized ?? false,
204
+ configPath: options.configPath,
205
+ dataDir: options.dataDir,
206
+ });
207
+ startSetupStage(receipt, "preflight", nowIso(options));
208
+ let writeFailure = await writeReceipt(receipt, "preflight", options);
209
+ if (writeFailure) {
210
+ return writeFailure;
211
+ }
212
+ if (initialSelection && "code" in initialSelection) {
213
+ return persistFailure(receipt, "preflight", initialSelection, options);
214
+ }
215
+ const preflightError = await preflightFolder(
216
+ folder,
217
+ effectiveExcludes,
218
+ options.secretRiskAuthorized ?? false
219
+ );
220
+ if (preflightError) {
221
+ return persistFailure(receipt, "preflight", preflightError, options);
222
+ }
223
+ passSetupStage(receipt, "preflight", nowIso(options));
224
+
225
+ startSetupStage(receipt, "config_saved", nowIso(options));
226
+ writeFailure = await writeReceipt(receipt, "config_saved", options);
227
+ if (writeFailure) {
228
+ return writeFailure;
229
+ }
230
+ if (!loaded.ok) {
231
+ return persistFailure(
232
+ receipt,
233
+ "config_saved",
234
+ setupError(
235
+ "config_load_failed",
236
+ loaded.error.message,
237
+ "Initialize or repair the selected GNO config, then retry."
238
+ ),
239
+ options
240
+ );
241
+ }
242
+
243
+ await options.beforeConfigBoundary?.();
244
+ let activeSelection: CollectionSelection | null = null;
245
+ let selectedCollection: Collection | undefined;
246
+ let activeConfig = loaded.value;
247
+ let boundaryError: FolderSetupError | null = null;
248
+ try {
249
+ const mutation = await applyConfigChange(
250
+ {
251
+ store: options.store,
252
+ configPath: options.configPath,
253
+ writeLockPath: configLockPath,
254
+ onConfigUpdated: (config) => {
255
+ activeConfig = config;
256
+ },
257
+ afterConfigSaved: async (config) => {
258
+ activeConfig = config;
259
+ const selected = activeSelection;
260
+ if (!selected) {
261
+ throw new Error("Setup collection selection was not established");
262
+ }
263
+ receipt.collection = {
264
+ name: selected.collection.name,
265
+ path: folder,
266
+ disposition: selected.disposition,
267
+ };
268
+ receipt.fingerprints.config = setupFingerprint({
269
+ version: config.version,
270
+ ftsTokenizer: config.ftsTokenizer,
271
+ collection: selected.collection,
272
+ });
273
+ passSetupStage(receipt, "config_saved", nowIso(options));
274
+ const configReceiptFailure = await writeReceipt(
275
+ receipt,
276
+ "config_saved",
277
+ options
278
+ );
279
+ if (configReceiptFailure) {
280
+ throw new SetupAbort(configReceiptFailure);
281
+ }
282
+ if (options.failureInjection === "after_config_save") {
283
+ throw new Error("INJECTED_AFTER_CONFIG_SAVE");
284
+ }
285
+ startSetupStage(receipt, "store_synced", nowIso(options));
286
+ const storeReceiptFailure = await writeReceipt(
287
+ receipt,
288
+ "store_synced",
289
+ options
290
+ );
291
+ if (storeReceiptFailure) {
292
+ throw new SetupAbort(storeReceiptFailure);
293
+ }
294
+ },
295
+ },
296
+ async (config) => {
297
+ const fresh = await selectFolderCollection(
298
+ config,
299
+ folder,
300
+ options.name,
301
+ effectiveExcludes
302
+ );
303
+ if ("code" in fresh) {
304
+ boundaryError = fresh;
305
+ return { ok: false, error: fresh.message, code: fresh.code };
306
+ }
307
+ if (
308
+ fresh.disposition === "reused" &&
309
+ !setupExcludesMatch(fresh.collection.exclude, effectiveExcludes)
310
+ ) {
311
+ boundaryError = setupFilterDisagreement(fresh.collection);
312
+ return {
313
+ ok: false,
314
+ error: boundaryError.message,
315
+ code: boundaryError.code,
316
+ };
317
+ }
318
+ activeSelection = fresh;
319
+ return {
320
+ ok: true,
321
+ config: fresh.config,
322
+ value: fresh.collection,
323
+ skipSave: fresh.disposition === "reused",
324
+ };
325
+ }
326
+ );
327
+ if (!mutation.ok) {
328
+ if (boundaryError) {
329
+ return persistFailure(receipt, "config_saved", boundaryError, options);
330
+ }
331
+ return persistFailure(
332
+ receipt,
333
+ receipt.stages.config_saved.status === "passed"
334
+ ? "store_synced"
335
+ : "config_saved",
336
+ setupError(
337
+ mutation.code === "SYNC_ERROR"
338
+ ? "store_sync_failed"
339
+ : "config_save_failed",
340
+ mutation.error,
341
+ "Fix config/data-directory permissions and rerun setup."
342
+ ),
343
+ options
344
+ );
345
+ }
346
+ activeConfig = mutation.config;
347
+ selectedCollection = mutation.value;
348
+ } catch (error) {
349
+ if (error instanceof SetupAbort) {
350
+ return error.result;
351
+ }
352
+ if (
353
+ error instanceof Error &&
354
+ error.message === "INJECTED_AFTER_CONFIG_SAVE"
355
+ ) {
356
+ return persistFailure(
357
+ receipt,
358
+ "config_saved",
359
+ setupInjectedFailure("after_config_save"),
360
+ options
361
+ );
362
+ }
363
+ return persistFailure(
364
+ receipt,
365
+ receipt.stages.config_saved.status === "passed"
366
+ ? "store_synced"
367
+ : "config_saved",
368
+ setupError(
369
+ "config_save_failed",
370
+ error instanceof Error ? error.message : "Config mutation failed",
371
+ "Retry after the selected config write lock is available."
372
+ ),
373
+ options
374
+ );
375
+ }
376
+
377
+ const collection = selectedCollection;
378
+ if (!collection) {
379
+ return persistFailure(
380
+ receipt,
381
+ "store_synced",
382
+ setupError(
383
+ "store_sync_failed",
384
+ "Store projection completed without a selected collection",
385
+ "Retry setup against a healthy config and index store."
386
+ ),
387
+ options
388
+ );
389
+ }
390
+ passSetupStage(receipt, "store_synced", nowIso(options));
391
+ writeFailure = await writeReceipt(receipt, "store_synced", options);
392
+ if (writeFailure) {
393
+ return writeFailure;
394
+ }
395
+ if (options.failureInjection === "after_store_sync") {
396
+ return persistFailure(
397
+ receipt,
398
+ "store_synced",
399
+ setupInjectedFailure("after_store_sync"),
400
+ options
401
+ );
402
+ }
403
+
404
+ startSetupStage(receipt, "lexical_indexed", nowIso(options));
405
+ writeFailure = await writeReceipt(receipt, "lexical_indexed", options);
406
+ if (writeFailure) {
407
+ return writeFailure;
408
+ }
409
+ const syncService = options.syncService ?? defaultSyncService;
410
+ const sync = await syncService.syncCollection(
411
+ collection,
412
+ options.store,
413
+ withContentTypeRules({ runUpdateCmd: false }, activeConfig)
414
+ );
415
+ const indexedCount =
416
+ sync.filesAdded + sync.filesUpdated + sync.filesUnchanged;
417
+ if (indexedCount === 0) {
418
+ return persistFailure(
419
+ receipt,
420
+ "lexical_indexed",
421
+ setupError(
422
+ "lexical_index_failed",
423
+ `No document reached the lexical index (${sync.filesErrored} errors, ${sync.filesSkipped} skipped)`,
424
+ "Inspect converter errors or add an indexable text document, then retry."
425
+ ),
426
+ options
427
+ );
428
+ }
429
+ passSetupStage(receipt, "lexical_indexed", nowIso(options));
430
+ writeFailure = await writeReceipt(receipt, "lexical_indexed", options);
431
+ if (writeFailure) {
432
+ return writeFailure;
433
+ }
434
+ if (options.failureInjection === "after_lexical_index") {
435
+ return persistFailure(
436
+ receipt,
437
+ "lexical_indexed",
438
+ setupInjectedFailure("after_lexical_index"),
439
+ options
440
+ );
441
+ }
442
+
443
+ startSetupStage(receipt, "lexical_proved", nowIso(options));
444
+ writeFailure = await writeReceipt(receipt, "lexical_proved", options);
445
+ if (writeFailure) {
446
+ return writeFailure;
447
+ }
448
+ const proof = await verifyLexicalActivation(options.store, collection.name);
449
+ if (!proof.ok || !proof.value.ready) {
450
+ const message = proof.ok
451
+ ? `Lexical activation was not proven (${proof.value.stages.lexical.code ?? proof.value.stages.index.code ?? "unknown"})`
452
+ : proof.error.message;
453
+ if (proof.ok) {
454
+ receipt.activation = proof.value;
455
+ receipt.fingerprints.index = proof.value.fingerprint;
456
+ }
457
+ return persistFailure(
458
+ receipt,
459
+ "lexical_proved",
460
+ setupError(
461
+ "lexical_proof_failed",
462
+ message,
463
+ "Fix the lexical corpus/index state, then rerun setup."
464
+ ),
465
+ options
466
+ );
467
+ }
468
+ receipt.activation = proof.value;
469
+ receipt.fingerprints.index = proof.value.fingerprint;
470
+ passSetupStage(receipt, "lexical_proved", nowIso(options));
471
+ writeFailure = await writeReceipt(receipt, "lexical_proved", options);
472
+ if (writeFailure) {
473
+ return writeFailure;
474
+ }
475
+ if (options.failureInjection === "after_lexical_proof") {
476
+ return persistFailure(
477
+ receipt,
478
+ "lexical_proved",
479
+ setupInjectedFailure("after_lexical_proof"),
480
+ options
481
+ );
482
+ }
483
+
484
+ startSetupStage(receipt, "completed", nowIso(options));
485
+ passSetupStage(receipt, "completed", nowIso(options));
486
+ receipt.status = "completed";
487
+ receipt.pending = [];
488
+ writeFailure = await writeReceipt(receipt, "completed", options);
489
+ return writeFailure ?? { ok: true, receipt };
490
+ }