@semiont/content 0.5.30 → 0.5.32

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,19 +1,125 @@
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";
8
- import { recordGitCommand } from "@semiont/observability";
9
- function git(args, cwd) {
10
- const started = performance.now();
11
- try {
12
- execFileSync("git", args, { cwd });
13
- } finally {
14
- recordGitCommand(args[0] ?? "git", performance.now() - started);
15
- }
7
+
8
+ // src/git-staging.ts
9
+ import { execFile } from "child_process";
10
+ import { resolve } from "path";
11
+ import { promisify } from "util";
12
+ import { recordGitCommand, recordGitStagingFailure } from "@semiont/observability";
13
+ var run = promisify(execFile);
14
+ var LOCK_RETRY_DELAYS_MS = [50, 100, 200, 400, 800, 1600];
15
+ var isIndexLockContention = (error) => typeof error === "object" && error !== null && error.code === 128 && String(error.stderr ?? "").includes("index.lock");
16
+ var DEFAULT_FLUSH_MS = 250;
17
+ var DEFAULT_MAX_WAIT_MS = 2e3;
18
+ var stagers = /* @__PURE__ */ new Map();
19
+ function createStager(cwd, options = {}) {
20
+ const key = resolve(cwd);
21
+ const existing = stagers.get(key);
22
+ if (existing) return existing;
23
+ const stager = buildStager(key, options);
24
+ stagers.set(key, stager);
25
+ return stager;
26
+ }
27
+ function buildStager(cwd, options = {}) {
28
+ const flushMs = options.flushMs ?? DEFAULT_FLUSH_MS;
29
+ const maxWaitMs = options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
30
+ const queued = /* @__PURE__ */ new Set();
31
+ let timer;
32
+ let oldestAt;
33
+ let inFlight = Promise.resolve();
34
+ let disposed = false;
35
+ const git = async (args) => {
36
+ const started = performance.now();
37
+ try {
38
+ for (let attempt = 0; ; attempt++) {
39
+ try {
40
+ await run("git", args, { cwd });
41
+ return;
42
+ } catch (error) {
43
+ const last = attempt >= LOCK_RETRY_DELAYS_MS.length;
44
+ if (last || !isIndexLockContention(error)) throw error;
45
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_DELAYS_MS[attempt]));
46
+ }
47
+ }
48
+ } finally {
49
+ recordGitCommand(args[0] ?? "git", performance.now() - started);
50
+ }
51
+ };
52
+ const serialize = (work) => {
53
+ inFlight = inFlight.then(work, work);
54
+ return inFlight;
55
+ };
56
+ const clearTimer = () => {
57
+ if (timer) {
58
+ clearTimeout(timer);
59
+ timer = void 0;
60
+ }
61
+ };
62
+ const drain = () => {
63
+ clearTimer();
64
+ if (queued.size === 0) return inFlight;
65
+ const batch = [...queued];
66
+ queued.clear();
67
+ oldestAt = void 0;
68
+ return serialize(
69
+ () => git(["add", ...batch]).catch((error) => {
70
+ const lock = isIndexLockContention(error);
71
+ if (lock) {
72
+ for (const path4 of batch) queued.add(path4);
73
+ if (oldestAt === void 0) oldestAt = Date.now();
74
+ arm();
75
+ }
76
+ recordGitStagingFailure(lock ? "index-lock" : "other");
77
+ })
78
+ );
79
+ };
80
+ const arm = () => {
81
+ clearTimer();
82
+ if (disposed || queued.size === 0) return;
83
+ const sinceOldest = oldestAt === void 0 ? 0 : Date.now() - oldestAt;
84
+ const wait = Math.max(0, Math.min(flushMs, maxWaitMs - sinceOldest));
85
+ timer = setTimeout(() => {
86
+ void drain();
87
+ }, wait);
88
+ timer.unref?.();
89
+ };
90
+ return {
91
+ add(path4) {
92
+ if (disposed) return;
93
+ if (queued.size === 0) oldestAt = Date.now();
94
+ queued.add(path4);
95
+ arm();
96
+ },
97
+ run(args) {
98
+ return drain().then(
99
+ () => serialize(
100
+ () => git(args).catch((error) => {
101
+ recordGitStagingFailure(isIndexLockContention(error) ? "index-lock" : "other");
102
+ })
103
+ )
104
+ );
105
+ },
106
+ flush() {
107
+ return drain();
108
+ },
109
+ pending() {
110
+ return queued.size;
111
+ },
112
+ async dispose() {
113
+ stagers.delete(cwd);
114
+ await drain();
115
+ disposed = true;
116
+ clearTimer();
117
+ await inFlight;
118
+ }
119
+ };
16
120
  }
121
+
122
+ // src/working-tree-store.ts
17
123
  function hashingTap() {
18
124
  const hash = createHash("sha256");
19
125
  let byteSize = 0;
@@ -34,33 +140,40 @@ var WorkingTreeStore = class {
34
140
  projectRoot;
35
141
  gitSync;
36
142
  logger;
37
- constructor(project, logger) {
143
+ _stager;
144
+ staging;
145
+ /** `staging` is policy — how stale the index may get is the caller's call. */
146
+ constructor(project, logger, staging = {}) {
38
147
  this.projectRoot = project.root;
39
148
  this.gitSync = project.gitSync;
40
149
  this.logger = logger;
150
+ this.staging = staging;
151
+ }
152
+ /** Created on first use — importers of this package may never stage. */
153
+ stager() {
154
+ if (!this._stager) this._stager = createStager(this.projectRoot, this.staging);
155
+ return this._stager;
156
+ }
157
+ /** Stage everything pending now — for a caller that wants the index current. */
158
+ flushStaging() {
159
+ return this._stager ? this._stager.flush() : Promise.resolve();
160
+ }
161
+ /** Drain and stop. A stopped process must leave nothing unstaged. */
162
+ async dispose() {
163
+ if (this._stager) await this._stager.dispose();
41
164
  }
42
165
  shouldRunGit(noGit) {
43
166
  return this.gitSync && !noGit;
44
167
  }
45
168
  /**
46
- * Write content to disk at the location indicated by storageUri.
169
+ * Write bytes to the path storageUri names, whole or streamed.
47
170
  *
48
- * API/GUI/AI path: caller provides bytes as a Buffer it already holds, or
49
- * as a stream (the Archivist's write endpoint hands the request body
50
- * straight through, SINGLE-KB-MOUNT P2/D7: memory stays bounded by the
51
- * chunk, never the representation).
171
+ * Atomic: bytes land in a temp file and are renamed into place only once
172
+ * complete and once `expectedChecksum`, when given, agrees. A mismatch or a
173
+ * torn stream leaves the target untouched, so `register` can never find
174
+ * partial bytes an event names.
52
175
  *
53
- * Atomic either way: bytes stream into a temp file beside the target and
54
- * are renamed into place only once complete — and only once
55
- * `expectedChecksum`, when given, agrees with what actually arrived. A
56
- * mismatch or a torn stream leaves the target untouched (a version being
57
- * overwritten survives) and no temp file behind, so the Stower's `register`
58
- * can never find partial bytes an event names.
59
- *
60
- * @param content - Raw bytes to write, whole or streamed
61
- * @param storageUri - file:// URI (e.g. "file://docs/overview.md")
62
176
  * @throws ChecksumMismatchError when expectedChecksum disagrees with the body
63
- * @returns Stored resource metadata
64
177
  */
65
178
  async store(content, storageUri, options) {
66
179
  const filePath = this.resolveUri(storageUri);
@@ -87,7 +200,7 @@ var WorkingTreeStore = class {
87
200
  }
88
201
  await fs.rename(tempPath, filePath);
89
202
  if (this.shouldRunGit(options?.noGit)) {
90
- git(["add", filePath], this.projectRoot);
203
+ this.stager().add(filePath);
91
204
  }
92
205
  this.logger?.info("Resource stored", { storageUri, checksum, byteSize });
93
206
  return {
@@ -102,17 +215,9 @@ var WorkingTreeStore = class {
102
215
  }
103
216
  }
104
217
  /**
105
- * Read an existing file and return its metadata.
106
- *
107
- * The file is already on disk; this hashes it by streaming to confirm what
108
- * it is, then stages it. If expectedChecksum is provided, throws
109
- * ChecksumMismatchError on mismatch.
218
+ * Adopt a file already on disk: stream it to hash it, then stage it.
110
219
  *
111
- * @param storageUri - file:// URI (e.g. "file://docs/overview.md")
112
- * @param expectedChecksum - Optional SHA-256 to verify against
113
- * @returns Stored resource metadata
114
- * @throws ChecksumMismatchError if expectedChecksum is provided and does not match
115
- * @throws Error if file does not exist
220
+ * @throws ChecksumMismatchError if expectedChecksum is given and disagrees
116
221
  */
117
222
  async register(storageUri, expectedChecksum, options) {
118
223
  const filePath = this.resolveUri(storageUri);
@@ -126,7 +231,7 @@ var WorkingTreeStore = class {
126
231
  throw new ChecksumMismatchError(storageUri, expectedChecksum, checksum);
127
232
  }
128
233
  if (this.shouldRunGit(options?.noGit)) {
129
- git(["add", filePath], this.projectRoot);
234
+ this.stager().add(filePath);
130
235
  }
131
236
  const byteSize = tap.byteSize;
132
237
  this.logger?.info("Resource registered", { storageUri, checksum, byteSize });
@@ -138,22 +243,9 @@ var WorkingTreeStore = class {
138
243
  };
139
244
  }
140
245
  /**
141
- * Read file content by URI.
142
- *
143
- * @param storageUri - file:// URI
144
- * @returns Raw bytes
145
- */
146
- /**
147
- * The same bytes as `retrieve`, streamed — for the byte paths that must not
148
- * hold a whole representation in memory (SINGLE-KB-MOUNT D7: the Archivist
149
- * serves content for every reader now, so its memory cannot be bounded by
150
- * the largest file anyone asks for).
151
- *
152
- * Lazy by construction: the stream is created here but nothing is read
153
- * until the caller iterates, so a missing file surfaces as an `error` event
154
- * on the stream rather than a rejected promise. Callers that need the
155
- * distinction up front should resolve the descriptor first — which is what
156
- * `resolveRepresentation` does.
246
+ * The same bytes as `retrieve`, streamed. Lazy: a missing file surfaces as
247
+ * an `error` event on the stream, not a rejected promise — callers needing
248
+ * that up front should resolve the descriptor first.
157
249
  */
158
250
  retrieveStream(storageUri) {
159
251
  return createReadStream(this.resolveUri(storageUri));
@@ -169,42 +261,20 @@ var WorkingTreeStore = class {
169
261
  throw error;
170
262
  }
171
263
  }
172
- /**
173
- * Move a file from one URI to another.
174
- *
175
- * If .git/ exists in the project root and noGit is not set, runs `git mv`.
176
- * Otherwise (no .git/ or noGit: true), runs fs.rename.
177
- *
178
- * @param fromUri - Current file:// URI
179
- * @param toUri - New file:// URI
180
- * @param options.noGit - Skip git mv even if .git/ is present
181
- */
264
+ /** `git mv` when the project syncs git, `fs.rename` otherwise. */
182
265
  async move(fromUri, toUri, options) {
183
266
  const fromPath = this.resolveUri(fromUri);
184
267
  const toPath = this.resolveUri(toUri);
185
268
  this.logger?.debug("Moving resource", { fromUri, toUri });
186
269
  await fs.mkdir(path.dirname(toPath), { recursive: true });
187
270
  if (this.shouldRunGit(options?.noGit)) {
188
- git(["mv", fromPath, toPath], this.projectRoot);
271
+ await this.stager().run(["mv", fromPath, toPath]);
189
272
  } else {
190
273
  await fs.rename(fromPath, toPath);
191
274
  }
192
275
  this.logger?.info("Resource moved", { fromUri, toUri });
193
276
  }
194
- /**
195
- * Remove a file from the working tree.
196
- *
197
- * If .git/ exists and noGit is not set:
198
- * - keepFile false (default): runs `git rm` (removes from index and disk)
199
- * - keepFile true: runs `git rm --cached` (removes from index only, file stays on disk)
200
- * If no .git/ or noGit: true:
201
- * - keepFile false: runs fs.unlink
202
- * - keepFile true: no-op on filesystem
203
- *
204
- * @param storageUri - file:// URI
205
- * @param options.noGit - Skip git rm even if .git/ is present
206
- * @param options.keepFile - Remove from git index only; leave file on disk
207
- */
277
+ /** @param options.keepFile - Drop from the index only; leave the file on disk. */
208
278
  async remove(storageUri, options) {
209
279
  const filePath = this.resolveUri(storageUri);
210
280
  const keepFile = options?.keepFile ?? false;
@@ -212,7 +282,7 @@ var WorkingTreeStore = class {
212
282
  const useGit = this.shouldRunGit(options?.noGit);
213
283
  if (useGit) {
214
284
  const gitArgs = keepFile ? ["rm", "--cached", filePath] : ["rm", filePath];
215
- git(gitArgs, this.projectRoot);
285
+ await this.stager().run(gitArgs);
216
286
  this.logger?.info("Resource removed", { storageUri, keepFile, git: true });
217
287
  return;
218
288
  }
@@ -231,14 +301,6 @@ var WorkingTreeStore = class {
231
301
  throw error;
232
302
  }
233
303
  }
234
- /**
235
- * Convert a file:// URI to an absolute filesystem path.
236
- *
237
- * "file://docs/overview.md" → "{projectRoot}/docs/overview.md"
238
- *
239
- * @param storageUri - file:// URI
240
- * @returns Absolute path
241
- */
242
304
  resolveUri(storageUri) {
243
305
  if (!storageUri.startsWith("file://")) {
244
306
  throw new Error(`Invalid storage URI (must start with file://): ${storageUri}`);
@@ -274,8 +336,8 @@ function verifyChecksum(content, checksum) {
274
336
  return calculateChecksum(content) === checksum;
275
337
  }
276
338
 
277
- // src/content-extractor.ts
278
- import { decodeRepresentation } from "@semiont/core";
339
+ // src/text-extractor.ts
340
+ import { yieldsGeometryOf } from "@semiont/core";
279
341
 
280
342
  // src/pdf-extractor.ts
281
343
  import { isObject as isObject4 } from "@semiont/core";
@@ -574,11 +636,11 @@ function toRgb(image) {
574
636
  var IMAGE_RESOLVE_TIMEOUT_MS = 3e4;
575
637
  function resolveImage(page, ref) {
576
638
  const scope = ref.startsWith("g_") ? page.commonObjs : page.objs;
577
- return new Promise((resolve) => {
578
- const timer = setTimeout(() => resolve(null), IMAGE_RESOLVE_TIMEOUT_MS);
639
+ return new Promise((resolve2) => {
640
+ const timer = setTimeout(() => resolve2(null), IMAGE_RESOLVE_TIMEOUT_MS);
579
641
  const settle = (value) => {
580
642
  clearTimeout(timer);
581
- resolve(value);
643
+ resolve2(value);
582
644
  };
583
645
  try {
584
646
  scope.get(ref, settle);
@@ -824,18 +886,17 @@ function shapeTables(layer) {
824
886
  }
825
887
  var pdfExtractor = {
826
888
  // Every non-declined PDF extraction carries positioned runs — native text
827
- // layers and OCR both anchor by page geometry.
828
- yieldsGeometry: true,
889
+ // layers and OCR both anchor by page geometry. That fact is declared in core
890
+ // ('pdf-text-layer' → true) rather than here; this comment records the
891
+ // behavior the census gate holds core's answer to.
829
892
  async extract(content, _mediaType, cache) {
830
- const hit = await cache?.store.read(cache.key);
893
+ const hit = await cache.store.read(cache.key);
831
894
  if (hit) return hit;
832
895
  const outcome = await extractPdf(content);
833
- if (cache) {
834
- try {
835
- if (outcome.kind === "declined") await cache.store.write(cache.key, outcome);
836
- else if (outcome.items) await cache.store.write(cache.key, { ...outcome, items: outcome.items });
837
- } catch {
838
- }
896
+ try {
897
+ if (outcome.kind === "declined") await cache.store.write(cache.key, outcome);
898
+ else if (outcome.items) await cache.store.write(cache.key, { ...outcome, items: outcome.items });
899
+ } catch {
839
900
  }
840
901
  return outcome;
841
902
  }
@@ -890,18 +951,10 @@ async function extractPdf(content) {
890
951
  };
891
952
  }
892
953
 
893
- // src/content-extractor.ts
894
- var passthroughExtractor = {
895
- yieldsGeometry: false,
896
- async extract(content, mediaType) {
897
- return { kind: "extracted", text: decodeRepresentation(content, mediaType), method: "text-passthrough" };
898
- }
899
- };
900
- var EXTRACTORS = {
901
- "decode": passthroughExtractor,
902
- "pdf-text-layer": pdfExtractor,
903
- "none": null
904
- };
954
+ // src/text-extractor.ts
955
+ function derivingExtractorFor(mediaType) {
956
+ return yieldsGeometryOf(mediaType) ? pdfExtractor : null;
957
+ }
905
958
 
906
959
  // src/anchored-text-store.ts
907
960
  import fs2 from "fs";
@@ -1103,13 +1156,14 @@ function archivistContentReads(config) {
1103
1156
  }
1104
1157
  export {
1105
1158
  ChecksumMismatchError,
1106
- EXTRACTORS,
1107
1159
  MAX_PDF_BYTES,
1108
1160
  RepresentationMissing,
1109
1161
  WorkingTreeStore,
1110
1162
  archivistContentReads,
1111
1163
  calculateChecksum,
1112
1164
  createAnchoredTextStore,
1165
+ createStager,
1166
+ derivingExtractorFor,
1113
1167
  extractPdfTextLayer,
1114
1168
  verifyChecksum,
1115
1169
  withinByteBudget