@cosmicdrift/kumiko-framework 0.378.0 → 0.380.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/AGENTS.md +4 -1
- package/agents/examples/compose-stacks.md +1 -2
- package/agents/examples/currencies-global.md +2 -3
- package/agents/examples/embedded.md +23 -37
- package/agents/examples/event-sourcing.md +6 -5
- package/agents/examples/legal-pages.md +5 -5
- package/agents/examples/reference-data.md +26 -21
- package/agents/examples/relations.md +2 -1
- package/agents/features.md +4 -4
- package/dist/api/entity-read-access.d.ts +2 -0
- package/dist/api/entity-read-access.js +27 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/server.js +10 -3
- package/dist/api/sse-route.d.ts +5 -0
- package/dist/api/sse-route.js +59 -9
- package/dist/db/apply-entity-event.d.ts +3 -2
- package/dist/db/apply-entity-event.js +1 -1
- package/dist/db/dialect.d.ts +2 -1
- package/dist/db/dialect.js +2 -2
- package/dist/db/entity-table-from-registry.js +0 -2
- package/dist/db/entity-table-meta.d.ts +5 -3
- package/dist/db/entity-table-meta.js +6 -15
- package/dist/db/event-store-executor-write.js +21 -16
- package/dist/db/executor-managed-tables.d.ts +8 -0
- package/dist/db/executor-managed-tables.js +48 -0
- package/dist/db/index.d.ts +6 -4
- package/dist/db/index.js +4 -2
- package/dist/db/money.d.ts +1 -1
- package/dist/db/queries/backfill-pii.js +22 -12
- package/dist/db/queries/database-now.d.ts +5 -0
- package/dist/db/queries/database-now.js +12 -0
- package/dist/db/queries/event-store-orphan-rows.d.ts +8 -0
- package/dist/db/queries/event-store-orphan-rows.js +32 -0
- package/dist/db/query.d.ts +22 -11
- package/dist/db/query.js +41 -36
- package/dist/db/reference-data.d.ts +5 -4
- package/dist/db/reference-data.js +23 -21
- package/dist/db/table-builder.d.ts +2 -3
- package/dist/db/table-builder.js +2 -0
- package/dist/db/table-introspection.d.ts +2 -0
- package/dist/db/table-introspection.js +4 -0
- package/dist/db/tenant-db-runner.d.ts +2 -0
- package/dist/db/tenant-db-runner.js +7 -0
- package/dist/db/tenant-db.d.ts +9 -2
- package/dist/db/tenant-db.js +82 -25
- package/dist/engine/boot-validator/entity-handler.js +9 -4
- package/dist/engine/boot-validator/query-refs.js +8 -0
- package/dist/engine/boot-validator/screens.js +34 -9
- package/dist/engine/extensions/tenant-data.d.ts +2 -2
- package/dist/engine/feature-ui-extensions.js +4 -0
- package/dist/engine/projection-helpers.d.ts +3 -3
- package/dist/engine/registry-state.js +2 -0
- package/dist/engine/registry-validate.js +4 -1
- package/dist/engine/schema-builder.js +18 -4
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +3 -0
- package/dist/engine/soft-delete-cleanup.js +53 -24
- package/dist/engine/steps/unsafe-projection-delete.js +4 -2
- package/dist/entrypoint/index.d.ts +1 -3
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +4 -1
- package/dist/errors/serialize.d.ts +1 -0
- package/dist/errors/serialize.js +9 -5
- package/dist/errors/write-error-info.js +3 -2
- package/dist/errors/zod-bridge.js +7 -0
- package/dist/es-ops/operations-schema.d.ts +1 -1
- package/dist/event-store/archive.d.ts +1 -1
- package/dist/event-store/events-schema.d.ts +1 -1
- package/dist/event-store/index.d.ts +1 -0
- package/dist/event-store/index.js +1 -0
- package/dist/event-store/rebuild-dead-letter.d.ts +1 -1
- package/dist/event-store/snapshot.d.ts +1 -1
- package/dist/event-store/upcaster-dead-letter.d.ts +1 -1
- package/dist/files/action-form-upload.d.ts +16 -0
- package/dist/files/action-form-upload.js +45 -0
- package/dist/files/bind-pending-file-refs.d.ts +14 -0
- package/dist/files/bind-pending-file-refs.js +44 -4
- package/dist/files/create-file-ref.d.ts +3 -0
- package/dist/files/create-file-ref.js +41 -13
- package/dist/files/file-routes.js +29 -2
- package/dist/files/index.d.ts +2 -1
- package/dist/files/index.js +1 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +3 -2
- package/dist/files/purge-expired-file-ref-bytes.js +11 -5
- package/dist/files/storage-tracking.d.ts +1 -1
- package/dist/files/upload-ledger.d.ts +2 -2
- package/dist/internal/testing.d.ts +7 -0
- package/dist/internal/testing.js +14 -1
- package/dist/migrations/pending-rebuilds.d.ts +1 -1
- package/dist/migrations/pii-event-backfill.d.ts +1 -1
- package/dist/migrations/pii-event-backfill.js +14 -7
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +14 -3
- package/dist/pipeline/cascade-handler.js +79 -10
- package/dist/pipeline/dispatch-shared.js +1 -9
- package/dist/pipeline/event-consumer-state.d.ts +1 -1
- package/dist/pipeline/msp-rebuild.js +5 -3
- package/dist/pipeline/projection-rebuild.js +4 -2
- package/dist/pipeline/projection-state.d.ts +1 -1
- package/dist/pipeline/projections-runner.js +2 -1
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/index.js +1 -1
- package/dist/rate-limit/middleware.d.ts +4 -0
- package/dist/rate-limit/middleware.js +5 -2
- package/dist/ui-types/index.d.ts +2 -2
- package/dist/ui-types/index.js +2 -2
- package/dist/ui-types/list-row-meta.d.ts +3 -0
- package/dist/ui-types/list-row-meta.js +12 -1
- package/kumiko-es-tables.json +6 -0
- package/package.json +4 -3
- package/src/changes.json +117 -0
- package/src/scripts/codemod/README.md +20 -0
- package/src/scripts/codemod/codemod-support.ts +1 -1
- package/src/scripts/codemod/feature-layout.ts +416 -0
- package/src/scripts/codemod/package-merge.ts +0 -11
package/src/changes.json
CHANGED
|
@@ -1,4 +1,121 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.380.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Writes compile only for Writable tables or capable runners",
|
|
6
|
+
"detail": "The negative ExecutorOnly brand is replaced by positive brands. table(), defineUnmanagedTable and deriveEntityTableMeta with source \"unmanaged\" return Writable tables; buildEntityTable and r.projection tables do not. insertOne, updateMany, deleteMany, the upsert and counter helpers and the TenantDb and db.global() write methods accept a Writable table, or any table when the runner is an ExecutorManagedRunner or ExecutorManagedTenantDb. Widening a table to SchemaTable or EntityTableMeta drops the brand, so erased tables no longer compile. Projection apply functions receive tx as ExecutorManagedRunner. The runtime lock is unchanged.",
|
|
7
|
+
"migration": "Apply functions that annotate `tx` as `DbRunner`, inline or standalone, type it as `ExecutorManagedRunner` (or drop the annotation / annotate the function with SingleStreamApplyFn/MultiStreamApplyFn). Own write helpers take `WritableTable` for direct-write tables, or `ExecutorManagedRunner` / `ExecutorManagedTenantDb` when they write event-fed tables from an apply, rebuild or tenant-destroy context, instead of `SchemaTable`/`EntityTableMeta`. Tables annotated as `SchemaTable` lose the `Writable` brand; drop the annotation or add `& Writable`. Tenant-destroy hooks keep using the db they receive. Affects kumiko-enterprise, solon, money-horse, kumiko-studio, publicstatus."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.379.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "PII backfill encrypts jsonb object and array fields",
|
|
13
|
+
"detail": "runPiiEventBackfill and backfillEventPiiEncryption now retro-encrypt plaintext jsonb objects and arrays of personal fields and personal jsonb scalars, and createPiiBackfillStateTable bootstraps under an advisory lock so concurrent first boots no longer skip the ALTER. Completed backfill states rerun once because the transform version changed."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.379.0",
|
|
17
|
+
"type": "improvement",
|
|
18
|
+
"title": "applyEntityEvent is exported from the db barrel, extension sources accept auto-verbs",
|
|
19
|
+
"detail": "applyEntityEvent can be used by extendEntityProjection apply handlers to project stored entity events of a foreign aggregate type. validateProjectionApplyKeys accepts the entity auto-verbs for an extension source that is not a registered entity."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.379.0",
|
|
23
|
+
"type": "breaking",
|
|
24
|
+
"title": "SSE entity signals follow the read access of the connection",
|
|
25
|
+
"detail": "A signed-in connection receives an entity signal only when a query reading that entity lets one of its roles in (SseRouteOptions.entityReadAccess, built by collectEntityReadAccess). Entities without a reading query no longer go on the channel. When the reading access depends on a row or owner filter, the signal goes out without id and version, so a client refetches its query instead of learning which row changed.",
|
|
26
|
+
"migration": "Apps that rely on a signal for an entity no query exposes to the user must add a query for that entity with a matching access rule. Custom buildServer callers that build SseRouteOptions themselves must pass entityReadAccess."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.379.0",
|
|
30
|
+
"type": "fix",
|
|
31
|
+
"title": "Cause details only with NODE_ENV development or test",
|
|
32
|
+
"detail": "internal_error responses and write error infos carry the cause snapshot (name, message, stack) only when NODE_ENV is explicitly development or test. A missing or misspelled NODE_ENV no longer exposes stack traces, paths or upstream URLs. kumiko-dev starts the server with NODE_ENV=development when none is set, so local dev keeps the details; a dev script that runs the server directly (bun --watch bin/server.ts) must set NODE_ENV=development itself to see them."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"version": "0.379.0",
|
|
36
|
+
"type": "breaking",
|
|
37
|
+
"title": "Global IP rate-limit middleware default matches runProdApp",
|
|
38
|
+
"detail": "globalIpRateLimit defaults to 600 requests per 60 seconds (was 1000). DEFAULT_GLOBAL_RATE_LIMIT is exported from the api barrel and shared with server-runtime. authEndpointRateLimit keeps its default of 5 per 60 seconds.",
|
|
39
|
+
"migration": "Apps that use globalIpRateLimit directly and relied on 1000 pass limit explicitly. runProdApp users see no change."
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"version": "0.379.0",
|
|
43
|
+
"type": "improvement",
|
|
44
|
+
"title": "Boot warning when /metrics is off in production",
|
|
45
|
+
"detail": "buildServer logs a warning in production when no metrics option is set, which happens when PROMETHEUS_METRICS_TOKEN is unset."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"version": "0.379.0",
|
|
49
|
+
"type": "improvement",
|
|
50
|
+
"title": "Package ships its event-store table catalog",
|
|
51
|
+
"detail": "kumiko-es-tables.json is included in the published files; no runtime change."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"version": "0.379.0",
|
|
55
|
+
"type": "breaking",
|
|
56
|
+
"title": "Direct writes on event-fed tables throw at runtime",
|
|
57
|
+
"detail": "buildEntityTable and r.projection mark their tables. insertOne, updateMany, deleteMany, the upsert and counter helpers, ctx.db write methods and db.global() writes throw AccessDeniedError on a marked table unless the runner carries the framework write capability. The executor, projection apply, rebuild, unsafeProjectionDelete, tenant-destroy hooks and deleteRowsWithoutReplayableStream hold it; ctx.db.unsafeRaw() never does. Retention deletes only rows whose stream is gone or archived. The cascade delete hook, the soft-delete purge jobs and seedReferenceData now write through the executor, so their effect survives a rebuild; cascade children are written as the system user, and softDelete children are soft-deleted instead of hard-deleted. The direct-entity-writes guard has no exceptions left.",
|
|
58
|
+
"migration": "Write event-fed rows through the executor (r.crud, ctx.write, createEventStoreExecutor). Projection apply functions must use the db they receive. Test seeds and teardown use seedRow, seedRows, updateRows, deleteRows and clearTables from kumiko-testing instead of hand-written wipe helpers. Do not pass ctx.db.unsafeRaw() to code that deletes event-fed rows; pass ctx.db inside a tenant-destroy hook. Retention code calls deleteRowsWithoutReplayableStream after pruning or archiving the events. seedReferenceData takes the registry instead of a table map. Affects kumiko-enterprise, kumiko-studio and solon."
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"version": "0.379.0",
|
|
62
|
+
"type": "fix",
|
|
63
|
+
"title": "Tests pin the money units of form derivations",
|
|
64
|
+
"detail": "form-derivations tests now cover realistic money values (1234,56 EUR plus 234,57 EUR, 0,10 plus 0,20) and minor-unit list cells; no behaviour change.",
|
|
65
|
+
"migration": "None."
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"version": "0.379.0",
|
|
69
|
+
"type": "fix",
|
|
70
|
+
"title": "A slow SSE client no longer buffers without bound",
|
|
71
|
+
"detail": "The SSE route tracks the writes a client has not consumed. A client whose queued frames make no progress for SSE_STALLED_CLIENT_TIMEOUT_MS (one heartbeat interval), or that queues SSE_MAX_PENDING_WRITES_PER_CLIENT frames, is aborted and unregistered; it reconnects and resyncs. A large broadcast batch does not drop a client that keeps reading, and one failing client no longer stops the broadcast to the others. SseRouteOptions.now makes the clock injectable."
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"version": "0.379.0",
|
|
75
|
+
"type": "improvement",
|
|
76
|
+
"title": "Deprecations name their removal release",
|
|
77
|
+
"detail": "The three @deprecated APIs without a target release (the minor-units money accessor and the landingPath props of the invite and signup screens) now say they are removed in 0.390.0."
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"version": "0.379.0",
|
|
81
|
+
"type": "breaking",
|
|
82
|
+
"title": "buildEntityTableMeta and observabilityOptions removed",
|
|
83
|
+
"detail": "buildEntityTableMeta was a deprecated alias of deriveEntityTableMeta. The deprecated observabilityOptions option of the entrypoint and the prod and worker runners is gone as well, it had no effect since the HTTP route label no longer carries a query string.",
|
|
84
|
+
"migration": "Rename buildEntityTableMeta to deriveEntityTableMeta (same signature). Delete observabilityOptions from the entrypoint and runner options, nothing replaces it."
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"version": "0.379.0",
|
|
88
|
+
"type": "improvement",
|
|
89
|
+
"title": "readDatabaseNow(db) in framework/db",
|
|
90
|
+
"detail": "Typed read of the Postgres clock for a TenantDb without unsafeRaw. Inside a transaction now() is the transaction start, so repeated reads agree."
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"version": "0.379.0",
|
|
94
|
+
"type": "fix",
|
|
95
|
+
"title": "SaveContext.event no longer reaches JSON responses",
|
|
96
|
+
"detail": "The event echoed on the SaveContext of create, update and restore is non-enumerable, so a handler returning the save result no longer leaks event data (PII) in its response; hooks and projections still read it by property."
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"version": "0.379.0",
|
|
100
|
+
"type": "fix",
|
|
101
|
+
"title": "Dashboard navigate params may bind a record",
|
|
102
|
+
"detail": "The boot validator accepts navigate params on a dashboard row action that has a record binding (record.param) but no filter; it still rejects params when the dashboard reads no filter, time range or record binding."
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"version": "0.379.0",
|
|
106
|
+
"type": "breaking",
|
|
107
|
+
"title": "Re-export shim packages removed",
|
|
108
|
+
"detail": "The shims headless, dispatcher-live, renderer-web, http and repo-manifest are gone. Their code lives in kumiko-renderer (/headless, /live, /web), kumiko-framework (/http) and kumiko-guards (/repo-manifest).",
|
|
109
|
+
"migration": "Run bun kumiko-upgrade --apply, or bun kumiko codemod package-merge . --write. It rewrites the imports and package.json dependencies of all five shims to their target packages.",
|
|
110
|
+
"codemod": "scripts/codemod/package-merge.ts"
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"version": "0.379.0",
|
|
114
|
+
"type": "breaking",
|
|
115
|
+
"title": "TenantDb.databaseNow",
|
|
116
|
+
"detail": "TenantDb has a databaseNow() method returning Postgres now(); custom TenantDb implementations must add it.",
|
|
117
|
+
"migration": "Add databaseNow(): Promise<Date> to hand-built TenantDb objects and mocks, returning the Postgres now()."
|
|
118
|
+
},
|
|
2
119
|
{
|
|
3
120
|
"version": "0.378.0",
|
|
4
121
|
"type": "improvement",
|
|
@@ -165,3 +165,23 @@ bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/package-merge
|
|
|
165
165
|
|
|
166
166
|
Runs via `bun kumiko-upgrade --apply`. Without a mode flag it writes. `--check` exits 1 while any
|
|
167
167
|
site is left. Run your package manager's install afterwards to refresh the lockfile.
|
|
168
|
+
|
|
169
|
+
## feature-layout.ts
|
|
170
|
+
|
|
171
|
+
Applies the feature layout rule "the runtime of a file follows from its path": everything
|
|
172
|
+
under `src/features/<feature>/web/` is client code, the rest of the feature is server or
|
|
173
|
+
isomorphic. Every file that carries a runtime-client marker outside `web/` moves to
|
|
174
|
+
`<feature>/web/` (sub-structure kept), the relative imports, re-exports, `import()` calls and
|
|
175
|
+
`mock.module` strings that point at it or out of it are rewritten, `package.json` export
|
|
176
|
+
targets follow, and the marker is dropped. A marker the path already implies (client in
|
|
177
|
+
`web/`, test in `__tests__/`) is only removed. Idempotent.
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
bun node_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/feature-layout.ts <dir> [--check | --dry-run | --write] [--no-format]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Features are found as `src/features/<feature>/` and `bundled-features/src/<feature>/` below
|
|
184
|
+
`<dir>`; `--feature-dir <dir>` names a directory that is itself one feature and
|
|
185
|
+
`--features-root <dir>` one whose children are features. A target that already exists in
|
|
186
|
+
`web/` and a marker no path rule covers (for example `dev` outside `dev/`) are reported as
|
|
187
|
+
`manual` and left alone. `--check` exits 1 while anything is left.
|
|
@@ -180,7 +180,7 @@ function findBiomeBinary(startDir: string): string | undefined {
|
|
|
180
180
|
|
|
181
181
|
const FORMAT_BATCH_SIZE = 50;
|
|
182
182
|
|
|
183
|
-
async function formatWithBiome(
|
|
183
|
+
export async function formatWithBiome(
|
|
184
184
|
codemodName: string,
|
|
185
185
|
files: readonly string[],
|
|
186
186
|
cwd: string,
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// feature-layout — the runtime of a feature file follows from its path: <feature>/web/** is client
|
|
3
|
+
// code, everything else is server or isomorphic. This moves every file marked as runtime client of a
|
|
4
|
+
// feature that lies outside web/ into <feature>/web/ (sub-structure kept), rewrites each relative
|
|
5
|
+
// import / re-export / import() / mock.module that points at it or out of it, rewrites package.json
|
|
6
|
+
// export targets, and drops the marker. A marker the path already implies (client in web/, test in
|
|
7
|
+
// __tests__/, dev in dev/, tooling in scripts/) is only removed. A target that already exists, or a
|
|
8
|
+
// marker no path rule covers, is reported as manual and left alone.
|
|
9
|
+
//
|
|
10
|
+
// Features are found as src/features/<feature>/ and bundled-features/src/<feature>/ below the target
|
|
11
|
+
// directories; --feature-dir <dir> names a directory that is itself one feature, --features-root <dir>
|
|
12
|
+
// a directory whose children are features.
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// bun scripts/codemod/feature-layout.ts <targetDir...> [--feature-dir <dir>] [--features-root <dir>]
|
|
16
|
+
// [--check | --dry-run | --write] [--no-format]
|
|
17
|
+
// Without a mode flag it writes (kumiko-upgrade --apply calls it that way). --check exits 1 while
|
|
18
|
+
// any move, marker or manual site is left; --dry-run only reports.
|
|
19
|
+
|
|
20
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
22
|
+
import { Node, Project } from "ts-morph";
|
|
23
|
+
import {
|
|
24
|
+
applyEdits,
|
|
25
|
+
type CodemodSite,
|
|
26
|
+
formatWithBiome,
|
|
27
|
+
moduleSpecifierLiterals,
|
|
28
|
+
SCRIPT_EXTENSION,
|
|
29
|
+
type TextEdit,
|
|
30
|
+
} from "./codemod-support.ts";
|
|
31
|
+
import { collectSourceFiles } from "./collect-source-files.ts";
|
|
32
|
+
|
|
33
|
+
const MARKER_HEAD_LINES = 8;
|
|
34
|
+
const MARKER_LINE = /^\s*\/\/\s*@runtime(?:\s+(\w+))?\s*$/;
|
|
35
|
+
const WEB_DIR = "web";
|
|
36
|
+
const IMPLICIT_FEATURE_PATTERNS: readonly RegExp[] = [
|
|
37
|
+
/^(.*\/src\/features\/[^/]+)\/(.+)$/,
|
|
38
|
+
/^(.*\/bundled-features\/src\/[^/]+)\/(.+)$/,
|
|
39
|
+
];
|
|
40
|
+
const TEST_PATH = /(?:^|\/)__tests__\/|\.(?:test|integration|e2e|spec)\.[tj]sx?$/;
|
|
41
|
+
|
|
42
|
+
export interface FeatureLayoutOptions {
|
|
43
|
+
readonly featureDirs?: readonly string[];
|
|
44
|
+
readonly featuresRoots?: readonly string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FeatureLayoutMove {
|
|
48
|
+
readonly from: string;
|
|
49
|
+
readonly to: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface FeatureLayoutResult {
|
|
53
|
+
// New content per final path (moved files under their new path).
|
|
54
|
+
readonly writes: ReadonlyMap<string, string>;
|
|
55
|
+
// Old paths that no longer exist after the moves.
|
|
56
|
+
readonly removals: readonly string[];
|
|
57
|
+
readonly moves: readonly FeatureLayoutMove[];
|
|
58
|
+
readonly markersRemoved: readonly string[];
|
|
59
|
+
readonly importRewrites: readonly CodemodSite[];
|
|
60
|
+
readonly manual: readonly CodemodSite[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Marker {
|
|
64
|
+
readonly line: number;
|
|
65
|
+
readonly kind: string | undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findMarker(source: string): Marker | undefined {
|
|
69
|
+
const lines = source.split("\n", MARKER_HEAD_LINES);
|
|
70
|
+
for (const [index, text] of lines.entries()) {
|
|
71
|
+
const match = MARKER_LINE.exec(text);
|
|
72
|
+
if (match) return { line: index, kind: match[1] };
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function removeMarkerLine(source: string, line: number): string {
|
|
78
|
+
const lines = source.split("\n");
|
|
79
|
+
lines.splice(line, 1);
|
|
80
|
+
if (line === 0 && lines[0] === "") lines.shift();
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function splitFeaturePath(
|
|
85
|
+
file: string,
|
|
86
|
+
options: FeatureLayoutOptions,
|
|
87
|
+
): { readonly featureDir: string; readonly rel: string } | undefined {
|
|
88
|
+
for (const dir of options.featureDirs ?? []) {
|
|
89
|
+
if (file.startsWith(`${dir}/`)) return { featureDir: dir, rel: file.slice(dir.length + 1) };
|
|
90
|
+
}
|
|
91
|
+
for (const root of options.featuresRoots ?? []) {
|
|
92
|
+
if (!file.startsWith(`${root}/`)) continue;
|
|
93
|
+
const [feature, ...rest] = file.slice(root.length + 1).split("/");
|
|
94
|
+
if (feature !== undefined && rest.length > 0) {
|
|
95
|
+
return { featureDir: `${root}/${feature}`, rel: rest.join("/") };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const pattern of IMPLICIT_FEATURE_PATTERNS) {
|
|
99
|
+
const match = pattern.exec(file);
|
|
100
|
+
if (match?.[1] !== undefined && match[2] !== undefined) {
|
|
101
|
+
return { featureDir: match[1], rel: match[2] };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pathImpliedKind(rel: string): string | undefined {
|
|
108
|
+
if (TEST_PATH.test(rel)) return "test";
|
|
109
|
+
if (rel === WEB_DIR || rel.startsWith(`${WEB_DIR}/`) || /^web\.tsx?$/.test(rel)) return "client";
|
|
110
|
+
if (rel.startsWith("dev/")) return "dev";
|
|
111
|
+
if (rel.startsWith("scripts/")) return "tooling";
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function stripScriptExtension(file: string): { readonly base: string; readonly extension: string } {
|
|
116
|
+
const extension = SCRIPT_EXTENSION.exec(file)?.[0] ?? "";
|
|
117
|
+
return { base: file.slice(0, file.length - extension.length), extension };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function movedTargetOf(
|
|
121
|
+
absoluteSpecifier: string,
|
|
122
|
+
moved: ReadonlyMap<string, string>,
|
|
123
|
+
): string | undefined {
|
|
124
|
+
const { base, extension } = stripScriptExtension(absoluteSpecifier);
|
|
125
|
+
const candidates =
|
|
126
|
+
extension === ".js" || extension === ".ts" || extension === ".tsx" || extension === ""
|
|
127
|
+
? [absoluteSpecifier, `${base}.ts`, `${base}.tsx`]
|
|
128
|
+
: [absoluteSpecifier];
|
|
129
|
+
for (const candidate of candidates) {
|
|
130
|
+
if (moved.has(candidate)) return candidate;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function relativeSpecifierFrom(fromDir: string, target: string): string {
|
|
136
|
+
const path = relative(fromDir, target).split("\\").join("/");
|
|
137
|
+
return path.startsWith(".") ? path : `./${path}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function rewrittenSpecifier(
|
|
141
|
+
specifier: string,
|
|
142
|
+
oldFile: string,
|
|
143
|
+
newFile: string,
|
|
144
|
+
moved: ReadonlyMap<string, string>,
|
|
145
|
+
): string | undefined {
|
|
146
|
+
if (!specifier.startsWith(".")) return undefined;
|
|
147
|
+
const absolute = resolve(dirname(oldFile), specifier);
|
|
148
|
+
const oldTarget = movedTargetOf(absolute, moved);
|
|
149
|
+
if (oldTarget === undefined && oldFile === newFile) return undefined;
|
|
150
|
+
const newTarget = oldTarget === undefined ? absolute : moved.get(oldTarget);
|
|
151
|
+
if (newTarget === undefined) return undefined;
|
|
152
|
+
let next: string;
|
|
153
|
+
if (oldTarget === undefined) {
|
|
154
|
+
next = relativeSpecifierFrom(dirname(newFile), newTarget);
|
|
155
|
+
if (specifier.endsWith("/")) next = `${next}/`;
|
|
156
|
+
} else {
|
|
157
|
+
const { extension } = stripScriptExtension(absolute);
|
|
158
|
+
const { base } = stripScriptExtension(newTarget);
|
|
159
|
+
const isDirectoryImport = extension === "" && base.endsWith("/index");
|
|
160
|
+
const target = isDirectoryImport ? base.slice(0, -"/index".length) : `${base}${extension}`;
|
|
161
|
+
next = relativeSpecifierFrom(dirname(newFile), target);
|
|
162
|
+
}
|
|
163
|
+
return next === specifier ? undefined : next;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function rewriteSpecifiers(
|
|
167
|
+
source: string,
|
|
168
|
+
oldFile: string,
|
|
169
|
+
newFile: string,
|
|
170
|
+
moved: ReadonlyMap<string, string>,
|
|
171
|
+
sites: CodemodSite[],
|
|
172
|
+
): string {
|
|
173
|
+
const project = new Project({
|
|
174
|
+
useInMemoryFileSystem: true,
|
|
175
|
+
skipAddingFilesFromTsConfig: true,
|
|
176
|
+
skipFileDependencyResolution: true,
|
|
177
|
+
});
|
|
178
|
+
const sourceFile = project.createSourceFile(oldFile, source);
|
|
179
|
+
const edits: TextEdit[] = [];
|
|
180
|
+
for (const literal of moduleSpecifierLiterals(sourceFile)) {
|
|
181
|
+
if (!Node.isStringLiteral(literal)) continue;
|
|
182
|
+
const current = literal.getLiteralValue();
|
|
183
|
+
const next = rewrittenSpecifier(current, oldFile, newFile, moved);
|
|
184
|
+
if (next === undefined) continue;
|
|
185
|
+
sites.push({
|
|
186
|
+
file: oldFile,
|
|
187
|
+
line: literal.getStartLineNumber(),
|
|
188
|
+
description: `${current} -> ${next}`,
|
|
189
|
+
});
|
|
190
|
+
edits.push({ start: literal.getStart() + 1, end: literal.getEnd() - 1, text: next });
|
|
191
|
+
}
|
|
192
|
+
return applyEdits(source, edits);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// package.json export targets: "./src/<rel>.ts" and the publishConfig mirror "./dist/<rel>.js|.d.ts".
|
|
196
|
+
function rewritePackageJson(
|
|
197
|
+
source: string,
|
|
198
|
+
packageJsonPath: string,
|
|
199
|
+
moved: ReadonlyMap<string, string>,
|
|
200
|
+
sites: CodemodSite[],
|
|
201
|
+
): string {
|
|
202
|
+
const packageDir = dirname(packageJsonPath);
|
|
203
|
+
return source.replace(/"(\.\/(?:src|dist)\/[^"]+)"/g, (whole, target: string) => {
|
|
204
|
+
const isDist = target.startsWith("./dist/");
|
|
205
|
+
const sourceTarget = isDist
|
|
206
|
+
? target.replace("./dist/", "./src/").replace(/(?:\.d\.ts|\.js)$/, "")
|
|
207
|
+
: target;
|
|
208
|
+
const { base } = stripScriptExtension(resolve(packageDir, sourceTarget));
|
|
209
|
+
const candidates = isDist ? [`${base}.ts`, `${base}.tsx`] : [resolve(packageDir, sourceTarget)];
|
|
210
|
+
const oldFile = candidates.find((candidate) => moved.has(candidate));
|
|
211
|
+
const newFile = oldFile === undefined ? undefined : moved.get(oldFile);
|
|
212
|
+
if (newFile === undefined) return whole;
|
|
213
|
+
const newRel = relative(packageDir, newFile).split("\\").join("/");
|
|
214
|
+
const next = isDist
|
|
215
|
+
? target.replace(/^\.\/dist\/.*?(\.d\.ts|\.js)$/, (_all, ext: string) => {
|
|
216
|
+
return `./dist/${newRel.slice("src/".length).replace(/\.tsx?$/, "")}${ext}`;
|
|
217
|
+
})
|
|
218
|
+
: `./${newRel}`;
|
|
219
|
+
sites.push({
|
|
220
|
+
file: packageJsonPath,
|
|
221
|
+
line: source.slice(0, source.indexOf(whole)).split("\n").length,
|
|
222
|
+
description: `${target} -> ${next}`,
|
|
223
|
+
});
|
|
224
|
+
return `"${next}"`;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
type MarkerAction =
|
|
229
|
+
| { readonly action: "strip" }
|
|
230
|
+
| { readonly action: "move"; readonly target: string }
|
|
231
|
+
| { readonly action: "manual"; readonly description: string };
|
|
232
|
+
|
|
233
|
+
function actionForMarker(
|
|
234
|
+
marker: Marker,
|
|
235
|
+
location: { readonly featureDir: string; readonly rel: string },
|
|
236
|
+
isTaken: (target: string) => boolean,
|
|
237
|
+
): MarkerAction {
|
|
238
|
+
if (marker.kind === undefined) {
|
|
239
|
+
return { action: "manual", description: "bare @runtime marker without a kind" };
|
|
240
|
+
}
|
|
241
|
+
const implied = pathImpliedKind(location.rel);
|
|
242
|
+
if (marker.kind === implied || (marker.kind === "client" && implied !== undefined)) {
|
|
243
|
+
return { action: "strip" };
|
|
244
|
+
}
|
|
245
|
+
if (marker.kind !== "client") {
|
|
246
|
+
return { action: "manual", description: `@runtime ${marker.kind} has no path rule here` };
|
|
247
|
+
}
|
|
248
|
+
const target = join(location.featureDir, WEB_DIR, location.rel);
|
|
249
|
+
return isTaken(target)
|
|
250
|
+
? { action: "manual", description: `cannot move to ${WEB_DIR}/${location.rel}: target exists` }
|
|
251
|
+
: { action: "move", target };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function buildWrites(
|
|
255
|
+
files: ReadonlyMap<string, string>,
|
|
256
|
+
stripped: ReadonlyMap<string, string>,
|
|
257
|
+
moved: ReadonlyMap<string, string>,
|
|
258
|
+
importRewrites: CodemodSite[],
|
|
259
|
+
): Map<string, string> {
|
|
260
|
+
const writes = new Map<string, string>();
|
|
261
|
+
const basenames = [...moved.keys()].map((file) => stripScriptExtension(baseName(file)).base);
|
|
262
|
+
for (const [file, original] of files) {
|
|
263
|
+
const content = stripped.get(file) ?? original;
|
|
264
|
+
const newFile = moved.get(file) ?? file;
|
|
265
|
+
if (file.endsWith("package.json")) {
|
|
266
|
+
const next =
|
|
267
|
+
moved.size > 0 ? rewritePackageJson(content, file, moved, importRewrites) : content;
|
|
268
|
+
if (next !== original) writes.set(file, next);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const mentionsMoved = moved.has(file) || basenames.some((name) => content.includes(name));
|
|
272
|
+
const next = mentionsMoved
|
|
273
|
+
? rewriteSpecifiers(content, file, newFile, moved, importRewrites)
|
|
274
|
+
: content;
|
|
275
|
+
if (next !== original || newFile !== file) writes.set(newFile, next);
|
|
276
|
+
}
|
|
277
|
+
return writes;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function applyFeatureLayout(
|
|
281
|
+
files: ReadonlyMap<string, string>,
|
|
282
|
+
options: FeatureLayoutOptions = {},
|
|
283
|
+
): FeatureLayoutResult {
|
|
284
|
+
const manual: CodemodSite[] = [];
|
|
285
|
+
const markersRemoved: string[] = [];
|
|
286
|
+
const stripped = new Map<string, string>();
|
|
287
|
+
const moved = new Map<string, string>();
|
|
288
|
+
const claimedTargets = new Set<string>();
|
|
289
|
+
const isTaken = (target: string): boolean => files.has(target) || claimedTargets.has(target);
|
|
290
|
+
|
|
291
|
+
for (const [file, source] of files) {
|
|
292
|
+
const location = splitFeaturePath(file, options);
|
|
293
|
+
const marker = location === undefined ? undefined : findMarker(source);
|
|
294
|
+
if (location === undefined || marker === undefined) continue;
|
|
295
|
+
const decision = actionForMarker(marker, location, isTaken);
|
|
296
|
+
if (decision.action === "manual") {
|
|
297
|
+
manual.push({ file, line: marker.line + 1, description: decision.description });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (decision.action === "move") {
|
|
301
|
+
claimedTargets.add(decision.target);
|
|
302
|
+
moved.set(file, decision.target);
|
|
303
|
+
}
|
|
304
|
+
stripped.set(file, removeMarkerLine(source, marker.line));
|
|
305
|
+
markersRemoved.push(file);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const importRewrites: CodemodSite[] = [];
|
|
309
|
+
const writes = buildWrites(files, stripped, moved, importRewrites);
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
writes,
|
|
313
|
+
removals: [...moved.keys()],
|
|
314
|
+
moves: [...moved].map(([from, to]) => ({ from, to })),
|
|
315
|
+
markersRemoved,
|
|
316
|
+
importRewrites,
|
|
317
|
+
manual,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function baseName(file: string): string {
|
|
322
|
+
return file.slice(file.lastIndexOf("/") + 1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
type Mode = "write" | "dry-run" | "check";
|
|
326
|
+
|
|
327
|
+
interface CliOptions extends FeatureLayoutOptions {
|
|
328
|
+
readonly mode: Mode;
|
|
329
|
+
readonly format: boolean;
|
|
330
|
+
readonly paths: readonly string[];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function parseArgs(argv: readonly string[]): CliOptions {
|
|
334
|
+
let mode: Mode = "write";
|
|
335
|
+
let format = true;
|
|
336
|
+
const paths: string[] = [];
|
|
337
|
+
const featureDirs: string[] = [];
|
|
338
|
+
const featuresRoots: string[] = [];
|
|
339
|
+
for (let i = 0; i < argv.length; i++) {
|
|
340
|
+
const arg = argv[i] ?? "";
|
|
341
|
+
if (arg === "--check") mode = "check";
|
|
342
|
+
else if (arg === "--dry-run") mode = "dry-run";
|
|
343
|
+
else if (arg === "--write") mode = "write";
|
|
344
|
+
else if (arg === "--no-format") format = false;
|
|
345
|
+
else if (arg === "--feature-dir" || arg === "--features-root") {
|
|
346
|
+
const value = argv[++i];
|
|
347
|
+
if (value === undefined) throw new Error(`${arg} needs a directory`);
|
|
348
|
+
(arg === "--feature-dir" ? featureDirs : featuresRoots).push(resolve(value));
|
|
349
|
+
} else if (arg.startsWith("--")) throw new Error(`unknown flag: ${arg}`);
|
|
350
|
+
else paths.push(arg);
|
|
351
|
+
}
|
|
352
|
+
if (paths.length === 0) throw new Error("at least one target directory is required");
|
|
353
|
+
return { mode, format, paths, featureDirs, featuresRoots };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function readTargetFiles(paths: readonly string[]): Map<string, string> {
|
|
357
|
+
const files = new Map<string, string>();
|
|
358
|
+
const names = collectSourceFiles(paths, {
|
|
359
|
+
isMigratableFile: (name) => /\.(ts|tsx)$/.test(name) && !name.endsWith(".d.ts"),
|
|
360
|
+
});
|
|
361
|
+
const packageJsons = collectSourceFiles(paths, {
|
|
362
|
+
isMigratableFile: (name) => name === "package.json",
|
|
363
|
+
});
|
|
364
|
+
for (const file of [...names, ...packageJsons]) {
|
|
365
|
+
if (file.includes("/__tests__/fixtures/")) continue;
|
|
366
|
+
if (statSync(file).isFile()) files.set(file, readFileSync(file, "utf8"));
|
|
367
|
+
}
|
|
368
|
+
return files;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function display(file: string): string {
|
|
372
|
+
const fromCwd = relative(process.cwd(), file);
|
|
373
|
+
return fromCwd.startsWith("..") ? file : fromCwd;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function writeResult(result: FeatureLayoutResult): void {
|
|
377
|
+
for (const old of result.removals) rmSync(old);
|
|
378
|
+
for (const [file, content] of result.writes) {
|
|
379
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
380
|
+
writeFileSync(file, content);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function main(argv: readonly string[]): Promise<void> {
|
|
385
|
+
let options: CliOptions;
|
|
386
|
+
try {
|
|
387
|
+
options = parseArgs(argv);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.error(`[feature-layout] ${err instanceof Error ? err.message : String(err)}`);
|
|
390
|
+
process.exit(2);
|
|
391
|
+
}
|
|
392
|
+
const files = readTargetFiles(options.paths);
|
|
393
|
+
const result = applyFeatureLayout(files, options);
|
|
394
|
+
for (const move of result.moves) {
|
|
395
|
+
console.log(`${display(move.from)} moved -> ${display(move.to)}`);
|
|
396
|
+
}
|
|
397
|
+
for (const file of result.markersRemoved) console.log(`${display(file)} marker removed`);
|
|
398
|
+
for (const site of result.manual) {
|
|
399
|
+
console.log(`${display(site.file)}:${site.line} manual ${site.description}`);
|
|
400
|
+
}
|
|
401
|
+
console.log(
|
|
402
|
+
`\n[feature-layout] ${options.mode === "write" ? "moved" : "movable"}: ${result.moves.length} file(s); markers: ${result.markersRemoved.length}; import sites: ${result.importRewrites.length}; manual: ${result.manual.length}`,
|
|
403
|
+
);
|
|
404
|
+
if (options.mode === "write") {
|
|
405
|
+
writeResult(result);
|
|
406
|
+
if (options.format) {
|
|
407
|
+
const changed = [...result.writes.keys()].filter((file) => /\.(ts|tsx)$/.test(file));
|
|
408
|
+
if (changed.length > 0)
|
|
409
|
+
await formatWithBiome("feature-layout", changed, resolve(options.paths[0] ?? "."));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const pending = result.markersRemoved.length + result.manual.length;
|
|
413
|
+
process.exit(options.mode === "check" && pending > 0 ? 1 : 0);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (import.meta.main) await main(process.argv.slice(2));
|
|
@@ -42,17 +42,6 @@ export const PACKAGE_MERGE_MAPPINGS: readonly PackageMergeMapping[] = [
|
|
|
42
42
|
const DEPENDENCY_BLOCKS = ["dependencies", "devDependencies", "peerDependencies"] as const;
|
|
43
43
|
|
|
44
44
|
const SKIPPED_PATH_SUFFIXES: readonly string[] = [
|
|
45
|
-
"packages/http/src/index.ts",
|
|
46
|
-
"packages/repo-manifest/src/index.ts",
|
|
47
|
-
"packages/repo-manifest/src/app-ports/index.ts",
|
|
48
|
-
"packages/headless/src/index.ts",
|
|
49
|
-
"packages/headless/src/apex/index.ts",
|
|
50
|
-
"packages/headless/src/dispatcher/index.ts",
|
|
51
|
-
"packages/headless/src/locale-routing/index.ts",
|
|
52
|
-
"packages/dispatcher-live/src/index.ts",
|
|
53
|
-
"packages/renderer-web/src/index.ts",
|
|
54
|
-
"packages/renderer-web/src/theme-plugin.ts",
|
|
55
|
-
"packages/renderer-web/src/styles.css",
|
|
56
45
|
"packages/framework/src/scripts/codemod/package-merge.ts",
|
|
57
46
|
"packages/framework/src/__tests__/package-merge-codemod.test.ts",
|
|
58
47
|
];
|