@gmickel/gno 1.7.1 → 1.8.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.
@@ -5,10 +5,10 @@
5
5
  * @module src/serve/routes/api
6
6
  */
7
7
 
8
- // node:fs/promises structure walk has no Bun equivalent
9
- import { readdir } from "node:fs/promises";
8
+ // node:fs/promises structure ops have no Bun equivalent
9
+ import { mkdir, readdir } from "node:fs/promises";
10
10
  // node:path has no Bun equivalent
11
- import { posix as pathPosix } from "node:path";
11
+ import { dirname, join as pathJoin, posix as pathPosix } from "node:path";
12
12
 
13
13
  import type {
14
14
  Collection,
@@ -35,6 +35,14 @@ import {
35
35
  removeCollection,
36
36
  updateCollection,
37
37
  } from "../../collection";
38
+ import {
39
+ buildCaptureReceipt,
40
+ listCaptureDiskRelPaths,
41
+ planCapture,
42
+ type CapturePlan,
43
+ type PublicCaptureInput,
44
+ } from "../../core/capture";
45
+ import { writeCapturePlanFile } from "../../core/capture-write";
38
46
  import {
39
47
  buildEditableCopyContent,
40
48
  deriveEditableCopyRelPath,
@@ -292,6 +300,8 @@ export interface CreateDocRequestBody {
292
300
  tags?: string[];
293
301
  }
294
302
 
303
+ export interface CreateCaptureRequestBody extends PublicCaptureInput {}
304
+
295
305
  export interface RenameDocRequestBody {
296
306
  name: string;
297
307
  uri?: string;
@@ -2795,6 +2805,157 @@ export async function handleCreateEditableCopy(
2795
2805
  return handleCreateDoc(ctxHolder, store, createReq);
2796
2806
  }
2797
2807
 
2808
+ /**
2809
+ * POST /api/capture
2810
+ * Capture a note with structured provenance.
2811
+ */
2812
+ export async function handleCreateCapture(
2813
+ ctxHolder: ContextHolder,
2814
+ store: SqliteAdapter,
2815
+ req: Request
2816
+ ): Promise<Response> {
2817
+ let body: CreateCaptureRequestBody;
2818
+ try {
2819
+ body = (await req.json()) as CreateCaptureRequestBody;
2820
+ } catch {
2821
+ return errorResponse("VALIDATION", "Invalid JSON body");
2822
+ }
2823
+
2824
+ if (!body.collection || typeof body.collection !== "string") {
2825
+ return errorResponse("VALIDATION", "Missing or invalid collection");
2826
+ }
2827
+ if (body.content !== undefined && typeof body.content !== "string") {
2828
+ return errorResponse("VALIDATION", "content must be a string");
2829
+ }
2830
+ if (body.title !== undefined && typeof body.title !== "string") {
2831
+ return errorResponse("VALIDATION", "title must be a string");
2832
+ }
2833
+ if (body.relPath !== undefined && typeof body.relPath !== "string") {
2834
+ return errorResponse("VALIDATION", "relPath must be a string");
2835
+ }
2836
+ if (body.folderPath !== undefined && typeof body.folderPath !== "string") {
2837
+ return errorResponse("VALIDATION", "folderPath must be a string");
2838
+ }
2839
+ if ("overwrite" in body) {
2840
+ return errorResponse(
2841
+ "VALIDATION",
2842
+ "overwrite is not supported by /api/capture; use collisionPolicy instead"
2843
+ );
2844
+ }
2845
+
2846
+ const collectionName = body.collection.toLowerCase();
2847
+ const collection = ctxHolder.config.collections.find(
2848
+ (candidate) => candidate.name.toLowerCase() === collectionName
2849
+ );
2850
+ if (!collection) {
2851
+ return errorResponse(
2852
+ "NOT_FOUND",
2853
+ `Collection not found: ${body.collection}`,
2854
+ 404
2855
+ );
2856
+ }
2857
+
2858
+ let plan: CapturePlan;
2859
+ try {
2860
+ plan = planCapture({
2861
+ input: {
2862
+ ...body,
2863
+ collection: collection.name,
2864
+ },
2865
+ existingRelPaths: await listCollectionRelPaths(store, collection.name),
2866
+ diskRelPaths: await listCaptureDiskRelPaths(collection.path),
2867
+ });
2868
+ } catch (error) {
2869
+ return errorResponse(
2870
+ "VALIDATION",
2871
+ error instanceof Error ? error.message : String(error),
2872
+ 409
2873
+ );
2874
+ }
2875
+
2876
+ const fullPath = pathJoin(collection.path, plan.relPath);
2877
+ if (plan.openedExisting) {
2878
+ const existingDoc = await store.getDocument(collection.name, plan.relPath);
2879
+ if (!existingDoc.ok) {
2880
+ return errorResponse("RUNTIME", existingDoc.error.message, 500);
2881
+ }
2882
+ return jsonResponse(
2883
+ buildCaptureReceipt({
2884
+ plan,
2885
+ absPath: fullPath,
2886
+ docid: existingDoc.value?.docid,
2887
+ sync: existingDoc.value
2888
+ ? { status: "completed" }
2889
+ : {
2890
+ status: "skipped",
2891
+ reason: "Existing file is not indexed yet.",
2892
+ },
2893
+ })
2894
+ );
2895
+ }
2896
+
2897
+ try {
2898
+ await mkdir(dirname(fullPath), { recursive: true });
2899
+ ctxHolder.watchService?.suppress(fullPath);
2900
+ await writeCapturePlanFile(plan, fullPath);
2901
+
2902
+ const gnoUri = `gno://${collection.name}/${plan.relPath}`;
2903
+ const jobResult = startJob("sync", async (): Promise<SyncResult> => {
2904
+ const result = await defaultSyncService.syncCollection(
2905
+ collection,
2906
+ store,
2907
+ { runUpdateCmd: false }
2908
+ );
2909
+ ctxHolder.scheduler?.notifySyncComplete([plan.relPath]);
2910
+ ctxHolder.eventBus?.emit({
2911
+ type: "document-changed",
2912
+ uri: gnoUri,
2913
+ collection: collection.name,
2914
+ relPath: plan.relPath,
2915
+ origin: "create",
2916
+ changedAt: new Date().toISOString(),
2917
+ });
2918
+ return {
2919
+ collections: [result],
2920
+ totalDurationMs: result.durationMs,
2921
+ totalFilesProcessed: result.filesProcessed,
2922
+ totalFilesAdded: result.filesAdded,
2923
+ totalFilesUpdated: result.filesUpdated,
2924
+ totalFilesErrored: result.filesErrored,
2925
+ totalFilesSkipped: result.filesSkipped,
2926
+ };
2927
+ });
2928
+
2929
+ return jsonResponse(
2930
+ buildCaptureReceipt({
2931
+ plan,
2932
+ absPath: fullPath,
2933
+ sync: jobResult.ok
2934
+ ? {
2935
+ status: "pending",
2936
+ jobId: jobResult.jobId,
2937
+ reason: "Sync job started; poll /api/jobs/:id for status.",
2938
+ }
2939
+ : {
2940
+ status: "skipped",
2941
+ jobId: jobResult.activeJobId,
2942
+ reason: "Sync skipped because another job is running.",
2943
+ error: jobResult.error,
2944
+ },
2945
+ }),
2946
+ 202
2947
+ );
2948
+ } catch (error) {
2949
+ return errorResponse(
2950
+ "RUNTIME",
2951
+ `Failed to capture document: ${
2952
+ error instanceof Error ? error.message : String(error)
2953
+ }`,
2954
+ 500
2955
+ );
2956
+ }
2957
+ }
2958
+
2798
2959
  /**
2799
2960
  * POST /api/docs
2800
2961
  * Create a new document in a collection.
@@ -3989,6 +4150,31 @@ export async function routeApi(
3989
4150
  return handlePublishExport(config, store, req);
3990
4151
  }
3991
4152
 
4153
+ if (path === "/api/capture" && req.method === "POST") {
4154
+ const ctxHolder: ContextHolder = {
4155
+ current: {
4156
+ config,
4157
+ store,
4158
+ vectorIndex: null,
4159
+ embedPort: null,
4160
+ expandPort: null,
4161
+ answerPort: null,
4162
+ rerankPort: null,
4163
+ capabilities: {
4164
+ bm25: true,
4165
+ vector: false,
4166
+ hybrid: false,
4167
+ answer: false,
4168
+ },
4169
+ },
4170
+ config,
4171
+ scheduler: null,
4172
+ eventBus: null,
4173
+ watchService: null,
4174
+ };
4175
+ return handleCreateCapture(ctxHolder, store, req);
4176
+ }
4177
+
3992
4178
  if (path === "/api/docs") {
3993
4179
  return handleDocs(store, url);
3994
4180
  }
@@ -23,6 +23,7 @@ import {
23
23
  handleCreateFolder,
24
24
  handleCreateCollection,
25
25
  handleCreateEditableCopy,
26
+ handleCreateCapture,
26
27
  handleCreateDoc,
27
28
  handleDeactivateDoc,
28
29
  handleDeleteCollection,
@@ -256,6 +257,17 @@ export async function startServer(
256
257
  );
257
258
  },
258
259
  },
260
+ "/api/capture": {
261
+ POST: async (req: Request) => {
262
+ if (!isRequestAllowed(req, port)) {
263
+ return withSecurityHeaders(forbiddenResponse(), isDev);
264
+ }
265
+ return withSecurityHeaders(
266
+ await handleCreateCapture(ctxHolder, store, req),
267
+ isDev
268
+ );
269
+ },
270
+ },
259
271
  "/api/docs": {
260
272
  GET: async (req: Request) => {
261
273
  const url = new URL(req.url);