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

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.
@@ -23,6 +23,7 @@ import { promisify } from "node:util";
23
23
 
24
24
  import {
25
25
  canonicalJSON,
26
+ checkOperationShape,
26
27
  fail,
27
28
  listPackageEntries,
28
29
  loadPackageFromValues,
@@ -692,7 +693,29 @@ async function commitCandidate(originalPath, candidatePath, transactionPath) {
692
693
  await cleanup([backupPath, transactionPath, journalPath]);
693
694
  }
694
695
 
695
- async function applyOperationBatchLocked(locator, batch) {
696
+ // A Product's set-active-token-themes may name its Foundation's themes; a
697
+ // caller that did not load the Foundation (the Web backend) gets it here.
698
+ async function themeSelectionDependencies(locator, before, batch, dependencies) {
699
+ if (
700
+ dependencies.foundation ||
701
+ before.manifest.role !== "product" ||
702
+ !batch?.operations?.some?.((operation) => operation?.type === "set-active-token-themes")
703
+ ) {
704
+ return dependencies;
705
+ }
706
+ const dependency = before.manifest.dependencies[0];
707
+ try {
708
+ const foundation = await openPackageUnlocked(join(dirname(locator), dependency.path));
709
+ return foundation.manifest.packageId === dependency.packageId
710
+ ? { ...dependencies, foundation }
711
+ : dependencies;
712
+ } catch (error) {
713
+ if (error instanceof SmallPenError) return dependencies;
714
+ throw error;
715
+ }
716
+ }
717
+
718
+ async function applyOperationBatchLocked(locator, batch, dependencies = {}) {
696
719
  const before = await openPackageUnlocked(locator);
697
720
  const blobWrites = batch.blobs ?? new Map();
698
721
  if (!(blobWrites instanceof Map)) {
@@ -701,7 +724,11 @@ async function applyOperationBatchLocked(locator, batch) {
701
724
  for (const [entry, bytes] of blobWrites) {
702
725
  before.blobs.set(entry, bytes);
703
726
  }
704
- const prepared = await prepareOperationBatch(before, batch);
727
+ const prepared = await prepareOperationBatch(
728
+ before,
729
+ batch,
730
+ await themeSelectionDependencies(locator, before, batch, dependencies),
731
+ );
705
732
  const referencedBlobs = new Set(
706
733
  prepared.snapshot.manifest.entries.assets.flatMap((entry) =>
707
734
  [
@@ -826,12 +853,13 @@ function hashCanonical(value) {
826
853
  function batchIdentityHash(batch) {
827
854
  return hashCanonical({
828
855
  baseRevision: batch.baseRevision ?? null,
829
- operations: batch.operations ?? [],
856
+ operations: batch.operations,
857
+ ...(batch.cliIntent ? { cliIntent: batch.cliIntent } : {}),
830
858
  });
831
859
  }
832
860
 
833
861
  function batchOperationsHash(batch) {
834
- return hashCanonical({ operations: batch.operations ?? [] });
862
+ return hashCanonical({ operations: batch.operations });
835
863
  }
836
864
 
837
865
  // SP-039/040: a batch ledger beside the Package gives the public CLI stable
@@ -854,15 +882,13 @@ async function recordedBatchReplay(ledger, batch, readRevision, options = {}) {
854
882
  const recorded = ledger[batchId];
855
883
  if (!recorded || typeof recorded !== "object") return undefined;
856
884
  const operationsHash = await batchOperationsHash(batch);
857
- const sameBatch =
858
- typeof recorded.identityHash === "string"
859
- ? recorded.identityHash === (await batchIdentityHash(batch))
860
- : // Entries written before identities carried the base revision.
861
- recorded.operationsHash === operationsHash;
885
+ const sameBatch = recorded.identityHash === (await batchIdentityHash(batch));
862
886
  const sameCommandRetry =
887
+ typeof recorded.identityHash === "string" &&
863
888
  options.rebuilt === true &&
864
- recorded.operationsHash === operationsHash &&
865
- batch.baseRevision === recorded.revision;
889
+ (recorded.operationsHash === operationsHash ||
890
+ (batch.cliIntent && recorded.cliIntentHash === await hashCanonical(batch.cliIntent))) &&
891
+ typeof batch.baseRevision === "string";
866
892
  if (!sameBatch && !sameCommandRetry) {
867
893
  fail(
868
894
  "batch_id_conflict",
@@ -877,9 +903,13 @@ async function recordedBatchReplay(ledger, batch, readRevision, options = {}) {
877
903
  }
878
904
  const currentRevision = await readRevision();
879
905
  if (currentRevision === recorded.revision) {
906
+ batch.operations.forEach(checkOperationShape);
880
907
  return { ...recorded.result, alreadyApplied: true };
881
908
  }
882
- if (sameBatch && currentRevision === batch.baseRevision) return undefined;
909
+ if (
910
+ (sameBatch && currentRevision === batch.baseRevision) ||
911
+ (sameCommandRetry && recorded.baseRevision === currentRevision && batch.baseRevision === currentRevision)
912
+ ) return undefined;
883
913
  fail(
884
914
  "batch_superseded",
885
915
  "Batch ID was already committed, and later writes changed the Package",
@@ -897,6 +927,26 @@ function currentRevisionReader(packagePath) {
897
927
  return async () => (await openPackageUnlocked(packagePath)).revision;
898
928
  }
899
929
 
930
+ // The committed batches, oldest first: what each changed, when, from which
931
+ // revision, and the inverse that restores the revision before it. Read
932
+ // only; `smallpen changes` rebuilds an earlier revision from it.
933
+ export async function readBatchHistory(locator) {
934
+ const resolvedPackagePath = await resolvePackageRoot(await lockTarget(locator));
935
+ const ledger = await readBatchLedger(resolvedPackagePath);
936
+ return Object.entries(ledger)
937
+ .map(([batchId, entry]) => ({
938
+ affectedIds: entry?.result?.affectedIds ?? [],
939
+ baseRevision: entry?.baseRevision,
940
+ batchId,
941
+ committedAt: entry?.committedAt,
942
+ inverseBatch: entry?.result?.inverseBatch,
943
+ revision: entry?.revision ?? entry?.result?.revision,
944
+ // CLI writes carry the intent that produced them; App edits do not.
945
+ source: entry?.cliIntentHash ? "cli" : "app",
946
+ }))
947
+ .sort((left, right) => String(left.committedAt ?? "").localeCompare(String(right.committedAt ?? "")));
948
+ }
949
+
900
950
  // Public replay check used by the CLI before stale-revision validation and
901
951
  // before high-level commands rebuild a batch: a committed batch identity
902
952
  // replays its recorded confirmation instead of failing or writing again.
@@ -947,13 +997,19 @@ export async function applyOperationBatch(locator, batch, options = {}) {
947
997
  currentRevisionReader(resolvedPackagePath),
948
998
  );
949
999
  if (!result) {
950
- result = await applyOperationBatchLocked(resolvedPackagePath, batch);
1000
+ result = await applyOperationBatchLocked(
1001
+ resolvedPackagePath,
1002
+ batch,
1003
+ options.dependencies,
1004
+ );
951
1005
  if (Array.isArray(batch?.operations)) {
952
1006
  try {
953
1007
  ledger[batch.batchId] = {
1008
+ baseRevision: batch.baseRevision,
954
1009
  committedAt: new Date().toISOString(),
955
1010
  identityHash: await batchIdentityHash(batch),
956
1011
  operationsHash: await batchOperationsHash(batch),
1012
+ ...(batch.cliIntent ? { cliIntentHash: await hashCanonical(batch.cliIntent) } : {}),
957
1013
  result,
958
1014
  revision: result.revision,
959
1015
  };
@@ -1,6 +1,7 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { dirname, join, resolve } from "node:path";
2
3
 
3
- import { SmallPenError } from "@smallpen/core";
4
+ import { followedVariantOperations, SmallPenError } from "@smallpen/core";
4
5
 
5
6
  import { LocalPackageBackend } from "./local-package-backend.mjs";
6
7
  import {
@@ -258,7 +259,10 @@ export class LocalWorkspaceSession {
258
259
  async commit(locator, batch) {
259
260
  const session = this.#required(locator);
260
261
  this.#assertWorkspaceWritable(session);
262
+ const before = session.backend.snapshot;
261
263
  const result = await session.backend.commit(batch);
264
+ const followed = await this.#followVariants(session, before);
265
+ if (followed.length > 0) result.followedVariants = followed;
262
266
  await this.#openLinkedPackages(session.backend.snapshot);
263
267
  await this.#updateWorkspaceStatus(session);
264
268
  session.viewState = reconcilePackageViewState(
@@ -278,7 +282,10 @@ export class LocalWorkspaceSession {
278
282
  async undo(locator) {
279
283
  const session = this.#required(locator);
280
284
  this.#assertWorkspaceWritable(session);
285
+ const before = session.backend.snapshot;
281
286
  const result = await session.backend.undo();
287
+ const followed = await this.#followVariants(session, before);
288
+ if (followed.length > 0) result.followedVariants = followed;
282
289
  await this.#updateWorkspaceStatus(session);
283
290
  session.viewState = reconcilePackageViewState(
284
291
  session.backend.snapshot,
@@ -292,7 +299,10 @@ export class LocalWorkspaceSession {
292
299
  async redo(locator) {
293
300
  const session = this.#required(locator);
294
301
  this.#assertWorkspaceWritable(session);
302
+ const before = session.backend.snapshot;
295
303
  const result = await session.backend.redo();
304
+ const followed = await this.#followVariants(session, before);
305
+ if (followed.length > 0) result.followedVariants = followed;
296
306
  await this.#updateWorkspaceStatus(session);
297
307
  session.viewState = reconcilePackageViewState(
298
308
  session.backend.snapshot,
@@ -425,6 +435,50 @@ export class LocalWorkspaceSession {
425
435
  }
426
436
  }
427
437
 
438
+ // A write renamed variant values in a Package other open Packages use:
439
+ // each of those re-points its Instances to the same variants in a batch
440
+ // of its own (its own undo entry), so it never turns stale for a rename.
441
+ // Instances of deleted variants stay for Repair. Returns what followed.
442
+ async #followVariants(session, before) {
443
+ const after = session.backend.snapshot;
444
+ if (!before || !after || before === after) return [];
445
+ const followed = [];
446
+ for (const dependent of this.#dependentSessions(session)) {
447
+ const snapshot = dependent.backend.snapshot;
448
+ if (!snapshot) continue;
449
+ const operations = followedVariantOperations(before, after, snapshot);
450
+ if (operations.length === 0) continue;
451
+ try {
452
+ const result = await dependent.backend.commit({
453
+ baseRevision: snapshot.revision,
454
+ batchId: `follow_variants_${randomUUID()}`,
455
+ operations,
456
+ });
457
+ followed.push({
458
+ batchId: result.batchId,
459
+ instances: operations.length,
460
+ locator: dependent.locator,
461
+ revision: result.revision,
462
+ });
463
+ await this.#updateWorkspaceStatus(dependent);
464
+ this.#emit({
465
+ change: dependent.backend.changes.at(-1),
466
+ locator: dependent.locator,
467
+ result,
468
+ type: "package-committed",
469
+ });
470
+ } catch (error) {
471
+ // The dependent stays as it was; its Instances show in Repair.
472
+ followed.push({
473
+ error: { code: error?.code ?? "follow_variants_failed", message: error?.message },
474
+ instances: operations.length,
475
+ locator: dependent.locator,
476
+ });
477
+ }
478
+ }
479
+ return followed;
480
+ }
481
+
428
482
  // Every open Package whose composite Workspace includes the changed one,
429
483
  // directly or through a Foundation or Library chain.
430
484
  #dependentSessions(changed) {
@@ -0,0 +1,116 @@
1
+ // `smallpen migrate-themes`: ADR 0003 upgrade by copy. The original Package
2
+ // (or Foundation + Product pair) stays untouched; the converted copy is
3
+ // written beside it in one rename.
4
+ import { cp, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+
7
+ import {
8
+ canonicalJSON,
9
+ migrateTokenThemes,
10
+ SmallPenError,
11
+ } from "@smallpen/core";
12
+
13
+ import { pathExists } from "./fs-utils.mjs";
14
+ import { openWorkspace } from "./workspace.mjs";
15
+
16
+ async function writePackage(packagePath, { deletedEntries, entries, manifest }) {
17
+ await writeFile(join(packagePath, "manifest.json"), canonicalJSON(manifest), "utf8");
18
+ for (const [entry, value] of Object.entries(entries)) {
19
+ const output = join(packagePath, entry);
20
+ await mkdir(dirname(output), { recursive: true });
21
+ await writeFile(output, canonicalJSON(value), "utf8");
22
+ }
23
+ for (const entry of deletedEntries) {
24
+ if (Object.hasOwn(entries, entry)) continue;
25
+ await rm(join(packagePath, entry), { force: true });
26
+ }
27
+ }
28
+
29
+ export async function migrateThemesByCopy(locator, outputLocator) {
30
+ if (typeof outputLocator !== "string" || outputLocator.length === 0) {
31
+ throw new SmallPenError(
32
+ "missing_migration_output",
33
+ "migrate-themes requires --output NEW_PATH; the original is never changed",
34
+ );
35
+ }
36
+ const workspace = await openWorkspace(locator);
37
+ const { foundation, product } = workspace;
38
+ const pair = Boolean(foundation) && foundation !== product;
39
+ const outputPath = resolve(outputLocator);
40
+ if (!pair && !outputPath.toLowerCase().endsWith(".smallpen")) {
41
+ throw new SmallPenError(
42
+ "invalid_package_path",
43
+ "--output must be a new <name>.smallpen path for a single Package",
44
+ { outputPath },
45
+ );
46
+ }
47
+ if (await pathExists(outputPath)) {
48
+ throw new SmallPenError(
49
+ "migration_output_exists",
50
+ `Migration output already exists: ${outputPath}`,
51
+ { outputPath },
52
+ );
53
+ }
54
+ const result = migrateTokenThemes({ foundation, product });
55
+ const parent = dirname(outputPath);
56
+ await mkdir(parent, { recursive: true });
57
+ const transactionPath = await mkdtemp(join(parent, ".smallpen-migrate-"));
58
+ const candidatePath = join(transactionPath, basename(outputPath));
59
+ const dependency = pair
60
+ ? product.manifest.dependencies.find(
61
+ ({ packageId }) => packageId === foundation.manifest.packageId,
62
+ )
63
+ : undefined;
64
+ // Package directory of each migrated Package, relative to candidatePath.
65
+ const targets = new Map(
66
+ pair
67
+ ? [
68
+ [foundation.manifest.packageId, dependency.path],
69
+ [product.manifest.packageId, basename(product.locator)],
70
+ ]
71
+ : [[product.manifest.packageId, "."]],
72
+ );
73
+ const sources = new Map(
74
+ [foundation, product].filter(Boolean).map((snapshot) => [
75
+ snapshot.manifest.packageId,
76
+ snapshot.locator,
77
+ ]),
78
+ );
79
+ try {
80
+ for (const migrated of result.packages) {
81
+ const target = join(candidatePath, targets.get(migrated.packageId));
82
+ // Copy everything first so blobs and other files travel along.
83
+ await cp(sources.get(migrated.packageId), target, { recursive: true });
84
+ await writePackage(target, migrated);
85
+ }
86
+ const productTarget = join(candidatePath, targets.get(product.manifest.packageId));
87
+ const opened = await openWorkspace(productTarget);
88
+ await rename(candidatePath, outputPath);
89
+ await rm(transactionPath, { force: true, recursive: true });
90
+ const finalPath = (packageId) => join(outputPath, targets.get(packageId));
91
+ return {
92
+ original: pair
93
+ ? { foundationPath: foundation.locator, productPath: product.locator }
94
+ : { packagePath: product.locator },
95
+ output: pair
96
+ ? {
97
+ foundationPath: finalPath(foundation.manifest.packageId),
98
+ productPath: finalPath(product.manifest.packageId),
99
+ workspacePath: outputPath,
100
+ }
101
+ : { packagePath: outputPath },
102
+ packages: [opened.foundation, opened.product]
103
+ .filter(Boolean)
104
+ .map((snapshot) => ({
105
+ packageId: snapshot.manifest.packageId,
106
+ revision: snapshot.revision,
107
+ })),
108
+ status: "migrated",
109
+ themes: result.themes,
110
+ warnings: result.warnings,
111
+ };
112
+ } catch (error) {
113
+ await rm(transactionPath, { force: true, recursive: true });
114
+ throw error;
115
+ }
116
+ }