@semiont/content 0.5.29 → 0.5.31

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/dist/index.js CHANGED
@@ -1,10 +1,87 @@
1
1
  // src/working-tree-store.ts
2
2
  import { promises as fs, createReadStream, createWriteStream } from "fs";
3
- import { execFileSync } from "child_process";
4
3
  import { createHash, randomUUID } from "crypto";
5
4
  import { Readable } from "stream";
6
5
  import { pipeline } from "stream/promises";
7
6
  import path from "path";
7
+
8
+ // src/git-staging.ts
9
+ import { execFile } from "child_process";
10
+ import { promisify } from "util";
11
+ import { recordGitCommand } from "@semiont/observability";
12
+ var run = promisify(execFile);
13
+ var DEFAULT_FLUSH_MS = 250;
14
+ var DEFAULT_MAX_WAIT_MS = 2e3;
15
+ function createStager(cwd, options = {}) {
16
+ const flushMs = options.flushMs ?? DEFAULT_FLUSH_MS;
17
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
18
+ const queued = /* @__PURE__ */ new Set();
19
+ let timer;
20
+ let oldestAt;
21
+ let inFlight = Promise.resolve();
22
+ let disposed = false;
23
+ const git = async (args) => {
24
+ const started = performance.now();
25
+ try {
26
+ await run("git", args, { cwd });
27
+ } finally {
28
+ recordGitCommand(args[0] ?? "git", performance.now() - started);
29
+ }
30
+ };
31
+ const serialize = (work) => {
32
+ inFlight = inFlight.then(work, work);
33
+ return inFlight;
34
+ };
35
+ const clearTimer = () => {
36
+ if (timer) {
37
+ clearTimeout(timer);
38
+ timer = void 0;
39
+ }
40
+ };
41
+ const drain = () => {
42
+ clearTimer();
43
+ if (queued.size === 0) return inFlight;
44
+ const batch = [...queued];
45
+ queued.clear();
46
+ oldestAt = void 0;
47
+ return serialize(() => git(["add", ...batch]));
48
+ };
49
+ const arm = () => {
50
+ clearTimer();
51
+ if (disposed || queued.size === 0) return;
52
+ const sinceOldest = oldestAt === void 0 ? 0 : Date.now() - oldestAt;
53
+ const wait = Math.max(0, Math.min(flushMs, maxWaitMs - sinceOldest));
54
+ timer = setTimeout(() => {
55
+ void drain();
56
+ }, wait);
57
+ timer.unref?.();
58
+ };
59
+ return {
60
+ add(path4) {
61
+ if (disposed) return;
62
+ if (queued.size === 0) oldestAt = Date.now();
63
+ queued.add(path4);
64
+ arm();
65
+ },
66
+ run(args) {
67
+ return drain().then(() => serialize(() => git(args)));
68
+ },
69
+ flush() {
70
+ return drain();
71
+ },
72
+ pending() {
73
+ return queued.size;
74
+ },
75
+ async dispose() {
76
+ await drain();
77
+ disposed = true;
78
+ clearTimer();
79
+ await inFlight;
80
+ }
81
+ };
82
+ }
83
+
84
+ // src/working-tree-store.ts
8
85
  function hashingTap() {
9
86
  const hash = createHash("sha256");
10
87
  let byteSize = 0;
@@ -25,33 +102,40 @@ var WorkingTreeStore = class {
25
102
  projectRoot;
26
103
  gitSync;
27
104
  logger;
28
- constructor(project, logger) {
105
+ _stager;
106
+ staging;
107
+ /** `staging` is policy — how stale the index may get is the caller's call. */
108
+ constructor(project, logger, staging = {}) {
29
109
  this.projectRoot = project.root;
30
110
  this.gitSync = project.gitSync;
31
111
  this.logger = logger;
112
+ this.staging = staging;
113
+ }
114
+ /** Created on first use — importers of this package may never stage. */
115
+ stager() {
116
+ if (!this._stager) this._stager = createStager(this.projectRoot, this.staging);
117
+ return this._stager;
118
+ }
119
+ /** Stage everything pending now — for a caller that wants the index current. */
120
+ flushStaging() {
121
+ return this._stager ? this._stager.flush() : Promise.resolve();
122
+ }
123
+ /** Drain and stop. A stopped process must leave nothing unstaged. */
124
+ async dispose() {
125
+ if (this._stager) await this._stager.dispose();
32
126
  }
33
127
  shouldRunGit(noGit) {
34
128
  return this.gitSync && !noGit;
35
129
  }
36
130
  /**
37
- * Write content to disk at the location indicated by storageUri.
38
- *
39
- * API/GUI/AI path: caller provides bytes — as a Buffer it already holds, or
40
- * as a stream (the Archivist's write endpoint hands the request body
41
- * straight through, SINGLE-KB-MOUNT P2/D7: memory stays bounded by the
42
- * chunk, never the representation).
131
+ * Write bytes to the path storageUri names, whole or streamed.
43
132
  *
44
- * Atomic either way: bytes stream into a temp file beside the target and
45
- * are renamed into place only once complete and only once
46
- * `expectedChecksum`, when given, agrees with what actually arrived. A
47
- * mismatch or a torn stream leaves the target untouched (a version being
48
- * overwritten survives) and no temp file behind, so the Stower's `register`
49
- * can never find partial bytes an event names.
133
+ * Atomic: bytes land in a temp file and are renamed into place only once
134
+ * complete and once `expectedChecksum`, when given, agrees. A mismatch or a
135
+ * torn stream leaves the target untouched, so `register` can never find
136
+ * partial bytes an event names.
50
137
  *
51
- * @param content - Raw bytes to write, whole or streamed
52
- * @param storageUri - file:// URI (e.g. "file://docs/overview.md")
53
138
  * @throws ChecksumMismatchError when expectedChecksum disagrees with the body
54
- * @returns Stored resource metadata
55
139
  */
56
140
  async store(content, storageUri, options) {
57
141
  const filePath = this.resolveUri(storageUri);
@@ -78,7 +162,7 @@ var WorkingTreeStore = class {
78
162
  }
79
163
  await fs.rename(tempPath, filePath);
80
164
  if (this.shouldRunGit(options?.noGit)) {
81
- execFileSync("git", ["add", filePath], { cwd: this.projectRoot });
165
+ this.stager().add(filePath);
82
166
  }
83
167
  this.logger?.info("Resource stored", { storageUri, checksum, byteSize });
84
168
  return {
@@ -93,17 +177,9 @@ var WorkingTreeStore = class {
93
177
  }
94
178
  }
95
179
  /**
96
- * Read an existing file and return its metadata.
97
- *
98
- * The file is already on disk; this hashes it by streaming to confirm what
99
- * it is, then stages it. If expectedChecksum is provided, throws
100
- * ChecksumMismatchError on mismatch.
180
+ * Adopt a file already on disk: stream it to hash it, then stage it.
101
181
  *
102
- * @param storageUri - file:// URI (e.g. "file://docs/overview.md")
103
- * @param expectedChecksum - Optional SHA-256 to verify against
104
- * @returns Stored resource metadata
105
- * @throws ChecksumMismatchError if expectedChecksum is provided and does not match
106
- * @throws Error if file does not exist
182
+ * @throws ChecksumMismatchError if expectedChecksum is given and disagrees
107
183
  */
108
184
  async register(storageUri, expectedChecksum, options) {
109
185
  const filePath = this.resolveUri(storageUri);
@@ -117,7 +193,7 @@ var WorkingTreeStore = class {
117
193
  throw new ChecksumMismatchError(storageUri, expectedChecksum, checksum);
118
194
  }
119
195
  if (this.shouldRunGit(options?.noGit)) {
120
- execFileSync("git", ["add", filePath], { cwd: this.projectRoot });
196
+ this.stager().add(filePath);
121
197
  }
122
198
  const byteSize = tap.byteSize;
123
199
  this.logger?.info("Resource registered", { storageUri, checksum, byteSize });
@@ -129,22 +205,9 @@ var WorkingTreeStore = class {
129
205
  };
130
206
  }
131
207
  /**
132
- * Read file content by URI.
133
- *
134
- * @param storageUri - file:// URI
135
- * @returns Raw bytes
136
- */
137
- /**
138
- * The same bytes as `retrieve`, streamed — for the byte paths that must not
139
- * hold a whole representation in memory (SINGLE-KB-MOUNT D7: the Archivist
140
- * serves content for every reader now, so its memory cannot be bounded by
141
- * the largest file anyone asks for).
142
- *
143
- * Lazy by construction: the stream is created here but nothing is read
144
- * until the caller iterates, so a missing file surfaces as an `error` event
145
- * on the stream rather than a rejected promise. Callers that need the
146
- * distinction up front should resolve the descriptor first — which is what
147
- * `resolveRepresentation` does.
208
+ * The same bytes as `retrieve`, streamed. Lazy: a missing file surfaces as
209
+ * an `error` event on the stream, not a rejected promise — callers needing
210
+ * that up front should resolve the descriptor first.
148
211
  */
149
212
  retrieveStream(storageUri) {
150
213
  return createReadStream(this.resolveUri(storageUri));
@@ -160,42 +223,20 @@ var WorkingTreeStore = class {
160
223
  throw error;
161
224
  }
162
225
  }
163
- /**
164
- * Move a file from one URI to another.
165
- *
166
- * If .git/ exists in the project root and noGit is not set, runs `git mv`.
167
- * Otherwise (no .git/ or noGit: true), runs fs.rename.
168
- *
169
- * @param fromUri - Current file:// URI
170
- * @param toUri - New file:// URI
171
- * @param options.noGit - Skip git mv even if .git/ is present
172
- */
226
+ /** `git mv` when the project syncs git, `fs.rename` otherwise. */
173
227
  async move(fromUri, toUri, options) {
174
228
  const fromPath = this.resolveUri(fromUri);
175
229
  const toPath = this.resolveUri(toUri);
176
230
  this.logger?.debug("Moving resource", { fromUri, toUri });
177
231
  await fs.mkdir(path.dirname(toPath), { recursive: true });
178
232
  if (this.shouldRunGit(options?.noGit)) {
179
- execFileSync("git", ["mv", fromPath, toPath], { cwd: this.projectRoot });
233
+ await this.stager().run(["mv", fromPath, toPath]);
180
234
  } else {
181
235
  await fs.rename(fromPath, toPath);
182
236
  }
183
237
  this.logger?.info("Resource moved", { fromUri, toUri });
184
238
  }
185
- /**
186
- * Remove a file from the working tree.
187
- *
188
- * If .git/ exists and noGit is not set:
189
- * - keepFile false (default): runs `git rm` (removes from index and disk)
190
- * - keepFile true: runs `git rm --cached` (removes from index only, file stays on disk)
191
- * If no .git/ or noGit: true:
192
- * - keepFile false: runs fs.unlink
193
- * - keepFile true: no-op on filesystem
194
- *
195
- * @param storageUri - file:// URI
196
- * @param options.noGit - Skip git rm even if .git/ is present
197
- * @param options.keepFile - Remove from git index only; leave file on disk
198
- */
239
+ /** @param options.keepFile - Drop from the index only; leave the file on disk. */
199
240
  async remove(storageUri, options) {
200
241
  const filePath = this.resolveUri(storageUri);
201
242
  const keepFile = options?.keepFile ?? false;
@@ -203,7 +244,7 @@ var WorkingTreeStore = class {
203
244
  const useGit = this.shouldRunGit(options?.noGit);
204
245
  if (useGit) {
205
246
  const gitArgs = keepFile ? ["rm", "--cached", filePath] : ["rm", filePath];
206
- execFileSync("git", gitArgs, { cwd: this.projectRoot });
247
+ await this.stager().run(gitArgs);
207
248
  this.logger?.info("Resource removed", { storageUri, keepFile, git: true });
208
249
  return;
209
250
  }
@@ -222,14 +263,6 @@ var WorkingTreeStore = class {
222
263
  throw error;
223
264
  }
224
265
  }
225
- /**
226
- * Convert a file:// URI to an absolute filesystem path.
227
- *
228
- * "file://docs/overview.md" → "{projectRoot}/docs/overview.md"
229
- *
230
- * @param storageUri - file:// URI
231
- * @returns Absolute path
232
- */
233
266
  resolveUri(storageUri) {
234
267
  if (!storageUri.startsWith("file://")) {
235
268
  throw new Error(`Invalid storage URI (must start with file://): ${storageUri}`);
@@ -265,8 +298,8 @@ function verifyChecksum(content, checksum) {
265
298
  return calculateChecksum(content) === checksum;
266
299
  }
267
300
 
268
- // src/content-extractor.ts
269
- import { decodeRepresentation } from "@semiont/core";
301
+ // src/text-extractor.ts
302
+ import { yieldsGeometryOf } from "@semiont/core";
270
303
 
271
304
  // src/pdf-extractor.ts
272
305
  import { isObject as isObject4 } from "@semiont/core";
@@ -815,18 +848,17 @@ function shapeTables(layer) {
815
848
  }
816
849
  var pdfExtractor = {
817
850
  // Every non-declined PDF extraction carries positioned runs — native text
818
- // layers and OCR both anchor by page geometry.
819
- yieldsGeometry: true,
851
+ // layers and OCR both anchor by page geometry. That fact is declared in core
852
+ // ('pdf-text-layer' → true) rather than here; this comment records the
853
+ // behavior the census gate holds core's answer to.
820
854
  async extract(content, _mediaType, cache) {
821
- const hit = await cache?.store.read(cache.key);
855
+ const hit = await cache.store.read(cache.key);
822
856
  if (hit) return hit;
823
857
  const outcome = await extractPdf(content);
824
- if (cache) {
825
- try {
826
- if (outcome.kind === "declined") await cache.store.write(cache.key, outcome);
827
- else if (outcome.items) await cache.store.write(cache.key, { ...outcome, items: outcome.items });
828
- } catch {
829
- }
858
+ try {
859
+ if (outcome.kind === "declined") await cache.store.write(cache.key, outcome);
860
+ else if (outcome.items) await cache.store.write(cache.key, { ...outcome, items: outcome.items });
861
+ } catch {
830
862
  }
831
863
  return outcome;
832
864
  }
@@ -881,18 +913,10 @@ async function extractPdf(content) {
881
913
  };
882
914
  }
883
915
 
884
- // src/content-extractor.ts
885
- var passthroughExtractor = {
886
- yieldsGeometry: false,
887
- async extract(content, mediaType) {
888
- return { kind: "extracted", text: decodeRepresentation(content, mediaType), method: "text-passthrough" };
889
- }
890
- };
891
- var EXTRACTORS = {
892
- "decode": passthroughExtractor,
893
- "pdf-text-layer": pdfExtractor,
894
- "none": null
895
- };
916
+ // src/text-extractor.ts
917
+ function derivingExtractorFor(mediaType) {
918
+ return yieldsGeometryOf(mediaType) ? pdfExtractor : null;
919
+ }
896
920
 
897
921
  // src/anchored-text-store.ts
898
922
  import fs2 from "fs";
@@ -1094,13 +1118,14 @@ function archivistContentReads(config) {
1094
1118
  }
1095
1119
  export {
1096
1120
  ChecksumMismatchError,
1097
- EXTRACTORS,
1098
1121
  MAX_PDF_BYTES,
1099
1122
  RepresentationMissing,
1100
1123
  WorkingTreeStore,
1101
1124
  archivistContentReads,
1102
1125
  calculateChecksum,
1103
1126
  createAnchoredTextStore,
1127
+ createStager,
1128
+ derivingExtractorFor,
1104
1129
  extractPdfTextLayer,
1105
1130
  verifyChecksum,
1106
1131
  withinByteBudget