@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.
@@ -3,7 +3,15 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { SmallPenError } from "@smallpen/core";
4
4
 
5
5
  import { LocalPackageBackend } from "./local-package-backend.mjs";
6
- import { resolveWorkspace } from "./workspace.mjs";
6
+ import {
7
+ localLibraryLocator,
8
+ MAX_LIBRARY_DECLARATIONS,
9
+ resolveWorkspace,
10
+ } from "./workspace.mjs";
11
+
12
+ // A Package in Repair because a linked Foundation or Library is unavailable
13
+ // gets no event when that link is fixed, so its status is resolved again.
14
+ const LINK_REPAIR_RETRY_MS = 1000;
7
15
 
8
16
  function collectStableIds(value, result) {
9
17
  if (Array.isArray(value)) {
@@ -49,22 +57,6 @@ function defaultTokenLibrary() {
49
57
  };
50
58
  }
51
59
 
52
- async function seedDefaultTheme(backend, snapshot) {
53
- if (snapshot.manifest.entries.tokens.length > 0) return snapshot;
54
- await backend.commit({
55
- baseRevision: snapshot.revision,
56
- batchId: `seed-default-theme-${snapshot.revision.slice(0, 12)}`,
57
- operations: [
58
- {
59
- entry: "tokens/tokens.json",
60
- library: defaultTokenLibrary(),
61
- type: "replace-token-library",
62
- },
63
- ],
64
- });
65
- return backend.snapshot;
66
- }
67
-
68
60
  export function reconcilePackageViewState(snapshot, value = {}) {
69
61
  const firstScreenEntry = snapshot.manifest.entries.screens[0];
70
62
  const defaultScreen =
@@ -94,13 +86,21 @@ export function reconcilePackageViewState(snapshot, value = {}) {
94
86
  }
95
87
 
96
88
  export class LocalWorkspaceSession {
89
+ #fetchImpl;
97
90
  #libraryCacheRoot;
98
91
  #listeners = new Set();
92
+ // Opens still running: requests, dependency events and Repair retries.
93
+ #opening = new Set();
99
94
  #packages = new Map();
95
+ // Remote Library snapshots verified by this session, keyed by manifest URL.
96
+ // Status updates after commits reuse them so a slow or unreachable remote
97
+ // never blocks a local write; open() and refresh() read them again.
98
+ #remoteLibraries = new Map();
100
99
 
101
100
  activeLocator;
102
101
 
103
- constructor({ libraryCacheRoot } = {}) {
102
+ constructor({ fetchImpl, libraryCacheRoot } = {}) {
103
+ this.#fetchImpl = fetchImpl;
104
104
  this.#libraryCacheRoot = libraryCacheRoot;
105
105
  }
106
106
 
@@ -120,40 +120,67 @@ export class LocalWorkspaceSession {
120
120
  }));
121
121
  }
122
122
 
123
- async open(locator, viewState = {}) {
123
+ open(locator, viewState = {}) {
124
+ const opening = this.#open(locator, viewState);
125
+ this.#opening.add(opening);
126
+ const settled = () => this.#opening.delete(opening);
127
+ opening.then(settled, settled);
128
+ return opening;
129
+ }
130
+
131
+ async #open(locator, viewState) {
124
132
  const requested = resolve(locator);
125
133
  const existing = this.#find(requested);
126
134
  if (existing) {
127
135
  this.activeLocator = existing.locator;
128
136
  return this.describe(existing.locator);
129
137
  }
130
- const backend = new LocalPackageBackend();
131
- let snapshot = await backend.open(requested);
132
- const duplicate = [...this.#packages.values()].find(
133
- (candidate) =>
134
- candidate.backend.snapshot.runtime.file === snapshot.runtime.file,
135
- );
136
- if (duplicate) {
137
- await backend.close();
138
- throw new SmallPenError(
139
- "duplicate_file_id",
140
- "Another open Package has the same persistent file identity",
141
- {
142
- fileId: snapshot.runtime.file,
138
+ // Opening never writes: a Package without Tokens is served with the
139
+ // default Theme projected in memory and persisted by its first commit.
140
+ const backend = new LocalPackageBackend({
141
+ defaultTokenLibrary: defaultTokenLibrary(),
142
+ });
143
+ let session;
144
+ let opened;
145
+ try {
146
+ const snapshot = await backend.open(requested);
147
+ // A symlinked or non-canonical spelling of an open Package, or a
148
+ // concurrent open of the same one, selects the session already open.
149
+ opened = this.#packages.get(snapshot.locator);
150
+ if (!opened) {
151
+ const duplicate = [...this.#packages.values()].find(
152
+ (candidate) =>
153
+ candidate.backend.snapshot.runtime.file === snapshot.runtime.file,
154
+ );
155
+ if (duplicate) {
156
+ throw new SmallPenError(
157
+ "duplicate_file_id",
158
+ "Another open Package has the same persistent file identity",
159
+ {
160
+ fileId: snapshot.runtime.file,
161
+ locator: snapshot.locator,
162
+ openLocator: duplicate.locator,
163
+ packageId: snapshot.manifest.packageId,
164
+ },
165
+ );
166
+ }
167
+ session = {
168
+ backend,
143
169
  locator: snapshot.locator,
144
- openLocator: duplicate.locator,
145
- packageId: snapshot.manifest.packageId,
146
- },
147
- );
170
+ unsubscribe: undefined,
171
+ statusSequence: 0,
172
+ viewState: reconcilePackageViewState(snapshot, viewState),
173
+ workspaceStatus: structuredClone(backend.status),
174
+ };
175
+ }
176
+ } finally {
177
+ if (!session) await backend.close();
148
178
  }
149
- snapshot = await seedDefaultTheme(backend, snapshot);
150
- const session = {
151
- backend,
152
- locator: snapshot.locator,
153
- unsubscribe: undefined,
154
- viewState: reconcilePackageViewState(snapshot, viewState),
155
- workspaceStatus: structuredClone(backend.status),
156
- };
179
+ if (opened) {
180
+ this.activeLocator = opened.locator;
181
+ return this.describe(opened.locator);
182
+ }
183
+ const { snapshot } = backend;
157
184
  session.unsubscribe = backend.subscribe((event) => {
158
185
  if (event.snapshot) {
159
186
  session.viewState = reconcilePackageViewState(
@@ -161,7 +188,11 @@ export class LocalWorkspaceSession {
161
188
  session.viewState,
162
189
  );
163
190
  }
164
- void this.#updateWorkspaceStatus(session);
191
+ // An external edit can retarget the Foundation or a Library.
192
+ const linked = event.snapshot
193
+ ? this.#openLinkedPackages(event.snapshot)
194
+ : Promise.resolve();
195
+ void linked.then(() => this.#updateWorkspaceStatus(session));
165
196
  this.#emit({ ...event, locator: session.locator });
166
197
  if (
167
198
  event.type === "external-revision" ||
@@ -173,25 +204,8 @@ export class LocalWorkspaceSession {
173
204
  });
174
205
  this.#packages.set(session.locator, session);
175
206
  this.activeLocator ??= session.locator;
176
- if (snapshot.manifest.role === "product") {
177
- const dependency = snapshot.manifest.dependencies[0];
178
- try {
179
- await this.open(join(dirname(snapshot.locator), dependency.path));
180
- } catch {
181
- // Composite status below retains Product and exposes dependency Repair.
182
- }
183
- }
184
- for (const library of snapshot.manifest.libraries ?? []) {
185
- if (library.source.type !== "local") continue;
186
- try {
187
- await this.open(
188
- resolve(dirname(snapshot.locator), library.source.path),
189
- );
190
- } catch {
191
- // Composite status below retains the owner and exposes Library Repair.
192
- }
193
- }
194
- await this.#updateWorkspaceStatus(session);
207
+ await this.#openLinkedPackages(snapshot);
208
+ await this.#updateWorkspaceStatus(session, { reuseRemoteLibraries: false });
195
209
  this.#emit({ locator: session.locator, snapshot, type: "package-opened" });
196
210
  return this.describe(session.locator);
197
211
  }
@@ -219,7 +233,7 @@ export class LocalWorkspaceSession {
219
233
 
220
234
  async refresh(locator) {
221
235
  const session = this.#required(locator);
222
- await this.#updateWorkspaceStatus(session);
236
+ await this.#updateWorkspaceStatus(session, { reuseRemoteLibraries: false });
223
237
  return this.describe(session.locator);
224
238
  }
225
239
 
@@ -245,6 +259,7 @@ export class LocalWorkspaceSession {
245
259
  const session = this.#required(locator);
246
260
  this.#assertWorkspaceWritable(session);
247
261
  const result = await session.backend.commit(batch);
262
+ await this.#openLinkedPackages(session.backend.snapshot);
248
263
  await this.#updateWorkspaceStatus(session);
249
264
  session.viewState = reconcilePackageViewState(
250
265
  session.backend.snapshot,
@@ -256,6 +271,7 @@ export class LocalWorkspaceSession {
256
271
  result,
257
272
  type: "package-committed",
258
273
  });
274
+ this.#emitDependencyChanges(session, { type: "package-committed" });
259
275
  return result;
260
276
  }
261
277
 
@@ -269,6 +285,7 @@ export class LocalWorkspaceSession {
269
285
  session.viewState,
270
286
  );
271
287
  this.#emit({ locator: session.locator, result, type: "package-undone" });
288
+ this.#emitDependencyChanges(session, { type: "package-undone" });
272
289
  return result;
273
290
  }
274
291
 
@@ -282,6 +299,7 @@ export class LocalWorkspaceSession {
282
299
  session.viewState,
283
300
  );
284
301
  this.#emit({ locator: session.locator, result, type: "package-redone" });
302
+ this.#emitDependencyChanges(session, { type: "package-redone" });
285
303
  return result;
286
304
  }
287
305
 
@@ -330,6 +348,8 @@ export class LocalWorkspaceSession {
330
348
 
331
349
  async close(locator) {
332
350
  const session = this.#required(locator);
351
+ clearTimeout(session.repairRetry);
352
+ session.repairRetry = undefined;
333
353
  session.unsubscribe?.();
334
354
  await session.backend.close();
335
355
  this.#packages.delete(session.locator);
@@ -339,8 +359,14 @@ export class LocalWorkspaceSession {
339
359
  this.#emit({ locator: session.locator, type: "package-closed" });
340
360
  }
341
361
 
362
+ // An open still in flight can register a Package (or its Foundation and
363
+ // Libraries) after the loop has passed; its watchers would keep the
364
+ // process alive, so close until no open is pending.
342
365
  async closeAll() {
343
- for (const locator of [...this.#packages.keys()]) await this.close(locator);
366
+ while (this.#packages.size > 0 || this.#opening.size > 0) {
367
+ for (const locator of [...this.#packages.keys()]) await this.close(locator);
368
+ await Promise.allSettled([...this.#opening]);
369
+ }
344
370
  }
345
371
 
346
372
  #find(locator) {
@@ -363,37 +389,134 @@ export class LocalWorkspaceSession {
363
389
  return session;
364
390
  }
365
391
 
392
+ // Opens the Foundation and local Libraries a Package links to, so their
393
+ // external edits reach it. Links that cannot be opened stay in Repair and
394
+ // are retried while the owner's status is not ready. The active Package
395
+ // never changes here.
396
+ async #openLinkedPackages(snapshot) {
397
+ const locators = [];
398
+ if (snapshot.manifest.role === "product") {
399
+ const dependency = snapshot.manifest.dependencies[0];
400
+ locators.push(join(dirname(snapshot.locator), dependency.path));
401
+ }
402
+ // Resolution never follows more declarations than this, so retrying
403
+ // the rest would only spend CPU on every Repair retry.
404
+ for (const library of (snapshot.manifest.libraries ?? []).slice(
405
+ 0,
406
+ MAX_LIBRARY_DECLARATIONS,
407
+ )) {
408
+ if (library.source.type !== "local") continue;
409
+ try {
410
+ locators.push(localLibraryLocator(snapshot.locator, library.source));
411
+ } catch {
412
+ // Composite status exposes the invalid Library source for Repair.
413
+ }
414
+ }
415
+ for (const locator of locators) {
416
+ if (this.#find(locator)) continue;
417
+ const active = this.activeLocator;
418
+ try {
419
+ await this.open(locator);
420
+ } catch {
421
+ // Composite status retains the owner and exposes the Repair.
422
+ } finally {
423
+ this.activeLocator = active ?? this.activeLocator;
424
+ }
425
+ }
426
+ }
427
+
428
+ // Every open Package whose composite Workspace includes the changed one,
429
+ // directly or through a Foundation or Library chain.
430
+ #dependentSessions(changed) {
431
+ const dependents = [];
432
+ const seen = new Set([changed]);
433
+ const pending = [changed];
434
+ while (pending.length > 0) {
435
+ const packageId = pending.shift().backend.snapshot?.manifest.packageId;
436
+ if (!packageId) continue;
437
+ for (const session of this.#packages.values()) {
438
+ if (seen.has(session)) continue;
439
+ const manifest = session.backend.snapshot?.manifest;
440
+ if (
441
+ manifest?.dependencies?.some(
442
+ (dependency) => dependency.packageId === packageId,
443
+ ) ||
444
+ manifest?.libraries?.some((library) => library.packageId === packageId)
445
+ ) {
446
+ seen.add(session);
447
+ dependents.push(session);
448
+ pending.push(session);
449
+ }
450
+ }
451
+ }
452
+ return dependents;
453
+ }
454
+
366
455
  #emitDependencyChanges(changed, event) {
367
- const packageId = changed.backend.snapshot?.manifest.packageId;
368
- if (!packageId) return;
369
- for (const session of this.#packages.values()) {
370
- if (session === changed) continue;
371
- if (
372
- session.backend.snapshot.manifest.dependencies?.some(
373
- (dependency) => dependency.packageId === packageId,
374
- ) ||
375
- session.backend.snapshot.manifest.libraries?.some(
376
- (library) => library.packageId === packageId,
377
- )
378
- ) {
379
- void this.#updateWorkspaceStatus(session).then(() => {
380
- this.#emit({
381
- dependencyEvent: event.type,
382
- dependencyLocator: changed.locator,
383
- locator: session.locator,
384
- status: structuredClone(session.workspaceStatus),
385
- type: "package-dependency-changed",
386
- });
456
+ for (const session of this.#dependentSessions(changed)) {
457
+ void this.#updateWorkspaceStatus(session).then(() => {
458
+ this.#emit({
459
+ dependencyEvent: event.type,
460
+ dependencyLocator: changed.locator,
461
+ locator: session.locator,
462
+ status: structuredClone(session.workspaceStatus),
463
+ type: "package-dependency-changed",
387
464
  });
388
- }
465
+ });
389
466
  }
390
467
  }
391
468
 
392
- async #updateWorkspaceStatus(session) {
469
+ #scheduleLinkRepairRetry(session) {
470
+ clearTimeout(session.repairRetry);
471
+ session.repairRetry = undefined;
472
+ // A Package whose own state is invalid recovers through its Backend.
473
+ if (
474
+ session.workspaceStatus.state !== "repair" ||
475
+ session.backend.status.state !== "ready"
476
+ ) {
477
+ return;
478
+ }
479
+ session.repairRetry = setTimeout(async () => {
480
+ session.repairRetry = undefined;
481
+ if (this.#packages.get(session.locator) !== session) return;
482
+ const before = JSON.stringify(session.workspaceStatus);
483
+ await this.#openLinkedPackages(session.backend.snapshot);
484
+ if (this.#packages.get(session.locator) !== session) return;
485
+ await this.#updateWorkspaceStatus(session, { emitUnchanged: false });
486
+ if (JSON.stringify(session.workspaceStatus) === before) return;
487
+ this.#emit({
488
+ dependencyEvent: "link-repair-retry",
489
+ locator: session.locator,
490
+ status: structuredClone(session.workspaceStatus),
491
+ type: "package-dependency-changed",
492
+ });
493
+ }, LINK_REPAIR_RETRY_MS);
494
+ session.repairRetry.unref?.();
495
+ }
496
+
497
+ async #updateWorkspaceStatus(
498
+ session,
499
+ { emitUnchanged = true, reuseRemoteLibraries = true } = {},
500
+ ) {
501
+ // Updates run concurrently (events do not await them); only the latest
502
+ // one started may set the status, so a slow older read never wins.
503
+ session.statusSequence += 1;
504
+ const sequence = session.statusSequence;
505
+ const before = JSON.stringify(session.workspaceStatus);
506
+ let resolution;
507
+ let failure;
393
508
  try {
394
- const resolution = await resolveWorkspace(session.locator, {
509
+ resolution = await resolveWorkspace(session.locator, {
510
+ fetchImpl: this.#fetchImpl,
395
511
  libraryCacheRoot: this.#libraryCacheRoot,
512
+ remoteLibrarySnapshots: this.#remoteLibraries,
513
+ reuseRemoteLibrarySnapshots: reuseRemoteLibraries,
396
514
  });
515
+ } catch (error) {
516
+ failure = error;
517
+ }
518
+ if (sequence !== session.statusSequence) return session.workspaceStatus;
519
+ if (resolution) {
397
520
  if (resolution.status === "ready") {
398
521
  session.lastValidWorkspace = resolution.workspace;
399
522
  session.repairWorkspace = undefined;
@@ -429,7 +552,8 @@ export class LocalWorkspaceSession {
429
552
  state: "repair",
430
553
  };
431
554
  }
432
- } catch (error) {
555
+ } else {
556
+ const error = failure;
433
557
  session.workspaceStatus = {
434
558
  error: {
435
559
  code: error?.code ?? "invalid_workspace_state",
@@ -444,11 +568,14 @@ export class LocalWorkspaceSession {
444
568
  state: "repair",
445
569
  };
446
570
  }
447
- this.#emit({
448
- locator: session.locator,
449
- status: structuredClone(session.workspaceStatus),
450
- type: "package-workspace-status",
451
- });
571
+ this.#scheduleLinkRepairRetry(session);
572
+ if (emitUnchanged || JSON.stringify(session.workspaceStatus) !== before) {
573
+ this.#emit({
574
+ locator: session.locator,
575
+ status: structuredClone(session.workspaceStatus),
576
+ type: "package-workspace-status",
577
+ });
578
+ }
452
579
  return session.workspaceStatus;
453
580
  }
454
581
 
@@ -467,6 +594,7 @@ export class LocalWorkspaceSession {
467
594
  type,
468
595
  value,
469
596
  });
597
+ this.#emitDependencyChanges(session, { type });
470
598
  return value;
471
599
  }
472
600