@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.d.ts +134 -151
- package/dist/index.js +168 -114
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
169
|
+
* Write bytes to the path storageUri names, whole or streamed.
|
|
47
170
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
278
|
-
import {
|
|
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((
|
|
578
|
-
const timer = setTimeout(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
893
|
+
const hit = await cache.store.read(cache.key);
|
|
831
894
|
if (hit) return hit;
|
|
832
895
|
const outcome = await extractPdf(content);
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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/
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|