@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.
- package/package.json +2 -2
- package/src/index.mjs +12 -2
- package/src/initialize.mjs +362 -125
- package/src/local-package.mjs +69 -13
- package/src/local-workspace-session.mjs +55 -1
- package/src/migrate-themes.mjs +116 -0
- package/src/render.mjs +1029 -436
- package/src/workspace.mjs +93 -2
package/src/local-package.mjs
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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
|
+
}
|