@effected/workspaces 0.1.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.
@@ -0,0 +1,392 @@
1
+ import { CatalogAssemblyError } from "./CatalogAssemblyError.js";
2
+ import { WorkspaceRoot } from "./WorkspaceRoot.js";
3
+ import { inlineCatalogs, merge, normalize, rangeOf } from "./internal/catalogs.js";
4
+ import { ConfigDependencyHooks } from "./ConfigDependencyHooks.js";
5
+ import { LockfileReader } from "./LockfileReader.js";
6
+ import { Context, Duration, Effect, Exit, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
7
+ import { CatalogResolver, DependencyResolutionError } from "@effected/npm";
8
+ import { Yaml } from "@effected/yaml";
9
+
10
+ //#region src/WorkspaceCatalogs.ts
11
+ /**
12
+ * An immutable, fully-normalized catalog collection — the one catalog
13
+ * resolution semantic in the package.
14
+ *
15
+ * @remarks
16
+ * `entries` is catalog name → dependency → range, with pnpm's unnamed
17
+ * top-level `catalog:` block normalized under the key `"default"`. Lockfile
18
+ * catalog entries, which pnpm records as `{ specifier, version }`, are
19
+ * normalized to their `specifier` — the declared range, which is what a catalog
20
+ * resolves to.
21
+ *
22
+ * @public
23
+ */
24
+ var CatalogSet = class CatalogSet extends Schema.Class("CatalogSet")({
25
+ /** Catalog name → dependency name → version range. */
26
+ entries: Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.String)) }) {
27
+ /** The empty set — a workspace with no catalogs. */
28
+ static empty() {
29
+ return CatalogSet.make({ entries: {} });
30
+ }
31
+ /** Wrap a pnpm `Catalogs` map, dropping unusable entries. */
32
+ static fromCatalogs(catalogs) {
33
+ return CatalogSet.make({ entries: normalize(catalogs) });
34
+ }
35
+ /**
36
+ * The `catalog:` and `catalogs:` blocks of a `pnpm-workspace.yaml` document.
37
+ *
38
+ * @param text - The raw YAML text.
39
+ */
40
+ static fromWorkspaceYaml = Effect.fn("CatalogSet.fromWorkspaceYaml")(function* (text) {
41
+ const document = yield* Yaml.parse(text).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
42
+ source: "manifest",
43
+ path: "pnpm-workspace.yaml",
44
+ cause
45
+ })));
46
+ return CatalogSet.fromCatalogs(inlineCatalogs(catalogBlocksOf(document)));
47
+ });
48
+ /**
49
+ * The `catalogs:` section of a pnpm lockfile, whose entries are either a bare
50
+ * range or a `{ specifier, version }` pair.
51
+ */
52
+ static fromLockfileCatalogs(raw) {
53
+ return CatalogSet.make({ entries: normalize(raw) });
54
+ }
55
+ /**
56
+ * The catalog set a parsed lockfile records, PM-aware.
57
+ *
58
+ * @remarks
59
+ * Both pnpm and bun record catalogs in the lockfile, in different shapes:
60
+ * pnpm under `extension.catalogs`, bun under `extension.catalog` /
61
+ * `extension.catalogs`. Assembly reads whichever the parsed lockfile carries
62
+ * rather than assuming the pnpm extension. A lockfile with no extension, or a
63
+ * pnpm one with no catalogs, yields the empty set.
64
+ *
65
+ * @param lockfile - A parsed lockfile from `@effected/lockfiles`.
66
+ */
67
+ static fromLockfile(lockfile) {
68
+ const ext = lockfile.extension;
69
+ if (ext === void 0) return CatalogSet.empty();
70
+ if (ext._tag === "pnpm") return ext.catalogs !== void 0 ? CatalogSet.fromLockfileCatalogs(ext.catalogs) : CatalogSet.empty();
71
+ return CatalogSet.fromBunBlocks({
72
+ catalog: ext.catalog,
73
+ catalogs: ext.catalogs
74
+ });
75
+ }
76
+ /**
77
+ * A catalog set from bun's `{ catalog, catalogs }` blocks — used for the
78
+ * `bun.lock` extension and for the root `package.json` `workspaces` block. The
79
+ * unnamed default catalog normalizes under `"default"`. Tolerant: unusable
80
+ * values are dropped, never fatal.
81
+ *
82
+ * @param blocks - The `catalog` (default) and `catalogs` (named) blocks.
83
+ */
84
+ static fromBunBlocks(blocks) {
85
+ const raw = {};
86
+ if (isObject(blocks.catalogs)) Object.assign(raw, blocks.catalogs);
87
+ if (isObject(blocks.catalog)) raw.default = {
88
+ ...isObject(raw.default) ? raw.default : {},
89
+ ...blocks.catalog
90
+ };
91
+ return CatalogSet.fromCatalogs(raw);
92
+ }
93
+ /**
94
+ * The `catalog` / `catalogs` blocks of a root `package.json` `workspaces` field
95
+ * — bun's package.json analogue of pnpm's `pnpm-workspace.yaml` blocks.
96
+ *
97
+ * @remarks
98
+ * **Hard-fail by design**, preserving the semantics catalog output is
99
+ * load-bearing for: a present-but-malformed `workspaces` shape (a number, a
100
+ * string, an object with a malformed `packages` / `catalog` / `catalogs`), or
101
+ * the default catalog declared twice — once as `workspaces.catalog` and again
102
+ * as `workspaces.catalogs.default` — fails with {@link CatalogAssemblyError}
103
+ * naming what was wrong. An absent `workspaces` field, one explicitly `null`,
104
+ * or the plain array form (npm/yarn patterns, which carry no catalogs) yields
105
+ * the empty set. Presence is checked structurally, so an explicitly-declared
106
+ * empty `catalog: {}` still counts as a declaration.
107
+ *
108
+ * @param text - The raw root `package.json` text.
109
+ */
110
+ static fromManifestWorkspaces = Effect.fn("CatalogSet.fromManifestWorkspaces")(function* (text) {
111
+ const manifest = yield* Effect.try({
112
+ try: () => JSON.parse(text),
113
+ catch: (cause) => new CatalogAssemblyError({
114
+ source: "manifest",
115
+ path: "package.json",
116
+ cause
117
+ })
118
+ });
119
+ if (!isObject(manifest)) return yield* Effect.fail(new CatalogAssemblyError({
120
+ source: "manifest",
121
+ path: "package.json",
122
+ cause: /* @__PURE__ */ new Error("package.json is not a JSON object")
123
+ }));
124
+ const blocks = yield* manifestCatalogBlocks(manifest.workspaces);
125
+ return CatalogSet.fromBunBlocks(blocks);
126
+ });
127
+ /** Merge sets. Later sets win per dependency within a catalog. */
128
+ static merge(...sets) {
129
+ return CatalogSet.fromCatalogs(merge(...sets.map((set) => set.entries)));
130
+ }
131
+ /** Whether any catalog declares anything. */
132
+ get isEmpty() {
133
+ return Object.keys(this.entries).length === 0;
134
+ }
135
+ /**
136
+ * The range a `catalog:` specifier resolves to.
137
+ *
138
+ * @remarks
139
+ * Total: an unmatched dependency, an unknown catalog name, or a non-catalog
140
+ * specifier all yield `Option.none()`. A *malformed* catalog — one pnpm
141
+ * itself rejects — also yields `Option.none()` here; the fallible surface is
142
+ * {@link WorkspaceCatalogs}, which fails typed on assembly instead.
143
+ *
144
+ * @param dependency - The package name being resolved.
145
+ * @param specifier - The declared specifier, e.g. `catalog:` or `catalog:build`.
146
+ */
147
+ resolveSpecifier(dependency, specifier) {
148
+ const resolved = rangeOf(this.entries, dependency, specifier);
149
+ return typeof resolved === "string" ? Option.some(resolved) : Option.none();
150
+ }
151
+ /**
152
+ * The range `dependency` carries in a named catalog, or in the default
153
+ * catalog when `catalog` is `Option.none()`.
154
+ *
155
+ * @remarks
156
+ * The shape `@effected/npm`'s `CatalogResolver` contract asks for.
157
+ */
158
+ rangeOf(dependency, catalog) {
159
+ const name = Option.getOrElse(catalog, () => "default");
160
+ return Option.fromUndefinedOr(this.entries[name]?.[dependency]);
161
+ }
162
+ };
163
+ /** Whether `value` is a non-null, non-array object. */
164
+ const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
165
+ /** Whether every value in an object is a string — a usable `dependency → range` catalog. */
166
+ const isStringRecord = (value) => isObject(value) && Object.values(value).every((entry) => typeof entry === "string");
167
+ /** The `catalog` / `catalogs` blocks of a parsed pnpm-workspace document. */
168
+ const catalogBlocksOf = (document) => {
169
+ if (document === null || typeof document !== "object") return {};
170
+ const raw = document;
171
+ return {
172
+ catalog: raw.catalog,
173
+ catalogs: raw.catalogs
174
+ };
175
+ };
176
+ /** The `configDependencies` map (name → version+integrity) of a parsed pnpm-workspace document. */
177
+ const configDependenciesOf = (document) => {
178
+ if (!isObject(document) || !isObject(document.configDependencies)) return {};
179
+ const out = {};
180
+ for (const [name, spec] of Object.entries(document.configDependencies)) if (typeof spec === "string") out[name] = spec;
181
+ return out;
182
+ };
183
+ /** A hard-fail catalog-assembly failure naming the malformed part of a `workspaces` field. */
184
+ const malformed = (source, path, detail) => new CatalogAssemblyError({
185
+ source,
186
+ path,
187
+ cause: new Error(detail)
188
+ });
189
+ /**
190
+ * Validate a `catalog` (default) / `catalogs` (named) block pair, hard-failing on
191
+ * a malformed shape or the default catalog declared twice.
192
+ *
193
+ * @remarks
194
+ * Shared by the bun `package.json` `workspaces` reader
195
+ * ({@link CatalogSet.fromManifestWorkspaces}) and the pnpm `pnpm-workspace.yaml`
196
+ * reader, so both fail typed on exactly the same conditions rather than one
197
+ * hard-failing and the other silently normalizing to `{}` — the "every dependency
198
+ * looks newly added" bug. `labels` names the two blocks in the diagnostics so the
199
+ * error reads in the caller's vocabulary (`workspaces.catalog` vs. `catalog`).
200
+ */
201
+ const validatedCatalogBlocks = (catalog, catalogs, labels) => {
202
+ let validCatalog;
203
+ if (catalog !== void 0 && catalog !== null) {
204
+ if (!isStringRecord(catalog)) return Effect.fail(malformed("catalog", labels.catalog, `"${labels.catalog}" must map dependency names to version strings`));
205
+ validCatalog = catalog;
206
+ }
207
+ let validCatalogs;
208
+ if (catalogs !== void 0 && catalogs !== null) {
209
+ if (!isObject(catalogs)) return Effect.fail(malformed("catalog", labels.catalogs, `"${labels.catalogs}" must map catalog names to catalogs`));
210
+ for (const [name, entries] of Object.entries(catalogs)) if (!isStringRecord(entries)) return Effect.fail(malformed("catalog", `${labels.catalogs}.${name}`, `"${labels.catalogs}.${name}" must map dependency names to version strings`));
211
+ validCatalogs = catalogs;
212
+ }
213
+ if (validCatalog !== void 0 && validCatalogs !== void 0 && "default" in validCatalogs) return Effect.fail(malformed("catalog", "default", `The default catalog is declared twice: as "${labels.catalog}" and as "${labels.catalogs}.default"`));
214
+ return Effect.succeed({
215
+ ...validCatalog !== void 0 ? { catalog: validCatalog } : {},
216
+ ...validCatalogs !== void 0 ? { catalogs: validCatalogs } : {}
217
+ });
218
+ };
219
+ /**
220
+ * The `catalog` / `catalogs` blocks of a root `package.json` `workspaces` field,
221
+ * hard-failing on a malformed shape or the default catalog declared twice.
222
+ */
223
+ const manifestCatalogBlocks = (workspaces) => {
224
+ if (workspaces === void 0 || workspaces === null || Array.isArray(workspaces)) return Effect.succeed({});
225
+ if (!isObject(workspaces)) return Effect.fail(malformed("manifest", "package.json", `"workspaces" must be an array or object, got ${typeof workspaces}`));
226
+ if (workspaces.packages !== void 0 && workspaces.packages !== null) {
227
+ const packages = workspaces.packages;
228
+ if (!Array.isArray(packages) || !packages.every((entry) => typeof entry === "string")) return Effect.fail(malformed("manifest", "package.json", `"workspaces.packages" must be an array of strings`));
229
+ }
230
+ return validatedCatalogBlocks(workspaces.catalog, workspaces.catalogs, {
231
+ catalog: "workspaces.catalog",
232
+ catalogs: "workspaces.catalogs"
233
+ });
234
+ };
235
+ /**
236
+ * Validate the inline `catalog` / `catalogs` blocks of a parsed
237
+ * `pnpm-workspace.yaml` document, hard-failing on the same malformed-shape and
238
+ * duplicate-default conditions as the bun `package.json` path.
239
+ *
240
+ * @remarks
241
+ * The live pnpm reader in {@link WorkspaceCatalogs.make} runs this BEFORE
242
+ * normalizing with `inlineCatalogs`, so an invalid inline catalog fails typed
243
+ * rather than normalizing to `{}` and reading as ABSENT. A non-object document,
244
+ * or one with no catalog blocks, validates to `{}` — absent/empty still yields
245
+ * empty. The return is discarded; normalization stays on the shared
246
+ * `inlineCatalogs` path.
247
+ */
248
+ const validatePnpmWorkspaceCatalogs = (document) => {
249
+ if (!isObject(document)) return Effect.void;
250
+ return Effect.asVoid(validatedCatalogBlocks(document.catalog, document.catalogs, {
251
+ catalog: "catalog",
252
+ catalogs: "catalogs"
253
+ }));
254
+ };
255
+ /**
256
+ * Assembles a workspace's catalogs, package-manager-aware.
257
+ *
258
+ * @remarks
259
+ * The inline source is picked by **file presence**, the same rule
260
+ * `internal/patterns.ts` uses for globs: a `pnpm-workspace.yaml` selects the pnpm
261
+ * path (its `catalog:` / `catalogs:` blocks); its absence selects the root
262
+ * `package.json` `workspaces.catalog` / `workspaces.catalogs` (bun's analogue).
263
+ * The lockfile source is PM-aware too — whichever extension the parsed lockfile
264
+ * carries (pnpm or bun) contributes its recorded catalogs.
265
+ *
266
+ * Precedence, lowest first: catalogs recorded in the lockfile, then the inline
267
+ * declaration, then — only under {@link WorkspaceCatalogs.layerWithConfigDependencies}
268
+ * — the config-dependency `pnpmfile.cjs` hooks, each layer winning per dependency
269
+ * within a catalog. An unreadable or absent lockfile degrades to no lockfile
270
+ * catalogs rather than failing (the inline declaration is the source of truth, the
271
+ * lockfile a record of what was installed); a malformed inline source **fails
272
+ * typed** with {@link CatalogAssemblyError}, because a silently-empty catalog read
273
+ * is the "every dependency looks newly added" bug.
274
+ *
275
+ * Assembly is deferred to the first call and memoized success-only, matching
276
+ * {@link WorkspaceDiscovery}.
277
+ *
278
+ * @public
279
+ */
280
+ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effected/workspaces/WorkspaceCatalogs") {
281
+ /** Builds the service. */
282
+ static make = (options) => Effect.gen(function* () {
283
+ const roots = yield* WorkspaceRoot;
284
+ const lockfiles = yield* LockfileReader;
285
+ const hooks = yield* ConfigDependencyHooks;
286
+ const fs = yield* FileSystem.FileSystem;
287
+ const path = yield* Path.Path;
288
+ const probeExists = (target) => fs.exists(target).pipe(Effect.catchTag("PlatformError", (error) => {
289
+ const reason = error.reason;
290
+ return reason instanceof PlatformError.SystemError && reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(new CatalogAssemblyError({
291
+ source: "manifest",
292
+ path: target,
293
+ cause: error
294
+ }));
295
+ }));
296
+ const assemble = Effect.gen(function* () {
297
+ const root = yield* Effect.suspend(() => roots.find(options?.cwd ?? process.cwd()));
298
+ const fromLockfile = yield* lockfiles.read().pipe(Effect.map(CatalogSet.fromLockfile), Effect.catch((_failure) => Effect.succeed(CatalogSet.empty())));
299
+ const workspaceYaml = path.join(root, "pnpm-workspace.yaml");
300
+ const hasPnpmWorkspace = yield* probeExists(workspaceYaml);
301
+ let inline;
302
+ let injected;
303
+ if (hasPnpmWorkspace) {
304
+ const text = yield* fs.readFileString(workspaceYaml).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
305
+ source: "manifest",
306
+ path: workspaceYaml,
307
+ cause
308
+ })));
309
+ const document = yield* Yaml.parse(text).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
310
+ source: "manifest",
311
+ path: "pnpm-workspace.yaml",
312
+ cause
313
+ })));
314
+ yield* validatePnpmWorkspaceCatalogs(document);
315
+ inline = CatalogSet.fromCatalogs(inlineCatalogs(catalogBlocksOf(document)));
316
+ const injectedEntries = yield* hooks.inject(root, configDependenciesOf(document), inline.entries);
317
+ injected = CatalogSet.fromCatalogs(injectedEntries);
318
+ } else {
319
+ const manifestPath = path.join(root, "package.json");
320
+ if (!(yield* probeExists(manifestPath))) return fromLockfile;
321
+ const text = yield* fs.readFileString(manifestPath).pipe(Effect.mapError((cause) => new CatalogAssemblyError({
322
+ source: "manifest",
323
+ path: manifestPath,
324
+ cause
325
+ })));
326
+ inline = yield* CatalogSet.fromManifestWorkspaces(text);
327
+ injected = CatalogSet.empty();
328
+ }
329
+ const assembled = CatalogSet.merge(fromLockfile, inline, injected);
330
+ yield* Effect.logDebug("Catalogs assembled").pipe(Effect.annotateLogs({
331
+ "workspace.root": root,
332
+ "workspace.catalogs": Object.keys(assembled.entries).join(",")
333
+ }));
334
+ return assembled;
335
+ });
336
+ const [resolveOnce, invalidate] = yield* Effect.cachedInvalidateWithTTL(assemble, Duration.infinity);
337
+ const memo = Effect.onExit(resolveOnce, (exit) => Exit.isSuccess(exit) ? Effect.void : invalidate);
338
+ return {
339
+ set: Effect.fn("WorkspaceCatalogs.set")(function* () {
340
+ return yield* memo;
341
+ }),
342
+ resolveSpecifier: Effect.fn("WorkspaceCatalogs.resolveSpecifier")(function* (dependency, specifier) {
343
+ return (yield* memo).resolveSpecifier(dependency, specifier);
344
+ })
345
+ };
346
+ });
347
+ /**
348
+ * The live layer — the default, which **never executes config-dependency
349
+ * code**: it wires {@link ConfigDependencyHooks.layerNoop}, whose hooks return
350
+ * the inline-catalog seed untouched.
351
+ *
352
+ * @remarks
353
+ * Parameterized, so it mints a fresh reference per call — bind it to a
354
+ * `const` and reuse it, or layer memoization does not apply.
355
+ */
356
+ static layer = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerNoop));
357
+ /**
358
+ * The opt-in live layer that **does** replay config-dependency `pnpmfile.cjs`
359
+ * hooks: it wires {@link ConfigDependencyHooks.layerLive}, which dynamically
360
+ * imports and runs each config dependency's `updateConfig` in process.
361
+ *
362
+ * @remarks
363
+ * Same output and requirement set as {@link WorkspaceCatalogs.layer} — the only
364
+ * difference is that config-dependency code is executed. Use it deliberately;
365
+ * the default catalog path stays free of any config-dependency execution.
366
+ * Parameterized, so bind it to a `const` and reuse it.
367
+ */
368
+ static layerWithConfigDependencies = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerLive));
369
+ /**
370
+ * The real implementation of `@effected/npm`'s `CatalogResolver` contract —
371
+ * the one `@effected/package-json` declares but cannot fill.
372
+ *
373
+ * @remarks
374
+ * `rangeOf` returns `Option.none()` for a dependency no catalog declares, per
375
+ * the contract's convention; `DependencyResolutionError` is reserved for a
376
+ * failure of the resolution *mechanism* — an unfindable workspace root, an
377
+ * unreadable or malformed `pnpm-workspace.yaml`.
378
+ */
379
+ static catalogResolver = Layer.effect(CatalogResolver, Effect.gen(function* () {
380
+ const catalogs = yield* WorkspaceCatalogs;
381
+ return { rangeOf: (packageName, catalog) => catalogs.set().pipe(Effect.map((set) => set.rangeOf(packageName, catalog)), Effect.mapError((cause) => new DependencyResolutionError({
382
+ specifier: Option.match(catalog, {
383
+ onNone: () => "catalog:",
384
+ onSome: (name) => `catalog:${name}`
385
+ }),
386
+ cause
387
+ }))) };
388
+ }));
389
+ };
390
+
391
+ //#endregion
392
+ export { CatalogSet, WorkspaceCatalogs };