@effected/workspaces 0.9.5 → 0.10.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.
- package/ConfigDependencyHooks.js +223 -1
- package/README.md +1 -0
- package/WorkspaceCatalogs.js +18 -0
- package/WorkspaceDiscovery.js +20 -2
- package/Workspaces.js +27 -0
- package/index.d.ts +97 -1
- package/package.json +3 -3
package/ConfigDependencyHooks.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { normalize } from "./internal/catalogs.js";
|
|
2
|
-
import { Context, Effect, Layer, Predicate, Result } from "effect";
|
|
2
|
+
import { Context, Duration, Effect, Layer, Predicate, Result, Schema } from "effect";
|
|
3
3
|
import { CatalogAssemblyError } from "@effected/npm";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { Run } from "@effected/commands";
|
|
7
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
6
8
|
|
|
7
9
|
//#region src/ConfigDependencyHooks.ts
|
|
8
10
|
/** Whether `value` is a non-null, non-array object. */
|
|
@@ -97,6 +99,137 @@ const updateConfigOf = (mod) => {
|
|
|
97
99
|
}
|
|
98
100
|
};
|
|
99
101
|
/**
|
|
102
|
+
* The subprocess replay program {@link ConfigDependencyHooks.layerSubprocess}
|
|
103
|
+
* hands to `node --input-type=module -e`.
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* A **static** string constant, deliberately: a bundler compiles a *computed*
|
|
107
|
+
* dynamic `import()` into a context module that cannot resolve a runtime path
|
|
108
|
+
* (`Cannot find module 'file:///…'`), so the computed import has to run in a
|
|
109
|
+
* child process whose program text carries **no interpolated runtime value** —
|
|
110
|
+
* the workspace root, the seed and the dependency names all arrive via argv
|
|
111
|
+
* (`process.argv.slice(1)` under `-e`), never spliced into the script.
|
|
112
|
+
*
|
|
113
|
+
* The script mirrors this module's in-process semantics exactly so the two
|
|
114
|
+
* layers are drop-in interchangeable: `pnpmfile.mjs` before `pnpmfile.cjs`
|
|
115
|
+
* (pnpm 11's loader order), the `ERR_MODULE_NOT_FOUND`-for-the-candidate-itself
|
|
116
|
+
* skip discrimination (`err.url` equality), the same hook-locator shapes, the
|
|
117
|
+
* same tolerant threading of returned data (`configOf` / `finiteNumberOr` /
|
|
118
|
+
* `stringArrayOr` clones below), and the same synchronous hook call. Only the
|
|
119
|
+
* *mechanism* failures are reported: the script prints one final line of JSON —
|
|
120
|
+
* `{ ok: true, config }` on success, `{ ok: false, name, message, stack? }`
|
|
121
|
+
* naming the offending dependency on a load/replay failure — and exits through
|
|
122
|
+
* the write callback so the payload is flushed even if a hook left the event
|
|
123
|
+
* loop occupied. The final-line framing tolerates a hook's own `console.log`
|
|
124
|
+
* noise on stdout.
|
|
125
|
+
*/
|
|
126
|
+
const REPLAY_SCRIPT = `
|
|
127
|
+
const [root, seedJson, ...names] = process.argv.slice(1);
|
|
128
|
+
const { pathToFileURL } = await import("node:url");
|
|
129
|
+
const { join } = await import("node:path");
|
|
130
|
+
const isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
131
|
+
const finiteNumberOr = (value, fallback) => (typeof value === "number" && Number.isFinite(value) ? value : fallback);
|
|
132
|
+
const stringArrayOr = (value, fallback) =>
|
|
133
|
+
Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : fallback;
|
|
134
|
+
const configOf = (value, fallback) =>
|
|
135
|
+
isObject(value)
|
|
136
|
+
? {
|
|
137
|
+
catalog: isObject(value.catalog) ? value.catalog : fallback.catalog,
|
|
138
|
+
catalogs: isObject(value.catalogs) ? value.catalogs : fallback.catalogs,
|
|
139
|
+
minimumReleaseAge: finiteNumberOr(value.minimumReleaseAge, fallback.minimumReleaseAge),
|
|
140
|
+
minimumReleaseAgeExclude: stringArrayOr(value.minimumReleaseAgeExclude, fallback.minimumReleaseAgeExclude),
|
|
141
|
+
}
|
|
142
|
+
: fallback;
|
|
143
|
+
const failure = (name, cause) => ({
|
|
144
|
+
ok: false,
|
|
145
|
+
name,
|
|
146
|
+
message: cause instanceof Error ? cause.message : String(cause),
|
|
147
|
+
...(cause instanceof Error && typeof cause.stack === "string" ? { stack: cause.stack } : {}),
|
|
148
|
+
});
|
|
149
|
+
const replay = async () => {
|
|
150
|
+
let catalog = {};
|
|
151
|
+
const catalogs = {};
|
|
152
|
+
for (const [name, entries] of Object.entries(JSON.parse(seedJson))) {
|
|
153
|
+
if (name === "default") catalog = { ...entries };
|
|
154
|
+
else catalogs[name] = { ...entries };
|
|
155
|
+
}
|
|
156
|
+
let config = { catalog, catalogs, minimumReleaseAge: undefined, minimumReleaseAgeExclude: undefined };
|
|
157
|
+
for (const name of names) {
|
|
158
|
+
let loaded;
|
|
159
|
+
let found = false;
|
|
160
|
+
for (const filename of ["pnpmfile.mjs", "pnpmfile.cjs"]) {
|
|
161
|
+
const candidate = pathToFileURL(join(root, "node_modules", ".pnpm-config", name, filename)).href;
|
|
162
|
+
try {
|
|
163
|
+
loaded = await import(candidate);
|
|
164
|
+
found = true;
|
|
165
|
+
break;
|
|
166
|
+
} catch (cause) {
|
|
167
|
+
const url =
|
|
168
|
+
isObject(cause) && cause.code === "ERR_MODULE_NOT_FOUND" && typeof cause.url === "string"
|
|
169
|
+
? cause.url
|
|
170
|
+
: undefined;
|
|
171
|
+
if (url === candidate) continue;
|
|
172
|
+
return failure(name, cause);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!found) continue;
|
|
176
|
+
let hook;
|
|
177
|
+
for (const candidate of [loaded, isObject(loaded) ? loaded.default : undefined]) {
|
|
178
|
+
if (!isObject(candidate)) continue;
|
|
179
|
+
if (isObject(candidate.hooks) && typeof candidate.hooks.updateConfig === "function") {
|
|
180
|
+
hook = candidate.hooks.updateConfig;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (typeof candidate.updateConfig === "function") {
|
|
184
|
+
hook = candidate.updateConfig;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (hook === undefined) continue;
|
|
189
|
+
try {
|
|
190
|
+
config = configOf(hook(config), config);
|
|
191
|
+
} catch (cause) {
|
|
192
|
+
return failure(name, cause);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, config };
|
|
196
|
+
};
|
|
197
|
+
const payload = await replay();
|
|
198
|
+
process.stdout.write("\\n" + JSON.stringify(payload) + "\\n", () => process.exit(0));
|
|
199
|
+
`;
|
|
200
|
+
/**
|
|
201
|
+
* The ceiling on one subprocess replay. A config dependency's `updateConfig`
|
|
202
|
+
* reads and rewrites a config object — no install work, no network — so thirty
|
|
203
|
+
* seconds is generous. Without a ceiling a pnpmfile that loops or awaits a
|
|
204
|
+
* promise that never settles hangs `inject`, and through it the one memoized
|
|
205
|
+
* {@link WorkspaceCatalogs} assemble pass every catalog read blocks on. Expiry
|
|
206
|
+
* kills the child (`Run` scopes it) and surfaces as a `CommandFailedError`
|
|
207
|
+
* through the same typed transport-failure path as any other mechanism failure.
|
|
208
|
+
*/
|
|
209
|
+
const REPLAY_TIMEOUT = Duration.seconds(30);
|
|
210
|
+
/**
|
|
211
|
+
* The subprocess protocol payload — the child's final stdout line, framed and
|
|
212
|
+
* parsed by `Run.jsonLine`. The envelope is strict (a payload without a usable
|
|
213
|
+
* `ok` discriminant is a mechanism failure, typed); the `config` slice inside a
|
|
214
|
+
* success stays `Unknown` because a hook's returned *data* is tolerantly
|
|
215
|
+
* threaded (`configOf`), never fatal.
|
|
216
|
+
*/
|
|
217
|
+
const ReplayPayload = Schema.Union([Schema.Struct({
|
|
218
|
+
ok: Schema.Literal(true),
|
|
219
|
+
config: Schema.Unknown
|
|
220
|
+
}), Schema.Struct({
|
|
221
|
+
ok: Schema.Literal(false),
|
|
222
|
+
name: Schema.optionalKey(Schema.String),
|
|
223
|
+
message: Schema.optionalKey(Schema.String),
|
|
224
|
+
stack: Schema.optionalKey(Schema.String)
|
|
225
|
+
})]);
|
|
226
|
+
/** Rebuild the subprocess's serialized failure as an `Error`, preserving the child-side stack when it carried one. */
|
|
227
|
+
const replayFailureCause = (payload) => {
|
|
228
|
+
const error = new Error(payload.message ?? "config dependency hook replay failed");
|
|
229
|
+
if (payload.stack !== void 0) error.stack = payload.stack;
|
|
230
|
+
return error;
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
100
233
|
* Replays a workspace's `configDependencies` `updateConfig` hooks over the inline
|
|
101
234
|
* catalogs — the opt-in seam that lets hook-injected catalogs participate in
|
|
102
235
|
* assembly.
|
|
@@ -189,6 +322,95 @@ var ConfigDependencyHooks = class ConfigDependencyHooks extends Context.Service(
|
|
|
189
322
|
releaseAge: releaseAgeOf(config)
|
|
190
323
|
};
|
|
191
324
|
}) });
|
|
325
|
+
/**
|
|
326
|
+
* The subprocess layer: replays each config dependency's pnpmfile in a `node`
|
|
327
|
+
* child process instead of an in-process dynamic `import()`, with identical
|
|
328
|
+
* typed semantics to {@link ConfigDependencyHooks.layerLive} — the two are
|
|
329
|
+
* drop-in interchangeable.
|
|
330
|
+
*
|
|
331
|
+
* @remarks
|
|
332
|
+
* `layerLive` computes the `import()` path at runtime, and a bundler (rspack,
|
|
333
|
+
* for one) compiles a *computed* dynamic import into a context module that
|
|
334
|
+
* throws `Cannot find module 'file:///…'` at runtime — so in any bundled
|
|
335
|
+
* consumer, a GitHub Action above all, the in-process replay is unreachable.
|
|
336
|
+
* This layer keeps every computed load out of the bundle graph: the replay
|
|
337
|
+
* program is a **static** string constant passed via argv
|
|
338
|
+
* (`node --input-type=module -e <script> <root> <seed> <...names>`), and the
|
|
339
|
+
* child process performs the computed imports where no bundler rewrote them.
|
|
340
|
+
* A subprocess also keeps config-dependency code out of the consumer's own
|
|
341
|
+
* process.
|
|
342
|
+
*
|
|
343
|
+
* The contract's semantics are unchanged, not the downstream fail-open shape:
|
|
344
|
+
* an empty `configDependencies` returns the seed without spawning anything; a
|
|
345
|
+
* `..` path segment in a dependency name fails typed **before** any spawn; a
|
|
346
|
+
* missing pnpmfile (neither `.mjs` nor `.cjs`) is the one legitimate skip,
|
|
347
|
+
* discriminated inside the child by `err.url` equality exactly as
|
|
348
|
+
* `layerLive` discriminates in process; any other load failure — a syntax
|
|
349
|
+
* error, a throwing top level, an `ERR_MODULE_NOT_FOUND` for a module the
|
|
350
|
+
* pnpmfile itself imports — and a hook that throws when called are serialized
|
|
351
|
+
* back per-name and surface typed as a `hooks`-source `CatalogAssemblyError`
|
|
352
|
+
* naming that dependency. A hook's returned *data* stays tolerantly threaded
|
|
353
|
+
* (last well-formed write wins), never fatal. Spawn and transport failures —
|
|
354
|
+
* `node` absent, a non-zero exit without a result payload, unparseable
|
|
355
|
+
* output — fail typed too, never a defect and never a silent skip.
|
|
356
|
+
*
|
|
357
|
+
* Two bounds this layer imposes that `layerLive` cannot: the replay is
|
|
358
|
+
* given thirty seconds (a pnpmfile that loops or awaits forever fails typed
|
|
359
|
+
* instead of hanging the memoized assemble pass — a subprocess is killable,
|
|
360
|
+
* while `layerLive`'s in-process synchronous hook call is not interruptible
|
|
361
|
+
* by any means, so the asymmetry is inherent, not a parity violation), and
|
|
362
|
+
* the child's stdout is captured under `Run.jsonLine`'s 16 MiB default
|
|
363
|
+
* ceiling (a hook that logs more than that fails typed as `tooLarge`, where
|
|
364
|
+
* `layerLive` — which captures nothing — would succeed).
|
|
365
|
+
*
|
|
366
|
+
* Catalog folding and normalization stay in the **parent** (the same
|
|
367
|
+
* `@pnpm/catalogs`-derived path `layerLive` uses); the child returns only the
|
|
368
|
+
* raw threaded config slice, since the script cannot import kit code.
|
|
369
|
+
*
|
|
370
|
+
* Requires core's `ChildProcessSpawner`, resolved when the layer is built —
|
|
371
|
+
* the consumer provides it once at the edge (`@effect/platform-node`'s
|
|
372
|
+
* `NodeServices.layer`), the same discharge `@effected/git` uses. Wired by
|
|
373
|
+
* `WorkspaceCatalogs.layerWithConfigDependenciesSubprocess` /
|
|
374
|
+
* `Workspaces.layerWithConfigDependenciesSubprocess`.
|
|
375
|
+
*/
|
|
376
|
+
static layerSubprocess = Layer.effect(ConfigDependencyHooks, Effect.gen(function* () {
|
|
377
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
378
|
+
return { inject: (root, configDependencies, seed) => Effect.gen(function* () {
|
|
379
|
+
const names = Object.keys(configDependencies);
|
|
380
|
+
if (names.length === 0) return {
|
|
381
|
+
catalogs: seed,
|
|
382
|
+
releaseAge: {}
|
|
383
|
+
};
|
|
384
|
+
for (const name of names) if (hasTraversalSegment(name)) return yield* Effect.fail(new CatalogAssemblyError({
|
|
385
|
+
source: "hooks",
|
|
386
|
+
path: name,
|
|
387
|
+
cause: /* @__PURE__ */ new Error(`config dependency name has a '..' path segment: ${name}`)
|
|
388
|
+
}));
|
|
389
|
+
const command = ChildProcess.make("node", [
|
|
390
|
+
"--input-type=module",
|
|
391
|
+
"-e",
|
|
392
|
+
REPLAY_SCRIPT,
|
|
393
|
+
root,
|
|
394
|
+
JSON.stringify(seed),
|
|
395
|
+
...names
|
|
396
|
+
]);
|
|
397
|
+
const payload = yield* Run.jsonLine(command, ReplayPayload, { timeout: REPLAY_TIMEOUT }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.catch((cause) => Effect.fail(new CatalogAssemblyError({
|
|
398
|
+
source: "hooks",
|
|
399
|
+
path: root,
|
|
400
|
+
cause
|
|
401
|
+
}))));
|
|
402
|
+
if (payload.ok === false) return yield* Effect.fail(new CatalogAssemblyError({
|
|
403
|
+
source: "hooks",
|
|
404
|
+
path: payload.name ?? root,
|
|
405
|
+
cause: replayFailureCause(payload)
|
|
406
|
+
}));
|
|
407
|
+
const config = configOf(payload.config, seedToConfig(seed));
|
|
408
|
+
return {
|
|
409
|
+
catalogs: configToEntries(config),
|
|
410
|
+
releaseAge: releaseAgeOf(config)
|
|
411
|
+
};
|
|
412
|
+
}) };
|
|
413
|
+
}));
|
|
192
414
|
};
|
|
193
415
|
|
|
194
416
|
//#endregion
|
package/README.md
CHANGED
|
@@ -223,6 +223,7 @@ A name miss in the derived `getPackage` fails with the service's own typed `Pack
|
|
|
223
223
|
## Features
|
|
224
224
|
|
|
225
225
|
- `Workspaces.layer` / `Workspaces.layerWithGit` / `Workspaces.resolvers` — the composite layers, split on requirements rather than feature flags: a filesystem, a filesystem plus a subprocess, and the two `@effected/npm` resolver contracts.
|
|
226
|
+
- `Workspaces.layerWithConfigDependencies` / `Workspaces.layerWithConfigDependenciesSubprocess` — opt in to replaying a pnpm config dependency's pnpmfile hooks, which is what lets catalogs and `releaseAgeGate()` see the entries a hook injects. The default layer runs no config-dependency code at all. The two spellings differ only in where the replay happens: in process, or in a `node` child process for a consumer whose code is bundled (a GitHub Action, say), where the in-process form's computed dynamic import cannot survive the bundler. The subprocess form asks for core's `ChildProcessSpawner`; `WorkspaceCatalogs` carries the same pair.
|
|
226
227
|
- `Workspaces.resolverLayer` / `Workspaces.resolveManifest` — the one-call manifest-resolution path: a fresh, unmemoized layer per call so root discovery follows your cwd, and one-shot resolution of a whole `Manifest` against the real workspace.
|
|
227
228
|
- `WorkspaceRoot` — root discovery from a `cwd`, over `WORKSPACE_MARKERS`.
|
|
228
229
|
- `WorkspaceDiscovery` — package enumeration with a bounded descent for segment-crossing `packages/**` patterns, per-package lookup and the `makeTest` / `layerTest` in-memory test doubles.
|
package/WorkspaceCatalogs.js
CHANGED
|
@@ -428,6 +428,24 @@ var WorkspaceCatalogs = class WorkspaceCatalogs extends Context.Service()("@effe
|
|
|
428
428
|
*/
|
|
429
429
|
static layerWithConfigDependencies = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerLive));
|
|
430
430
|
/**
|
|
431
|
+
* The opt-in layer that replays config-dependency `pnpmfile` hooks in a
|
|
432
|
+
* `node` **child process**: it wires
|
|
433
|
+
* {@link ConfigDependencyHooks.layerSubprocess} in place of the in-process
|
|
434
|
+
* `layerLive`.
|
|
435
|
+
*
|
|
436
|
+
* @remarks
|
|
437
|
+
* Same typed semantics as {@link WorkspaceCatalogs.layerWithConfigDependencies}
|
|
438
|
+
* — the two hook layers are drop-in interchangeable — but the replay's
|
|
439
|
+
* computed dynamic `import()` runs in the subprocess, so it survives bundling
|
|
440
|
+
* (a bundler compiles a computed in-process `import()` into a context module
|
|
441
|
+
* that cannot resolve at runtime — every bundled GitHub Action hits this).
|
|
442
|
+
* The cost is one extra requirement: core's `ChildProcessSpawner`, provided
|
|
443
|
+
* once at the edge (`@effect/platform-node`'s `NodeServices.layer`) — the
|
|
444
|
+
* same sanctioned R-widening as `Workspaces.layerWithGit`. Parameterized, so
|
|
445
|
+
* bind it to a `const` and reuse it.
|
|
446
|
+
*/
|
|
447
|
+
static layerWithConfigDependenciesSubprocess = (options) => Layer.effect(WorkspaceCatalogs, WorkspaceCatalogs.make(options)).pipe(Layer.provide(ConfigDependencyHooks.layerSubprocess));
|
|
448
|
+
/**
|
|
431
449
|
* A test double satisfying the full {@link WorkspaceCatalogsShape} with no
|
|
432
450
|
* filesystem, lockfile read, or hook replay.
|
|
433
451
|
*
|
package/WorkspaceDiscovery.js
CHANGED
|
@@ -259,6 +259,15 @@ var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@ef
|
|
|
259
259
|
for (const entry of index) if (filePath.startsWith(entry.prefix)) return Option.some(entry.package);
|
|
260
260
|
return Option.none();
|
|
261
261
|
};
|
|
262
|
+
const packageIndexes = /* @__PURE__ */ new WeakMap();
|
|
263
|
+
const packagesByName = (all) => {
|
|
264
|
+
const cached = packageIndexes.get(all);
|
|
265
|
+
if (cached !== void 0) return cached;
|
|
266
|
+
const index = /* @__PURE__ */ new Map();
|
|
267
|
+
for (const pkg of all) if (!index.has(pkg.name)) index.set(pkg.name, pkg);
|
|
268
|
+
packageIndexes.set(all, index);
|
|
269
|
+
return index;
|
|
270
|
+
};
|
|
262
271
|
return {
|
|
263
272
|
info: Effect.fn("WorkspaceDiscovery.info")(function* () {
|
|
264
273
|
return (yield* memo).info;
|
|
@@ -272,7 +281,7 @@ var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@ef
|
|
|
272
281
|
}),
|
|
273
282
|
getPackage: Effect.fn("WorkspaceDiscovery.getPackage")(function* (name) {
|
|
274
283
|
const all = yield* packages;
|
|
275
|
-
const found = all.
|
|
284
|
+
const found = packagesByName(all).get(name);
|
|
276
285
|
if (found !== void 0) return found;
|
|
277
286
|
return yield* Effect.fail(new PackageNotFoundError({
|
|
278
287
|
name,
|
|
@@ -436,7 +445,16 @@ var WorkspaceDiscovery = class WorkspaceDiscovery extends Context.Service()("@ef
|
|
|
436
445
|
*/
|
|
437
446
|
static workspaceResolver = Layer.effect(WorkspaceResolver, Effect.gen(function* () {
|
|
438
447
|
const discovery = yield* WorkspaceDiscovery;
|
|
439
|
-
|
|
448
|
+
const versionIndexes = /* @__PURE__ */ new WeakMap();
|
|
449
|
+
const versionsByName = (all) => {
|
|
450
|
+
const cached = versionIndexes.get(all);
|
|
451
|
+
if (cached !== void 0) return cached;
|
|
452
|
+
const index = /* @__PURE__ */ new Map();
|
|
453
|
+
for (const pkg of all) if (!index.has(pkg.name)) index.set(pkg.name, pkg.version);
|
|
454
|
+
versionIndexes.set(all, index);
|
|
455
|
+
return index;
|
|
456
|
+
};
|
|
457
|
+
return { versionOf: (packageName) => discovery.listPackages().pipe(Effect.map((all) => Option.fromUndefinedOr(versionsByName(all).get(packageName))), Effect.mapError((cause) => new DependencyResolutionError({
|
|
440
458
|
specifier: `workspace:${packageName}`,
|
|
441
459
|
cause
|
|
442
460
|
}))) };
|
package/Workspaces.js
CHANGED
|
@@ -26,6 +26,7 @@ const layerWithGit = (options) => {
|
|
|
26
26
|
};
|
|
27
27
|
const resolvers = Layer.mergeAll(WorkspaceCatalogs.catalogResolver, WorkspaceDiscovery.workspaceResolver);
|
|
28
28
|
const layerWithConfigDependencies = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependencies);
|
|
29
|
+
const layerWithConfigDependenciesSubprocess = (options) => compose(options, WorkspaceCatalogs.layerWithConfigDependenciesSubprocess);
|
|
29
30
|
const resolverLayer = (options) => resolvers.pipe(Layer.provide(layerWithConfigDependencies(options)));
|
|
30
31
|
const resolveManifest = Effect.fn("Workspaces.resolveManifest")(function* (manifest, options) {
|
|
31
32
|
return yield* manifest.resolve().pipe(Effect.provide(resolverLayer(options)));
|
|
@@ -107,6 +108,32 @@ var Workspaces = class {
|
|
|
107
108
|
*/
|
|
108
109
|
static layerWithConfigDependencies = layerWithConfigDependencies;
|
|
109
110
|
/**
|
|
111
|
+
* The git-free composite with config-dependency hook replay in a `node`
|
|
112
|
+
* **child process** —
|
|
113
|
+
* {@link WorkspaceCatalogs.layerWithConfigDependenciesSubprocess} in place of
|
|
114
|
+
* the in-process replay.
|
|
115
|
+
*
|
|
116
|
+
* @remarks
|
|
117
|
+
* Same typed semantics as {@link Workspaces.layerWithConfigDependencies}; the
|
|
118
|
+
* difference is mechanism, and it matters in exactly one environment class: a
|
|
119
|
+
* **bundled** consumer. The in-process replay's computed dynamic `import()`
|
|
120
|
+
* is compiled by bundlers (rspack among them) into a context module that
|
|
121
|
+
* throws `Cannot find module 'file:///…'` at runtime, which makes
|
|
122
|
+
* `WorkspaceCatalogs.releaseAgeGate()` unreachable from any bundled GitHub
|
|
123
|
+
* Action. Here the computed import runs inside a `node` child process whose
|
|
124
|
+
* program text is a static string handed over argv, so nothing computed
|
|
125
|
+
* enters the bundle graph.
|
|
126
|
+
*
|
|
127
|
+
* The extra requirement is core's `ChildProcessSpawner`, provided once at
|
|
128
|
+
* the edge (`@effect/platform-node`'s `NodeServices.layer`) — the same
|
|
129
|
+
* sanctioned R-widening as {@link Workspaces.layerWithGit}, and the reason
|
|
130
|
+
* this is a separate composite rather than a flag: a consumer that keeps the
|
|
131
|
+
* in-process replay should not have to be able to spawn a subprocess.
|
|
132
|
+
*
|
|
133
|
+
* **Bind the result to a `const`.**
|
|
134
|
+
*/
|
|
135
|
+
static layerWithConfigDependenciesSubprocess = layerWithConfigDependenciesSubprocess;
|
|
136
|
+
/**
|
|
110
137
|
* The git-free composite plus {@link ChangeDetector} and
|
|
111
138
|
* {@link WorkspaceSnapshots}, over `@effected/git`'s `Git` service.
|
|
112
139
|
*
|
package/index.d.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { CatalogAssemblyError, CatalogResolver, DependencyResolutionError, Manif
|
|
|
4
4
|
import { GlobPattern } from "@effected/glob";
|
|
5
5
|
import { Lockfile, LockfileFramingError, LockfileIntegrity, LockfileParseError, ResolvedPackage, WorkspaceManifest } from "@effected/lockfiles";
|
|
6
6
|
import { Package } from "@effected/package-json";
|
|
7
|
-
import { LocalExec } from "@effected/commands";
|
|
8
7
|
import { ChildProcessSpawner } from "effect/unstable/process";
|
|
8
|
+
import { LocalExec } from "@effected/commands";
|
|
9
9
|
//#region src/WorkspacePackage.d.ts
|
|
10
10
|
declare const PublishConfig_base: Schema.Class<PublishConfig, Schema.Struct<{
|
|
11
11
|
/** Scoped-package visibility. Its presence overrides `private`. */
|
|
@@ -896,6 +896,58 @@ declare class ConfigDependencyHooks extends ConfigDependencyHooks_base {
|
|
|
896
896
|
* `WorkspaceCatalogs.layerWithConfigDependencies`.
|
|
897
897
|
*/
|
|
898
898
|
static readonly layerLive: Layer.Layer<ConfigDependencyHooks>;
|
|
899
|
+
/**
|
|
900
|
+
* The subprocess layer: replays each config dependency's pnpmfile in a `node`
|
|
901
|
+
* child process instead of an in-process dynamic `import()`, with identical
|
|
902
|
+
* typed semantics to {@link ConfigDependencyHooks.layerLive} — the two are
|
|
903
|
+
* drop-in interchangeable.
|
|
904
|
+
*
|
|
905
|
+
* @remarks
|
|
906
|
+
* `layerLive` computes the `import()` path at runtime, and a bundler (rspack,
|
|
907
|
+
* for one) compiles a *computed* dynamic import into a context module that
|
|
908
|
+
* throws `Cannot find module 'file:///…'` at runtime — so in any bundled
|
|
909
|
+
* consumer, a GitHub Action above all, the in-process replay is unreachable.
|
|
910
|
+
* This layer keeps every computed load out of the bundle graph: the replay
|
|
911
|
+
* program is a **static** string constant passed via argv
|
|
912
|
+
* (`node --input-type=module -e <script> <root> <seed> <...names>`), and the
|
|
913
|
+
* child process performs the computed imports where no bundler rewrote them.
|
|
914
|
+
* A subprocess also keeps config-dependency code out of the consumer's own
|
|
915
|
+
* process.
|
|
916
|
+
*
|
|
917
|
+
* The contract's semantics are unchanged, not the downstream fail-open shape:
|
|
918
|
+
* an empty `configDependencies` returns the seed without spawning anything; a
|
|
919
|
+
* `..` path segment in a dependency name fails typed **before** any spawn; a
|
|
920
|
+
* missing pnpmfile (neither `.mjs` nor `.cjs`) is the one legitimate skip,
|
|
921
|
+
* discriminated inside the child by `err.url` equality exactly as
|
|
922
|
+
* `layerLive` discriminates in process; any other load failure — a syntax
|
|
923
|
+
* error, a throwing top level, an `ERR_MODULE_NOT_FOUND` for a module the
|
|
924
|
+
* pnpmfile itself imports — and a hook that throws when called are serialized
|
|
925
|
+
* back per-name and surface typed as a `hooks`-source `CatalogAssemblyError`
|
|
926
|
+
* naming that dependency. A hook's returned *data* stays tolerantly threaded
|
|
927
|
+
* (last well-formed write wins), never fatal. Spawn and transport failures —
|
|
928
|
+
* `node` absent, a non-zero exit without a result payload, unparseable
|
|
929
|
+
* output — fail typed too, never a defect and never a silent skip.
|
|
930
|
+
*
|
|
931
|
+
* Two bounds this layer imposes that `layerLive` cannot: the replay is
|
|
932
|
+
* given thirty seconds (a pnpmfile that loops or awaits forever fails typed
|
|
933
|
+
* instead of hanging the memoized assemble pass — a subprocess is killable,
|
|
934
|
+
* while `layerLive`'s in-process synchronous hook call is not interruptible
|
|
935
|
+
* by any means, so the asymmetry is inherent, not a parity violation), and
|
|
936
|
+
* the child's stdout is captured under `Run.jsonLine`'s 16 MiB default
|
|
937
|
+
* ceiling (a hook that logs more than that fails typed as `tooLarge`, where
|
|
938
|
+
* `layerLive` — which captures nothing — would succeed).
|
|
939
|
+
*
|
|
940
|
+
* Catalog folding and normalization stay in the **parent** (the same
|
|
941
|
+
* `@pnpm/catalogs`-derived path `layerLive` uses); the child returns only the
|
|
942
|
+
* raw threaded config slice, since the script cannot import kit code.
|
|
943
|
+
*
|
|
944
|
+
* Requires core's `ChildProcessSpawner`, resolved when the layer is built —
|
|
945
|
+
* the consumer provides it once at the edge (`@effect/platform-node`'s
|
|
946
|
+
* `NodeServices.layer`), the same discharge `@effected/git` uses. Wired by
|
|
947
|
+
* `WorkspaceCatalogs.layerWithConfigDependenciesSubprocess` /
|
|
948
|
+
* `Workspaces.layerWithConfigDependenciesSubprocess`.
|
|
949
|
+
*/
|
|
950
|
+
static readonly layerSubprocess: Layer.Layer<ConfigDependencyHooks, never, ChildProcessSpawner.ChildProcessSpawner>;
|
|
899
951
|
}
|
|
900
952
|
//#endregion
|
|
901
953
|
//#region src/DependencyGraph.d.ts
|
|
@@ -2097,6 +2149,24 @@ declare class WorkspaceCatalogs extends WorkspaceCatalogs_base {
|
|
|
2097
2149
|
* Parameterized, so bind it to a `const` and reuse it.
|
|
2098
2150
|
*/
|
|
2099
2151
|
static readonly layerWithConfigDependencies: (options?: WorkspaceCatalogsOptions) => Layer.Layer<WorkspaceCatalogs, never, WorkspaceRoot | LockfileReader | FileSystem.FileSystem | Path.Path>;
|
|
2152
|
+
/**
|
|
2153
|
+
* The opt-in layer that replays config-dependency `pnpmfile` hooks in a
|
|
2154
|
+
* `node` **child process**: it wires
|
|
2155
|
+
* {@link ConfigDependencyHooks.layerSubprocess} in place of the in-process
|
|
2156
|
+
* `layerLive`.
|
|
2157
|
+
*
|
|
2158
|
+
* @remarks
|
|
2159
|
+
* Same typed semantics as {@link WorkspaceCatalogs.layerWithConfigDependencies}
|
|
2160
|
+
* — the two hook layers are drop-in interchangeable — but the replay's
|
|
2161
|
+
* computed dynamic `import()` runs in the subprocess, so it survives bundling
|
|
2162
|
+
* (a bundler compiles a computed in-process `import()` into a context module
|
|
2163
|
+
* that cannot resolve at runtime — every bundled GitHub Action hits this).
|
|
2164
|
+
* The cost is one extra requirement: core's `ChildProcessSpawner`, provided
|
|
2165
|
+
* once at the edge (`@effect/platform-node`'s `NodeServices.layer`) — the
|
|
2166
|
+
* same sanctioned R-widening as `Workspaces.layerWithGit`. Parameterized, so
|
|
2167
|
+
* bind it to a `const` and reuse it.
|
|
2168
|
+
*/
|
|
2169
|
+
static readonly layerWithConfigDependenciesSubprocess: (options?: WorkspaceCatalogsOptions) => Layer.Layer<WorkspaceCatalogs, never, WorkspaceRoot | LockfileReader | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
|
|
2100
2170
|
/**
|
|
2101
2171
|
* A test double satisfying the full {@link WorkspaceCatalogsShape} with no
|
|
2102
2172
|
* filesystem, lockfile read, or hook replay.
|
|
@@ -2569,6 +2639,32 @@ declare class Workspaces {
|
|
|
2569
2639
|
* **Bind the result to a `const`.**
|
|
2570
2640
|
*/
|
|
2571
2641
|
static readonly layerWithConfigDependencies: (options?: WorkspacesOptions) => Layer.Layer<WorkspacesServices, never, FileSystem.FileSystem | Path.Path>;
|
|
2642
|
+
/**
|
|
2643
|
+
* The git-free composite with config-dependency hook replay in a `node`
|
|
2644
|
+
* **child process** —
|
|
2645
|
+
* {@link WorkspaceCatalogs.layerWithConfigDependenciesSubprocess} in place of
|
|
2646
|
+
* the in-process replay.
|
|
2647
|
+
*
|
|
2648
|
+
* @remarks
|
|
2649
|
+
* Same typed semantics as {@link Workspaces.layerWithConfigDependencies}; the
|
|
2650
|
+
* difference is mechanism, and it matters in exactly one environment class: a
|
|
2651
|
+
* **bundled** consumer. The in-process replay's computed dynamic `import()`
|
|
2652
|
+
* is compiled by bundlers (rspack among them) into a context module that
|
|
2653
|
+
* throws `Cannot find module 'file:///…'` at runtime, which makes
|
|
2654
|
+
* `WorkspaceCatalogs.releaseAgeGate()` unreachable from any bundled GitHub
|
|
2655
|
+
* Action. Here the computed import runs inside a `node` child process whose
|
|
2656
|
+
* program text is a static string handed over argv, so nothing computed
|
|
2657
|
+
* enters the bundle graph.
|
|
2658
|
+
*
|
|
2659
|
+
* The extra requirement is core's `ChildProcessSpawner`, provided once at
|
|
2660
|
+
* the edge (`@effect/platform-node`'s `NodeServices.layer`) — the same
|
|
2661
|
+
* sanctioned R-widening as {@link Workspaces.layerWithGit}, and the reason
|
|
2662
|
+
* this is a separate composite rather than a flag: a consumer that keeps the
|
|
2663
|
+
* in-process replay should not have to be able to spawn a subprocess.
|
|
2664
|
+
*
|
|
2665
|
+
* **Bind the result to a `const`.**
|
|
2666
|
+
*/
|
|
2667
|
+
static readonly layerWithConfigDependenciesSubprocess: (options?: WorkspacesOptions) => Layer.Layer<WorkspacesServices, never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner>;
|
|
2572
2668
|
/**
|
|
2573
2669
|
* The git-free composite plus {@link ChangeDetector} and
|
|
2574
2670
|
* {@link WorkspaceSnapshots}, over `@effected/git`'s `Git` service.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effected/workspaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Monorepo workspace tooling as Effect services — root discovery, package enumeration, the dependency graph, package-manager detection, pnpm catalog resolution, lockfile IO and git-based change detection.",
|
|
6
6
|
"keywords": [
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
"./package.json": "./package.json"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@effected/commands": "^0.
|
|
49
|
+
"@effected/commands": "^0.3.0",
|
|
50
50
|
"@effected/git": "^0.5.2",
|
|
51
51
|
"@effected/glob": "^0.2.2",
|
|
52
52
|
"@effected/lockfiles": "^0.3.2",
|
|
53
|
-
"@effected/npm": "^0.8.
|
|
53
|
+
"@effected/npm": "^0.8.3",
|
|
54
54
|
"@effected/package-json": "^0.7.3",
|
|
55
55
|
"@effected/semver": "^0.3.2",
|
|
56
56
|
"@effected/walker": "^0.3.4",
|