@objectstack/metadata-fs 5.0.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.
package/dist/index.cjs ADDED
@@ -0,0 +1,622 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ FileSystemRepository: () => FileSystemRepository,
34
+ JsonlLog: () => JsonlLog
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+
38
+ // src/repository.ts
39
+ var import_promises2 = __toESM(require("fs/promises"), 1);
40
+ var import_node_fs2 = require("fs");
41
+ var import_node_path3 = __toESM(require("path"), 1);
42
+ var import_chokidar = __toESM(require("chokidar"), 1);
43
+ var import_metadata_core = require("@objectstack/metadata-core");
44
+
45
+ // src/layout.ts
46
+ var import_node_path = __toESM(require("path"), 1);
47
+ function itemPath(layout, type, name) {
48
+ return import_node_path.default.join(layout.root, type, `${name}.json`);
49
+ }
50
+ function typeDir(layout, type) {
51
+ return import_node_path.default.join(layout.root, type);
52
+ }
53
+ function logDir(layout) {
54
+ return import_node_path.default.join(layout.root, ".objectstack", ".log");
55
+ }
56
+ function logFile(layout) {
57
+ return import_node_path.default.join(logDir(layout), `main.jsonl`);
58
+ }
59
+ function parseItemPath(layout, absPath) {
60
+ const rel = import_node_path.default.relative(layout.root, absPath);
61
+ if (rel.startsWith("..") || rel.startsWith(".objectstack")) return null;
62
+ const segments = rel.split(import_node_path.default.sep);
63
+ if (segments.length !== 2) return null;
64
+ const type = segments[0];
65
+ const file = segments[1];
66
+ if (!file.endsWith(".json")) return null;
67
+ const name = file.slice(0, -".json".length);
68
+ return { type, name };
69
+ }
70
+
71
+ // src/jsonl-log.ts
72
+ var import_promises = __toESM(require("fs/promises"), 1);
73
+ var import_node_path2 = __toESM(require("path"), 1);
74
+ var import_node_readline = __toESM(require("readline"), 1);
75
+ var import_node_fs = require("fs");
76
+ var JsonlLog = class {
77
+ constructor(file) {
78
+ this.file = file;
79
+ }
80
+ async append(evt) {
81
+ await import_promises.default.mkdir(import_node_path2.default.dirname(this.file), { recursive: true });
82
+ await import_promises.default.appendFile(this.file, JSON.stringify(evt) + "\n", "utf8");
83
+ }
84
+ /** Read all events in seq order (i.e. file order). */
85
+ async *readAll() {
86
+ if (!(0, import_node_fs.existsSync)(this.file)) return;
87
+ const rl = import_node_readline.default.createInterface({
88
+ input: (0, import_node_fs.createReadStream)(this.file, { encoding: "utf8" }),
89
+ crlfDelay: Infinity
90
+ });
91
+ try {
92
+ for await (const line of rl) {
93
+ if (!line.trim()) continue;
94
+ try {
95
+ yield JSON.parse(line);
96
+ } catch {
97
+ }
98
+ }
99
+ } finally {
100
+ rl.close();
101
+ }
102
+ }
103
+ /** Return the highest seq number in the log, or 0 if empty. */
104
+ async highestSeq() {
105
+ let max = 0;
106
+ for await (const evt of this.readAll()) {
107
+ if (typeof evt.seq === "number" && evt.seq > max) max = evt.seq;
108
+ }
109
+ return max;
110
+ }
111
+ };
112
+
113
+ // src/sync.ts
114
+ var KeyedMutex = class {
115
+ constructor() {
116
+ this.tails = /* @__PURE__ */ new Map();
117
+ }
118
+ async run(key, fn) {
119
+ const prev = this.tails.get(key) ?? Promise.resolve();
120
+ const next = prev.then(fn, fn);
121
+ const swallowed = next.catch(() => void 0);
122
+ this.tails.set(key, swallowed);
123
+ try {
124
+ return await next;
125
+ } finally {
126
+ if (this.tails.get(key) === swallowed) {
127
+ this.tails.delete(key);
128
+ }
129
+ }
130
+ }
131
+ };
132
+ function createBroker(matches) {
133
+ const subs = /* @__PURE__ */ new Set();
134
+ return {
135
+ subscribe: (s) => {
136
+ subs.add(s);
137
+ },
138
+ unsubscribe: (s) => {
139
+ subs.delete(s);
140
+ },
141
+ publish: (evt) => {
142
+ for (const s of subs) {
143
+ if (s.closed) continue;
144
+ if (!matches(evt, s.filter)) continue;
145
+ s.push(evt);
146
+ }
147
+ }
148
+ };
149
+ }
150
+
151
+ // src/watch-iterable.ts
152
+ function createWatchIterable(args) {
153
+ const queue = [];
154
+ let waiter = null;
155
+ let closed = false;
156
+ const delivered = /* @__PURE__ */ new Set();
157
+ const evtKey = (e) => `${args.branchKeyOf(e)}#${e.seq}`;
158
+ const subscriber = {
159
+ filter: args.filter,
160
+ closed: false,
161
+ push: (evt) => {
162
+ if (subscriber.closed) return;
163
+ const k = evtKey(evt);
164
+ if (delivered.has(k)) return;
165
+ if (waiter) {
166
+ delivered.add(k);
167
+ const w = waiter;
168
+ waiter = null;
169
+ w({ value: clone(evt), done: false });
170
+ } else {
171
+ queue.push(evt);
172
+ }
173
+ }
174
+ };
175
+ args.broker.subscribe(subscriber);
176
+ const replay = [...args.replay].sort((a, b) => a.seq - b.seq);
177
+ let replayIdx = 0;
178
+ const drain = () => {
179
+ while (replayIdx < replay.length) {
180
+ const evt = replay[replayIdx++];
181
+ if (typeof args.since === "number" && evt.seq <= args.since) continue;
182
+ const k = evtKey(evt);
183
+ if (delivered.has(k)) continue;
184
+ delivered.add(k);
185
+ return { value: clone(evt), done: false };
186
+ }
187
+ while (queue.length > 0) {
188
+ const evt = queue.shift();
189
+ const k = evtKey(evt);
190
+ if (delivered.has(k)) continue;
191
+ delivered.add(k);
192
+ return { value: clone(evt), done: false };
193
+ }
194
+ return null;
195
+ };
196
+ const close = () => {
197
+ if (!closed) {
198
+ closed = true;
199
+ subscriber.closed = true;
200
+ args.broker.unsubscribe(subscriber);
201
+ if (waiter) {
202
+ const w = waiter;
203
+ waiter = null;
204
+ w({ value: void 0, done: true });
205
+ }
206
+ }
207
+ return { value: void 0, done: true };
208
+ };
209
+ const iterator = {
210
+ next: () => {
211
+ if (closed) return Promise.resolve({ value: void 0, done: true });
212
+ const immediate = drain();
213
+ if (immediate) return Promise.resolve(immediate);
214
+ return new Promise((resolve) => {
215
+ waiter = resolve;
216
+ });
217
+ },
218
+ return: () => Promise.resolve(close()),
219
+ throw: (err) => {
220
+ close();
221
+ return Promise.reject(err);
222
+ }
223
+ };
224
+ return { [Symbol.asyncIterator]: () => iterator };
225
+ }
226
+ function clone(value) {
227
+ return JSON.parse(JSON.stringify(value));
228
+ }
229
+
230
+ // src/repository.ts
231
+ var matchRefFilter = (ref, filter) => {
232
+ if (filter.org && filter.org !== ref.org) return false;
233
+ if (filter.type && filter.type !== ref.type) return false;
234
+ if (filter.name && filter.name !== ref.name) return false;
235
+ return true;
236
+ };
237
+ var matchEvent = (evt, filter) => matchRefFilter(evt.ref, filter);
238
+ var FileSystemRepository = class {
239
+ constructor(opts) {
240
+ this.mutex = new KeyedMutex();
241
+ this.broker = createBroker(matchEvent);
242
+ /** In-memory index: refKey → current hash (HEAD). */
243
+ this.heads = /* @__PURE__ */ new Map();
244
+ /** Next seq counter, hydrated from the log on `start()`. */
245
+ this.nextSeq = 1;
246
+ /** Paths we wrote ourselves; suppress the resulting chokidar event. */
247
+ this.selfWrites = /* @__PURE__ */ new Set();
248
+ this.watcher = null;
249
+ this.started = false;
250
+ this.org = opts.org;
251
+ this.fsActor = opts.fsActor ?? "fs";
252
+ this.disableWatch = opts.disableWatch ?? false;
253
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
254
+ this.layout = { root: import_node_path3.default.resolve(opts.root) };
255
+ this.log = new JsonlLog(logFile(this.layout));
256
+ }
257
+ // ── Lifecycle ───────────────────────────────────────────────────────
258
+ async start() {
259
+ if (this.started) return;
260
+ this.started = true;
261
+ await import_promises2.default.mkdir(this.layout.root, { recursive: true });
262
+ await import_promises2.default.mkdir(logDir(this.layout), { recursive: true });
263
+ await this.scanHeads();
264
+ const highest = await this.log.highestSeq();
265
+ this.nextSeq = highest + 1;
266
+ if (!this.disableWatch) this.startWatcher();
267
+ }
268
+ async close() {
269
+ if (this.watcher) {
270
+ await this.watcher.close();
271
+ this.watcher = null;
272
+ }
273
+ this.started = false;
274
+ }
275
+ // ── Read API ────────────────────────────────────────────────────────
276
+ async get(ref) {
277
+ this.assertScope(ref);
278
+ const file = itemPath(this.layout, ref.type, ref.name);
279
+ if (!(0, import_node_fs2.existsSync)(file)) return null;
280
+ const body = await readJson(file);
281
+ if (!body) return null;
282
+ const hash = (0, import_metadata_core.hashSpec)(body);
283
+ if (ref.version && ref.version !== hash) return null;
284
+ const meta = await this.findMetaForHash(ref, hash);
285
+ return {
286
+ ref: { ...ref, version: void 0 },
287
+ body,
288
+ hash,
289
+ parentHash: meta?.parentHash ?? null,
290
+ authoredBy: meta?.actor ?? this.fsActor,
291
+ authoredAt: meta?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
292
+ message: meta?.message,
293
+ seq: meta?.seq ?? 0
294
+ };
295
+ }
296
+ async *list(filter) {
297
+ const limit = filter.limit ?? Infinity;
298
+ let yielded = 0;
299
+ for (const [key, hash] of this.heads) {
300
+ const ref = parseRefKey(key);
301
+ if (!ref) continue;
302
+ if (!matchRefFilter(ref, filter)) continue;
303
+ if (filter.nameContains && !ref.name.includes(filter.nameContains)) continue;
304
+ const meta = await this.findMetaForHash(ref, hash);
305
+ const header = {
306
+ ref: { ...ref, version: void 0 },
307
+ hash,
308
+ parentHash: meta?.parentHash ?? null,
309
+ authoredBy: meta?.actor ?? this.fsActor,
310
+ authoredAt: meta?.ts ?? (/* @__PURE__ */ new Date(0)).toISOString(),
311
+ message: meta?.message,
312
+ seq: meta?.seq ?? 0
313
+ };
314
+ yield header;
315
+ if (++yielded >= limit) return;
316
+ }
317
+ }
318
+ async *history(ref, opts = {}) {
319
+ this.assertScope(ref);
320
+ const since = opts.sinceSeq ?? -1;
321
+ const limit = opts.limit ?? Infinity;
322
+ let yielded = 0;
323
+ for await (const evt of this.log.readAll()) {
324
+ if (evt.seq <= since) continue;
325
+ if (evt.ref.type !== ref.type || evt.ref.name !== ref.name) continue;
326
+ if (evt.ref.org !== ref.org) continue;
327
+ yield evt;
328
+ if (++yielded >= limit) return;
329
+ }
330
+ }
331
+ watch(filter, since) {
332
+ const replay = [];
333
+ const promise = (async () => {
334
+ for await (const evt of this.log.readAll()) {
335
+ if (matchEvent(evt, filter)) replay.push(evt);
336
+ }
337
+ })();
338
+ return deferredIterable(promise.then(
339
+ () => createWatchIterable({
340
+ filter,
341
+ since,
342
+ replay,
343
+ broker: this.broker,
344
+ matches: matchEvent,
345
+ branchKeyOf: (e) => e.ref.org
346
+ })
347
+ ));
348
+ }
349
+ // ── Write API ───────────────────────────────────────────────────────
350
+ put(ref, spec, opts) {
351
+ this.assertScope(ref);
352
+ return this.mutex.run((0, import_metadata_core.refKey)(ref), async () => {
353
+ const key = (0, import_metadata_core.refKey)(ref);
354
+ const currentHead = this.heads.get(key) ?? null;
355
+ if ((opts.parentVersion ?? null) !== currentHead) {
356
+ throw new import_metadata_core.ConflictError(ref, opts.parentVersion ?? null, currentHead);
357
+ }
358
+ const hash = (0, import_metadata_core.hashSpec)(spec);
359
+ if (currentHead === hash) {
360
+ const meta = await this.findMetaForHash(ref, hash);
361
+ return {
362
+ version: hash,
363
+ seq: meta?.seq ?? 0,
364
+ item: {
365
+ ref: { ...ref, version: void 0 },
366
+ body: spec,
367
+ hash,
368
+ parentHash: meta?.parentHash ?? null,
369
+ authoredBy: meta?.actor ?? this.fsActor,
370
+ authoredAt: meta?.ts ?? this.now().toISOString(),
371
+ message: meta?.message,
372
+ seq: meta?.seq ?? 0
373
+ }
374
+ };
375
+ }
376
+ const seq = this.nextSeq++;
377
+ const ts = this.now().toISOString();
378
+ const file = itemPath(this.layout, ref.type, ref.name);
379
+ await import_promises2.default.mkdir(typeDir(this.layout, ref.type), { recursive: true });
380
+ this.selfWrites.add(file);
381
+ try {
382
+ await writeJsonAtomic(file, spec);
383
+ } finally {
384
+ setTimeout(() => this.selfWrites.delete(file), 200);
385
+ }
386
+ this.heads.set(key, hash);
387
+ const evt = {
388
+ seq,
389
+ op: currentHead ? "update" : "create",
390
+ ref: { ...ref, version: void 0 },
391
+ hash,
392
+ parentHash: currentHead,
393
+ actor: opts.actor,
394
+ message: opts.message,
395
+ ts,
396
+ source: opts.source ?? "fs"
397
+ };
398
+ await this.log.append(evt);
399
+ this.broker.publish(evt);
400
+ return {
401
+ version: hash,
402
+ seq,
403
+ item: {
404
+ ref: { ...ref, version: void 0 },
405
+ body: spec,
406
+ hash,
407
+ parentHash: currentHead,
408
+ authoredBy: opts.actor,
409
+ authoredAt: ts,
410
+ message: opts.message,
411
+ seq
412
+ }
413
+ };
414
+ });
415
+ }
416
+ delete(ref, opts) {
417
+ this.assertScope(ref);
418
+ return this.mutex.run((0, import_metadata_core.refKey)(ref), async () => {
419
+ const key = (0, import_metadata_core.refKey)(ref);
420
+ const currentHead = this.heads.get(key) ?? null;
421
+ if (currentHead !== opts.parentVersion) {
422
+ throw new import_metadata_core.ConflictError(ref, opts.parentVersion, currentHead);
423
+ }
424
+ const file = itemPath(this.layout, ref.type, ref.name);
425
+ this.selfWrites.add(file);
426
+ try {
427
+ if ((0, import_node_fs2.existsSync)(file)) await import_promises2.default.unlink(file);
428
+ } finally {
429
+ setTimeout(() => this.selfWrites.delete(file), 200);
430
+ }
431
+ this.heads.delete(key);
432
+ const seq = this.nextSeq++;
433
+ const ts = this.now().toISOString();
434
+ const evt = {
435
+ seq,
436
+ op: "delete",
437
+ ref: { ...ref, version: void 0 },
438
+ hash: null,
439
+ parentHash: currentHead,
440
+ actor: opts.actor,
441
+ message: opts.message,
442
+ ts,
443
+ source: opts.source ?? "fs"
444
+ };
445
+ await this.log.append(evt);
446
+ this.broker.publish(evt);
447
+ return { seq };
448
+ });
449
+ }
450
+ // ── Internals ───────────────────────────────────────────────────────
451
+ assertScope(ref) {
452
+ if (ref.org !== this.org) {
453
+ throw new Error(
454
+ `FileSystemRepository scope mismatch: expected org=${this.org}, got org=${ref.org}`
455
+ );
456
+ }
457
+ }
458
+ async scanHeads() {
459
+ this.heads.clear();
460
+ let entries = [];
461
+ try {
462
+ entries = await import_promises2.default.readdir(this.layout.root, { withFileTypes: true });
463
+ } catch {
464
+ return;
465
+ }
466
+ for (const entry of entries) {
467
+ if (!entry.isDirectory()) continue;
468
+ if (entry.name.startsWith(".")) continue;
469
+ const type = entry.name;
470
+ const dir = import_node_path3.default.join(this.layout.root, type);
471
+ let files = [];
472
+ try {
473
+ files = await import_promises2.default.readdir(dir);
474
+ } catch {
475
+ continue;
476
+ }
477
+ for (const file of files) {
478
+ if (!file.endsWith(".json")) continue;
479
+ const name = file.slice(0, -".json".length);
480
+ const ref = {
481
+ org: this.org,
482
+ type,
483
+ name
484
+ };
485
+ const body = await readJson(import_node_path3.default.join(dir, file));
486
+ if (!body) continue;
487
+ this.heads.set((0, import_metadata_core.refKey)(ref), (0, import_metadata_core.hashSpec)(body));
488
+ }
489
+ }
490
+ }
491
+ async findMetaForHash(ref, hash) {
492
+ let last = null;
493
+ for await (const evt of this.log.readAll()) {
494
+ if (evt.ref.type !== ref.type || evt.ref.name !== ref.name) continue;
495
+ if (evt.ref.org !== ref.org) continue;
496
+ if (evt.hash === hash) last = evt;
497
+ }
498
+ return last;
499
+ }
500
+ startWatcher() {
501
+ const w = import_chokidar.default.watch(this.layout.root, {
502
+ ignored: [/(^|[\\/])\../],
503
+ // skip dotfiles incl. .objectstack
504
+ ignoreInitial: true,
505
+ depth: 2,
506
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 20 },
507
+ // Use polling to avoid `fs.watch` EMFILE on macOS / busy dev hosts.
508
+ // The depth-2 recursion would otherwise wire native watches across
509
+ // the entire customization tree.
510
+ usePolling: true,
511
+ interval: 1e3,
512
+ binaryInterval: 2e3
513
+ });
514
+ w.on("add", (p) => void this.handleFsChange(p, "add"));
515
+ w.on("change", (p) => void this.handleFsChange(p, "change"));
516
+ w.on("unlink", (p) => void this.handleFsChange(p, "unlink"));
517
+ this.watcher = w;
518
+ }
519
+ async handleFsChange(absPath, kind) {
520
+ if (this.selfWrites.has(absPath)) return;
521
+ const parsed = parseItemPath(this.layout, absPath);
522
+ if (!parsed) return;
523
+ const ref = {
524
+ org: this.org,
525
+ type: parsed.type,
526
+ name: parsed.name
527
+ };
528
+ const key = (0, import_metadata_core.refKey)(ref);
529
+ await this.mutex.run(key, async () => {
530
+ if (kind === "unlink") {
531
+ const currentHead2 = this.heads.get(key) ?? null;
532
+ if (!currentHead2) return;
533
+ this.heads.delete(key);
534
+ const seq2 = this.nextSeq++;
535
+ const evt2 = {
536
+ seq: seq2,
537
+ op: "delete",
538
+ ref: { ...ref, version: void 0 },
539
+ hash: null,
540
+ parentHash: currentHead2,
541
+ actor: this.fsActor,
542
+ ts: this.now().toISOString(),
543
+ source: "fs"
544
+ };
545
+ await this.log.append(evt2);
546
+ this.broker.publish(evt2);
547
+ return;
548
+ }
549
+ const body = await readJson(absPath);
550
+ if (!body) return;
551
+ const hash = (0, import_metadata_core.hashSpec)(body);
552
+ const currentHead = this.heads.get(key) ?? null;
553
+ if (currentHead === hash) return;
554
+ this.heads.set(key, hash);
555
+ const seq = this.nextSeq++;
556
+ const evt = {
557
+ seq,
558
+ op: currentHead ? "update" : "create",
559
+ ref: { ...ref, version: void 0 },
560
+ hash,
561
+ parentHash: currentHead,
562
+ actor: this.fsActor,
563
+ ts: this.now().toISOString(),
564
+ source: "fs"
565
+ };
566
+ await this.log.append(evt);
567
+ this.broker.publish(evt);
568
+ });
569
+ }
570
+ };
571
+ async function readJson(file) {
572
+ try {
573
+ const text = await import_promises2.default.readFile(file, "utf8");
574
+ return JSON.parse(text);
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+ async function writeJsonAtomic(file, body) {
580
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
581
+ await import_promises2.default.writeFile(tmp, JSON.stringify(body, null, 2) + "\n", "utf8");
582
+ await import_promises2.default.rename(tmp, file);
583
+ }
584
+ function parseRefKey(key) {
585
+ const parts = key.split("/");
586
+ if (parts.length !== 3) return null;
587
+ return {
588
+ org: parts[0],
589
+ type: parts[1],
590
+ name: parts[2]
591
+ };
592
+ }
593
+ function deferredIterable(promise) {
594
+ return {
595
+ [Symbol.asyncIterator]() {
596
+ let inner = null;
597
+ return {
598
+ async next() {
599
+ if (!inner) {
600
+ const iterable = await promise;
601
+ inner = iterable[Symbol.asyncIterator]();
602
+ }
603
+ return inner.next();
604
+ },
605
+ async return(value) {
606
+ if (!inner) {
607
+ const iterable = await promise;
608
+ inner = iterable[Symbol.asyncIterator]();
609
+ }
610
+ if (inner.return) return inner.return(value);
611
+ return { value: void 0, done: true };
612
+ }
613
+ };
614
+ }
615
+ };
616
+ }
617
+ // Annotate the CommonJS export names for ESM import in node:
618
+ 0 && (module.exports = {
619
+ FileSystemRepository,
620
+ JsonlLog
621
+ });
622
+ //# sourceMappingURL=index.cjs.map