@smallpen/local-package 0.1.0-alpha.2 → 0.1.0-alpha.5

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.
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { watch } from "node:fs";
3
+ import { lstat } from "node:fs/promises";
3
4
  import { basename, dirname, join, resolve } from "node:path";
4
5
 
5
6
  import {
6
7
  canonicalJSON,
8
+ loadPackageFromValues,
7
9
  SmallPenError,
8
10
  SMALLPEN_RUNTIME_CAPABILITIES,
9
11
  } from "@smallpen/core";
@@ -19,6 +21,63 @@ import {
19
21
  } from "./local-package.mjs";
20
22
 
21
23
  const MAX_CHANGE_HISTORY = 200;
24
+ const DEFAULT_TOKEN_ENTRY = "tokens/tokens.json";
25
+ // File systems with coarse timestamps (HFS+, FAT) can hide a same-size edit
26
+ // made in the same timestamp granule as the fingerprint. A fingerprint that
27
+ // contains a timestamp this recent is never trusted to skip a reload.
28
+ const FINGERPRINT_SETTLE_MS = 2000;
29
+
30
+ // A cheap stat-only fingerprint of the Package directory, its manifest, its
31
+ // entries, its blob directory, and its blobs. Commits replace the whole
32
+ // directory, and external edits change a file's size or timestamps, so an
33
+ // unchanged fingerprint means a full reload would read the same content.
34
+ async function packageFingerprint(packagePath, entries) {
35
+ const parts = [];
36
+ let newest = 0n;
37
+ for (const path of [
38
+ packagePath,
39
+ join(packagePath, "manifest.json"),
40
+ join(packagePath, "blobs"),
41
+ ...entries.map((entry) => join(packagePath, entry)),
42
+ ]) {
43
+ try {
44
+ const info = await lstat(path, { bigint: true });
45
+ parts.push(`${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`);
46
+ for (const time of [info.mtimeMs, info.ctimeMs]) {
47
+ if (time > newest) newest = time;
48
+ }
49
+ } catch (error) {
50
+ if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") return undefined;
51
+ parts.push("-");
52
+ }
53
+ }
54
+ if (Date.now() - Number(newest) < FINGERPRINT_SETTLE_MS) return undefined;
55
+ return parts.join("|");
56
+ }
57
+
58
+ // Blobs are listed too: an in-place blob edit changes no entry or directory.
59
+ function diskEntries(snapshot) {
60
+ return [
61
+ ...Object.values(snapshot.manifest.entries).flat(),
62
+ ...[...snapshot.blobs.keys()].sort(),
63
+ ];
64
+ }
65
+
66
+ // Projects an in-memory default Token library into a Package that has none,
67
+ // so opening never writes. The first commit made against the projected
68
+ // revision persists the library together with the edit.
69
+ async function projectDefaultTokenLibrary(snapshot, library) {
70
+ if (!library || snapshot.manifest.entries.tokens.length > 0) return snapshot;
71
+ const manifest = structuredClone(snapshot.manifest);
72
+ manifest.entries.tokens = [DEFAULT_TOKEN_ENTRY];
73
+ const values = new Map([["manifest.json", manifest]]);
74
+ for (const [entry, value] of Object.entries(snapshot.entries)) {
75
+ values.set(entry, structuredClone(value));
76
+ }
77
+ values.set(DEFAULT_TOKEN_ENTRY, structuredClone(library));
78
+ for (const [blob, bytes] of snapshot.blobs) values.set(blob, bytes);
79
+ return loadPackageFromValues(snapshot.locator, values);
80
+ }
22
81
 
23
82
  function isRecord(value) {
24
83
  return value !== null && typeof value === "object" && !Array.isArray(value);
@@ -103,9 +162,13 @@ export class LocalPackageBackend {
103
162
  #changeSequence = 0;
104
163
  #confirmedBatches = new Map();
105
164
  #commitQueue = Promise.resolve();
165
+ #defaultTokenLibrary;
166
+ #diskEntries = [];
167
+ #fingerprint;
106
168
  #listeners = new Set();
107
169
  #packagePath;
108
170
  #pollTimer;
171
+ #projection;
109
172
  #refreshPromise;
110
173
  #redoStack = [];
111
174
  #timer;
@@ -116,6 +179,15 @@ export class LocalPackageBackend {
116
179
  snapshot;
117
180
  status = { readOnly: true, state: "closed" };
118
181
 
182
+ // `defaultTokenLibrary`: when set, a Package without Token entries is
183
+ // served with this library projected in memory (see
184
+ // projectDefaultTokenLibrary); nothing is written until the first commit.
185
+ constructor({ defaultTokenLibrary } = {}) {
186
+ this.#defaultTokenLibrary = defaultTokenLibrary
187
+ ? structuredClone(defaultTokenLibrary)
188
+ : undefined;
189
+ }
190
+
119
191
  capabilities() {
120
192
  return structuredClone(SMALLPEN_RUNTIME_CAPABILITIES);
121
193
  }
@@ -127,13 +199,51 @@ export class LocalPackageBackend {
127
199
  this.#undoStack = [];
128
200
  this.#changeSequence = 0;
129
201
  this.changes = [];
130
- this.snapshot = await openPackage(resolve(locator));
131
- this.#packagePath = this.snapshot.locator;
202
+ const disk = await openPackage(resolve(locator));
203
+ this.#packagePath = disk.locator;
204
+ this.snapshot = await this.#project(disk);
205
+ this.#fingerprint = undefined;
132
206
  this.#markReady();
133
207
  this.#startWatchers();
134
208
  return this.snapshot;
135
209
  }
136
210
 
211
+ async #project(disk) {
212
+ const snapshot = await projectDefaultTokenLibrary(
213
+ disk,
214
+ this.#defaultTokenLibrary,
215
+ );
216
+ this.#projection =
217
+ snapshot === disk
218
+ ? undefined
219
+ : { diskRevision: disk.revision };
220
+ this.#diskEntries = diskEntries(disk);
221
+ return snapshot;
222
+ }
223
+
224
+ // A batch prepared against the projected revision is rebased onto the
225
+ // Package on disk with the projected default Token library prepended, so
226
+ // the commit persists exactly the state the client edited.
227
+ #diskBatch(batch) {
228
+ if (!this.#projection || batch?.baseRevision !== this.snapshot?.revision) {
229
+ return batch;
230
+ }
231
+ return {
232
+ ...batch,
233
+ baseRevision: this.#projection.diskRevision,
234
+ operations: Array.isArray(batch.operations)
235
+ ? [
236
+ {
237
+ entry: DEFAULT_TOKEN_ENTRY,
238
+ library: structuredClone(this.#defaultTokenLibrary),
239
+ type: "replace-token-library",
240
+ },
241
+ ...batch.operations,
242
+ ]
243
+ : batch.operations,
244
+ };
245
+ }
246
+
137
247
  commit(batch, options = {}) {
138
248
  return this.#commit(batch, options.historyMode ?? "normal");
139
249
  }
@@ -187,34 +297,47 @@ export class LocalPackageBackend {
187
297
  }
188
298
 
189
299
  importMedia(media) {
190
- return this.#write(async () => {
191
- const imported = await importPackageMedia(this.#packagePath, media);
300
+ return this.#write(async (options) => {
301
+ const imported = await importPackageMedia(this.#packagePath, media, options);
192
302
  return { result: imported.result, value: imported };
193
303
  });
194
304
  }
195
305
 
196
306
  importFontVariant(font) {
197
- return this.#write(async () => {
198
- const imported = await importPackageFontVariant(this.#packagePath, font);
307
+ return this.#write(async (options) => {
308
+ const imported = await importPackageFontVariant(
309
+ this.#packagePath,
310
+ font,
311
+ options,
312
+ );
199
313
  return { result: imported.result, value: imported };
200
314
  });
201
315
  }
202
316
 
203
317
  updateFontFamily(fontId, family) {
204
- return this.#write(async () => ({
205
- result: await updatePackageFontFamily(this.#packagePath, fontId, family),
318
+ return this.#write(async (options) => ({
319
+ result: await updatePackageFontFamily(
320
+ this.#packagePath,
321
+ fontId,
322
+ family,
323
+ options,
324
+ ),
206
325
  }));
207
326
  }
208
327
 
209
328
  deleteFontFamily(fontId) {
210
- return this.#write(async () => ({
211
- result: await deletePackageFontFamily(this.#packagePath, fontId),
329
+ return this.#write(async (options) => ({
330
+ result: await deletePackageFontFamily(this.#packagePath, fontId, options),
212
331
  }));
213
332
  }
214
333
 
215
334
  deleteFontVariant(fontVariantId) {
216
- return this.#write(async () => ({
217
- result: await deletePackageFontVariant(this.#packagePath, fontVariantId),
335
+ return this.#write(async (options) => ({
336
+ result: await deletePackageFontVariant(
337
+ this.#packagePath,
338
+ fontVariantId,
339
+ options,
340
+ ),
218
341
  }));
219
342
  }
220
343
 
@@ -236,7 +359,22 @@ export class LocalPackageBackend {
236
359
  async #performRefresh() {
237
360
  if (!this.#packagePath) return undefined;
238
361
  try {
239
- const next = await openPackage(this.#packagePath);
362
+ // Stat before reading: a change that lands during the read alters the
363
+ // next fingerprint, so it is never hidden behind this one.
364
+ const entries = this.#diskEntries;
365
+ const fingerprint = await packageFingerprint(this.#packagePath, entries);
366
+ if (
367
+ fingerprint !== undefined &&
368
+ fingerprint === this.#fingerprint &&
369
+ this.status.state === "ready"
370
+ ) {
371
+ return this.snapshot;
372
+ }
373
+ const next = await this.#project(await openPackage(this.#packagePath));
374
+ this.#fingerprint =
375
+ canonicalJSON(entries) === canonicalJSON(this.#diskEntries)
376
+ ? fingerprint
377
+ : undefined;
240
378
  const recovered = this.status.state === "repair";
241
379
  if (next.revision === this.snapshot?.revision) {
242
380
  this.#markReady();
@@ -248,11 +386,22 @@ export class LocalPackageBackend {
248
386
  }
249
387
  const before = this.snapshot;
250
388
  const change = externalChange(before, next);
389
+ // Every local inverse batch targets a revision that no longer exists,
390
+ // so Undo and Redo could only fail with stale_revision from here on.
391
+ const historyCleared =
392
+ this.#undoStack.length > 0 || this.#redoStack.length > 0;
393
+ this.#redoStack = [];
394
+ this.#undoStack = [];
251
395
  this.snapshot = next;
252
396
  this.#markReady();
253
397
  this.#recordChange(change);
254
398
  this.#startWatchers();
255
- this.#emit({ change, snapshot: next, type: "external-revision" });
399
+ this.#emit({
400
+ change,
401
+ historyCleared,
402
+ snapshot: next,
403
+ type: "external-revision",
404
+ });
256
405
  return next;
257
406
  } catch (error) {
258
407
  const alreadyRepair = this.status.state === "repair";
@@ -268,6 +417,7 @@ export class LocalPackageBackend {
268
417
  if (!alreadyRepair) {
269
418
  this.#emit({ error, snapshot: this.snapshot, status: this.status, type: "invalid-external-state" });
270
419
  }
420
+ this.#fingerprint = undefined;
271
421
  return undefined;
272
422
  }
273
423
  }
@@ -282,6 +432,9 @@ export class LocalPackageBackend {
282
432
  this.#timer = undefined;
283
433
  this.#closeWatchers();
284
434
  this.#packagePath = undefined;
435
+ this.#diskEntries = [];
436
+ this.#fingerprint = undefined;
437
+ this.#projection = undefined;
285
438
  this.snapshot = undefined;
286
439
  this.status = { readOnly: true, state: "closed" };
287
440
  this.#confirmedBatches.clear();
@@ -312,11 +465,15 @@ export class LocalPackageBackend {
312
465
  this.#startWatchers();
313
466
  return { ...confirmed.result, duplicate: true };
314
467
  }
315
- const result = await applyOperationBatch(this.#packagePath, batch);
316
- this.snapshot = await openPackage(this.#packagePath);
317
- this.#markReady();
318
- this.#startWatchers();
468
+ const result = await this.#applyCommitted((options) =>
469
+ applyOperationBatch(this.#packagePath, this.#diskBatch(batch), options),
470
+ );
319
471
  this.#confirmedBatches.set(batch.batchId, { identity, result });
472
+ if (result.alreadyApplied) {
473
+ // Replayed from the persistent ledger: nothing was written now, so
474
+ // its (possibly stale) inverse must not enter local history.
475
+ return result;
476
+ }
320
477
  if (historyMode === "normal") {
321
478
  this.#undoStack.push(result.inverseBatch);
322
479
  this.#redoStack = [];
@@ -352,10 +509,13 @@ export class LocalPackageBackend {
352
509
  this.#assertWritable();
353
510
  this.#closeWatchers();
354
511
  try {
355
- const { result, value } = await operation();
356
- this.snapshot = await openPackage(this.#packagePath);
357
- this.#markReady();
358
- this.#startWatchers();
512
+ let value;
513
+ const result = await this.#applyCommitted(async (options) => {
514
+ const written = await operation(options);
515
+ value = written.value;
516
+ return written.result;
517
+ });
518
+ if (result.alreadyApplied) return value ?? result;
359
519
  this.#undoStack.push(result.inverseBatch);
360
520
  this.#redoStack = [];
361
521
  this.#recordChange({
@@ -379,6 +539,32 @@ export class LocalPackageBackend {
379
539
  return pending;
380
540
  }
381
541
 
542
+ // Runs one write and adopts the Package state read under its write lock.
543
+ // Reopening after the lock is released could absorb an external revision
544
+ // that landed in between without reporting it; instead the fingerprint is
545
+ // dropped so the next refresh compares the disk against this snapshot and
546
+ // emits an external-revision event for anything newer.
547
+ async #applyCommitted(write) {
548
+ let committed;
549
+ const result = await write({
550
+ onCommitted: (snapshot) => {
551
+ committed = snapshot;
552
+ },
553
+ });
554
+ this.snapshot = await this.#project(
555
+ committed ?? (await openPackage(this.#packagePath)),
556
+ );
557
+ this.#fingerprint = undefined;
558
+ this.#markReady();
559
+ this.#startWatchers();
560
+ this.#scheduleRefresh();
561
+ // Clients use the reported revision as their next base revision; while a
562
+ // default Token library is projected that is the projected revision.
563
+ return this.#projection
564
+ ? { ...result, revision: this.snapshot.revision }
565
+ : result;
566
+ }
567
+
382
568
  #assertWritable() {
383
569
  if (!this.#packagePath) {
384
570
  throw new SmallPenError("package_not_open", "Local Package Backend is not open");
@@ -406,9 +592,14 @@ export class LocalPackageBackend {
406
592
  if (this.changes.length > MAX_CHANGE_HISTORY) this.changes.shift();
407
593
  }
408
594
 
595
+ // A pending refresh is never pushed back: a steady stream of external
596
+ // writes (or watcher events) must not postpone it indefinitely.
409
597
  #scheduleRefresh() {
410
- clearTimeout(this.#timer);
411
- this.#timer = setTimeout(() => void this.refresh(), 120);
598
+ if (this.#timer) return;
599
+ this.#timer = setTimeout(() => {
600
+ this.#timer = undefined;
601
+ void this.refresh();
602
+ }, 120);
412
603
  }
413
604
 
414
605
  #startWatchers() {