@cosmicdrift/kumiko-framework 0.336.1 → 0.338.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.
Files changed (116) hide show
  1. package/dist/api/api-constants.d.ts +1 -0
  2. package/dist/api/api-constants.js +9 -0
  3. package/dist/api/auth-middleware.js +45 -21
  4. package/dist/api/auth-routes.d.ts +1 -0
  5. package/dist/api/auth-routes.js +20 -0
  6. package/dist/api/redis-sse-broker.d.ts +1 -0
  7. package/dist/api/redis-sse-broker.js +4 -5
  8. package/dist/api/server.js +14 -4
  9. package/dist/api/sse-route.js +14 -1
  10. package/dist/bun-db/query.js +6 -4
  11. package/dist/compliance/sub-processors.js +10 -1
  12. package/dist/crypto/kek-source.js +1 -0
  13. package/dist/db/blind-index-cleanup.d.ts +1 -0
  14. package/dist/db/blind-index-cleanup.js +12 -1
  15. package/dist/db/event-store-executor-read.js +30 -4
  16. package/dist/db/index.d.ts +1 -1
  17. package/dist/db/index.js +1 -1
  18. package/dist/db/queries/event-consumer.d.ts +2 -2
  19. package/dist/db/queries/event-consumer.js +5 -4
  20. package/dist/db/queries/event-store.js +6 -6
  21. package/dist/db/queries/projection-rebuild.js +4 -4
  22. package/dist/db/queries/seed-context.js +6 -6
  23. package/dist/db/tenant-db.js +16 -8
  24. package/dist/derivatives/derivatives-context.js +5 -2
  25. package/dist/engine/boot-validator/access-declarations.js +13 -7
  26. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  27. package/dist/engine/boot-validator/entity-handler.js +26 -0
  28. package/dist/engine/boot-validator/index.js +2 -1
  29. package/dist/engine/boot-validator/pii-retention.js +11 -1
  30. package/dist/engine/boot-validator/projection-list-screens.d.ts +1 -1
  31. package/dist/engine/boot-validator/projection-list-screens.js +13 -0
  32. package/dist/engine/boot-validator/screens.js +12 -0
  33. package/dist/engine/build-config-feature-schema.js +4 -0
  34. package/dist/engine/config-helpers.d.ts +2 -1
  35. package/dist/engine/config-helpers.js +1 -0
  36. package/dist/engine/define-feature.js +1 -0
  37. package/dist/engine/extensions/escape-hatch-usage.js +3 -1
  38. package/dist/engine/feature-ast/extractors/hooks.js +7 -1
  39. package/dist/engine/feature-ast/extractors/index.d.ts +1 -1
  40. package/dist/engine/feature-ast/extractors/index.js +1 -1
  41. package/dist/engine/feature-ast/extractors/jobs-routes.js +6 -2
  42. package/dist/engine/feature-ast/extractors/round3.d.ts +2 -1
  43. package/dist/engine/feature-ast/extractors/round3.js +11 -0
  44. package/dist/engine/feature-ast/extractors/shared.js +1 -3
  45. package/dist/engine/feature-ast/index.d.ts +1 -1
  46. package/dist/engine/feature-ast/parse.js +3 -1
  47. package/dist/engine/feature-ast/patch.d.ts +3 -0
  48. package/dist/engine/feature-ast/patch.js +8 -11
  49. package/dist/engine/feature-ast/patcher.d.ts +6 -1
  50. package/dist/engine/feature-ast/patcher.js +3 -0
  51. package/dist/engine/feature-ast/pattern-change-schema.d.ts +630 -1
  52. package/dist/engine/feature-ast/pattern-change-schema.js +19 -2
  53. package/dist/engine/feature-ast/patterns.d.ts +8 -2
  54. package/dist/engine/feature-ast/patterns.js +1 -0
  55. package/dist/engine/feature-ast/render.js +9 -0
  56. package/dist/engine/feature-builder-state.d.ts +2 -1
  57. package/dist/engine/feature-builder-state.js +1 -0
  58. package/dist/engine/feature-config-events-jobs.d.ts +4 -1
  59. package/dist/engine/feature-config-events-jobs.js +25 -1
  60. package/dist/engine/feature-manifest.d.ts +1 -0
  61. package/dist/engine/feature-manifest.js +1 -0
  62. package/dist/engine/feature-ui-extensions.js +24 -21
  63. package/dist/engine/index.d.ts +1 -1
  64. package/dist/engine/pattern-library/library.js +2 -1
  65. package/dist/engine/pattern-library/static-schemas.d.ts +1 -0
  66. package/dist/engine/pattern-library/static-schemas.js +47 -4
  67. package/dist/engine/registry-facade.js +7 -0
  68. package/dist/engine/registry-ingest.js +7 -0
  69. package/dist/engine/registry-state.d.ts +2 -1
  70. package/dist/engine/registry-state.js +1 -0
  71. package/dist/engine/registry-validate.d.ts +1 -1
  72. package/dist/engine/registry-validate.js +39 -17
  73. package/dist/engine/registry.js +2 -2
  74. package/dist/engine/types/index.d.ts +3 -3
  75. package/dist/engine/types/index.js +1 -1
  76. package/dist/engine/types/screen.d.ts +1 -1
  77. package/dist/engine/types/screen.js +1 -1
  78. package/dist/errors/classes.d.ts +5 -0
  79. package/dist/errors/classes.js +15 -0
  80. package/dist/errors/index.d.ts +1 -1
  81. package/dist/errors/index.js +1 -1
  82. package/dist/i18n/required-surface-keys.js +3 -5
  83. package/dist/jobs/job-runner.js +2 -1
  84. package/dist/migrations/pending-rebuilds.d.ts +2 -0
  85. package/dist/migrations/pending-rebuilds.js +3 -0
  86. package/dist/pipeline/dispatch-query.js +3 -5
  87. package/dist/pipeline/dispatch-shared.js +32 -5
  88. package/dist/pipeline/dispatch-stream.js +2 -4
  89. package/dist/pipeline/dispatch-write.js +2 -4
  90. package/dist/pipeline/event-dispatcher-delivery.d.ts +0 -1
  91. package/dist/pipeline/event-dispatcher-delivery.js +1 -8
  92. package/dist/pipeline/event-dispatcher.js +16 -25
  93. package/dist/pipeline/handler-access-error.d.ts +3 -0
  94. package/dist/pipeline/handler-access-error.js +17 -0
  95. package/dist/pipeline/irreversible-operation-gate.js +9 -3
  96. package/dist/pipeline/member-read-only-transaction.js +8 -3
  97. package/dist/pipeline/system-hooks.js +7 -1
  98. package/dist/pipeline/system-identity-switch.js +11 -8
  99. package/dist/search/meilisearch-adapter.js +0 -2
  100. package/dist/stack/push-entity-projection-tables.js +5 -3
  101. package/dist/stack/redis.d.ts +1 -0
  102. package/dist/stack/redis.js +11 -2
  103. package/dist/stack/table-helpers.d.ts +3 -1
  104. package/dist/stack/table-helpers.js +9 -7
  105. package/dist/stack/test-stack.js +1 -7
  106. package/dist/testing/handler-context.js +3 -6
  107. package/dist/ui-types/index.d.ts +1 -1
  108. package/dist/ui-types/index.js +1 -1
  109. package/dist/upgrade-cli.js +29 -2
  110. package/package.json +4 -4
  111. package/src/changes.json +153 -0
  112. package/src/scripts/codemod/collect-source-files.ts +48 -0
  113. package/src/scripts/codemod/migrate-cross-tenant.ts +3 -23
  114. package/src/scripts/codemod/migrate-db-raw.ts +6 -23
  115. package/src/scripts/codemod/migrate-open-to-all.ts +4 -24
  116. package/src/scripts/codemod/unprocessable-error-details-reason.ts +47 -3
@@ -153,7 +153,6 @@ export function createMeilisearchAdapter(options) {
153
153
  // skip: empty batch — avoid an unnecessary Meilisearch round-trip
154
154
  if (items.length === 0)
155
155
  return;
156
- await ensureConfigured(tenantId);
157
156
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
158
157
  const ids = items.map((i) => meilisearchDocId(i.entityType, i.entityId));
159
158
  await awaitSucceededTask(index.deleteDocuments(ids), REMOVE_TOLERATED_ERROR_CODES);
@@ -178,7 +177,6 @@ export function createMeilisearchAdapter(options) {
178
177
  }));
179
178
  },
180
179
  async remove(tenantId, entityType, entityId) {
181
- await ensureConfigured(tenantId);
182
180
  const index = client.index(meilisearchTenantIndex(prefix, tenantId));
183
181
  await awaitSucceededTask(index.deleteDocument(meilisearchDocId(entityType, entityId)), REMOVE_TOLERATED_ERROR_CODES);
184
182
  },
@@ -1,6 +1,6 @@
1
1
  import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { columnNamesOf, tableExists } from "../db/schema-inspection.js";
3
- import { addMissingColumns, tableToMeta, unsafePushTables } from "./table-helpers.js";
3
+ import { addMissingColumns, addMissingIndexes, tableToMeta, unsafePushTables, } from "./table-helpers.js";
4
4
  // biome-ignore lint/suspicious/noConsole: stack-internal status logging
5
5
  const logInfo = (msg) => console.log(msg);
6
6
  /**
@@ -44,7 +44,7 @@ export async function pushEntityProjectionTables(stack, registry) {
44
44
  function isUnsafeToAutoAdd(col) {
45
45
  return col.notNull && !col.primaryKey && col.defaultSql === undefined;
46
46
  }
47
- // Backfills columns a persistent dev DB predates; a required column with no
47
+ // Backfills columns and indexes a persistent dev DB predates; a required column with no
48
48
  // default can't be added safely on a table that may hold rows, so that
49
49
  // stays a boot error instead.
50
50
  async function syncExistingProjectionTable(stack, physical, table) {
@@ -53,7 +53,8 @@ async function syncExistingProjectionTable(stack, physical, table) {
53
53
  const missingColumns = meta.columns.filter((c) => !liveColumns.has(c.name));
54
54
  if (missingColumns.length === 0) {
55
55
  logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
56
- // skip: live table already has every declared column
56
+ await addMissingIndexes(stack.db, physical, meta.indexes);
57
+ // skip: table is already in sync, only indexes needed backfilling
57
58
  return;
58
59
  }
59
60
  const unsafeColumns = missingColumns.filter(isUnsafeToAutoAdd);
@@ -70,4 +71,5 @@ async function syncExistingProjectionTable(stack, physical, table) {
70
71
  logInfo(`[kumiko-stack] table ${physical} is missing column(s) ` +
71
72
  `${missingColumns.map((c) => `"${c.name}"`).join(", ")} — adding`);
72
73
  await addMissingColumns(stack.db, physical, missingColumns);
74
+ await addMissingIndexes(stack.db, physical, meta.indexes);
73
75
  }
@@ -8,4 +8,5 @@ export type TestRedis = {
8
8
  flushNamespace: () => Promise<void>;
9
9
  cleanup: () => Promise<void>;
10
10
  };
11
+ export declare function queueNamePrefixForTestRedis(keyPrefix: string): string;
11
12
  export declare function createTestRedis(): Promise<TestRedis>;
@@ -1,5 +1,11 @@
1
1
  import { generateId } from "../utils/index.js";
2
2
  import { requireEnv } from "./db.js";
3
+ // BullMQ queues live on the raw redisUrl, outside the test Redis keyPrefix, so
4
+ // parallel stacks sharing the prod default queue name would consume each
5
+ // other's jobs. BullMQ rejects `:` in queue names.
6
+ export function queueNamePrefixForTestRedis(keyPrefix) {
7
+ return keyPrefix.split(":").filter(Boolean).join("-");
8
+ }
3
9
  export async function createTestRedis() {
4
10
  const Redis = (await import("ioredis")).Redis;
5
11
  const redisUrl = requireEnv("REDIS_URL");
@@ -8,13 +14,13 @@ export async function createTestRedis() {
8
14
  // prefix space is unbounded. See Track B.3 in docs/plans/tests-refactor.
9
15
  const prefix = `kt:${generateId().slice(-8)}:`;
10
16
  const redis = new Redis(redisUrl, { keyPrefix: prefix });
11
- async function flushNamespace() {
17
+ async function deleteKeysMatching(pattern) {
12
18
  // Open a prefix-less client for the scan — ioredis' keyPrefix is applied
13
19
  // per-command but SCAN's returned keys are full names, so managing the
14
20
  // del set with the prefix already on the connection is error-prone.
15
21
  const raw = new Redis(redisUrl);
16
22
  try {
17
- const stream = raw.scanStream({ match: `${prefix}*`, count: 500 });
23
+ const stream = raw.scanStream({ match: pattern, count: 500 });
18
24
  const keys = [];
19
25
  for await (const batch of stream)
20
26
  keys.push(...batch);
@@ -25,6 +31,7 @@ export async function createTestRedis() {
25
31
  raw.disconnect();
26
32
  }
27
33
  }
34
+ const flushNamespace = () => deleteKeysMatching(`${prefix}*`);
28
35
  return {
29
36
  redis,
30
37
  redisUrl,
@@ -32,6 +39,8 @@ export async function createTestRedis() {
32
39
  flushNamespace,
33
40
  cleanup: async () => {
34
41
  await flushNamespace();
42
+ // The derived per-stack JobRunner queues sit outside the keyPrefix.
43
+ await deleteKeysMatching(`bull:${queueNamePrefixForTestRedis(prefix)}-*`);
35
44
  redis.disconnect();
36
45
  },
37
46
  };
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "../db/connection.js";
2
- import type { ColumnMeta, EntityTableMeta } from "../db/entity-table-meta.js";
2
+ import type { ColumnMeta, EntityTableMeta, IndexMeta } from "../db/entity-table-meta.js";
3
3
  import type { EventDispatcher } from "../pipeline/index.js";
4
4
  /**
5
5
  * Bypass: creates an entity-table directly without going through the
@@ -11,6 +11,8 @@ export declare function unsafeEnsureEntityTable(db: DbConnection, entity: import
11
11
  export declare function tableToMeta(table: unknown): EntityTableMeta;
12
12
  /** Applies ALTER TABLE ADD COLUMN for exactly the given columns. */
13
13
  export declare function addMissingColumns(db: DbConnection, tableName: string, columns: readonly ColumnMeta[]): Promise<void>;
14
+ /** CREATE [UNIQUE] INDEX IF NOT EXISTS for exactly the given indexes. */
15
+ export declare function addMissingIndexes(db: DbConnection, tableName: string, indexes: readonly IndexMeta[]): Promise<void>;
14
16
  /**
15
17
  * Bypass: pushes table definitions to the database directly. Produces
16
18
  * CREATE TABLE IF NOT EXISTS + CREATE INDEX statements via renderTableDdl
@@ -66,6 +66,14 @@ export async function addMissingColumns(db, tableName, columns) {
66
66
  await alterTableAddColumn(db, tableName, col.name, type, defaultClause, notNull);
67
67
  }
68
68
  }
69
+ /** CREATE [UNIQUE] INDEX IF NOT EXISTS for exactly the given indexes. */
70
+ export async function addMissingIndexes(db, tableName, indexes) {
71
+ for (const idx of indexes) {
72
+ const kind = idx.unique ? "UNIQUE INDEX" : "INDEX";
73
+ const colList = idx.columns.map((c) => `"${c}"`).join(", ");
74
+ await createIndexIfNotExists(db, kind, idx.name, tableName, colList, idx.whereSql);
75
+ }
76
+ }
69
77
  /**
70
78
  * Bypass: pushes table definitions to the database directly. Produces
71
79
  * CREATE TABLE IF NOT EXISTS + CREATE INDEX statements via renderTableDdl
@@ -90,13 +98,7 @@ export async function unsafePushTables(db, tables, prevTables) {
90
98
  const newCols = meta.columns.filter((c) => !prevCols.has(c.name));
91
99
  await addMissingColumns(db, meta.tableName, newCols);
92
100
  const prevIdxNames = new Set(prev.indexes.map((i) => i.name));
93
- for (const idx of meta.indexes) {
94
- if (!prevIdxNames.has(idx.name)) {
95
- const kind = idx.unique ? "UNIQUE INDEX" : "INDEX";
96
- const colList = idx.columns.map((c) => `"${c}"`).join(", ");
97
- await createIndexIfNotExists(db, kind, idx.name, meta.tableName, colList, idx.whereSql);
98
- }
99
- }
101
+ await addMissingIndexes(db, meta.tableName, meta.indexes.filter((i) => !prevIdxNames.has(i.name)));
100
102
  }
101
103
  else {
102
104
  const statements = renderTableDdl(meta);
@@ -11,16 +11,10 @@ import { createInMemorySearchAdapter } from "../search/index.js";
11
11
  import { createTestDb } from "./db.js";
12
12
  import { createJobFailureTracker, drainJobs } from "./drain-jobs.js";
13
13
  import { createEventCollector } from "./event-collector.js";
14
- import { createTestRedis } from "./redis.js";
14
+ import { createTestRedis, queueNamePrefixForTestRedis } from "./redis.js";
15
15
  import { createRequestHelper } from "./request-helper.js";
16
16
  import { unsafePushTables } from "./table-helpers.js";
17
17
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
18
- // BullMQ queues live on the raw redisUrl, outside the test Redis keyPrefix, so
19
- // parallel stacks sharing the prod default queue name would consume each
20
- // other's jobs. BullMQ rejects `:` in queue names.
21
- function queueNamePrefixForTestRedis(keyPrefix) {
22
- return keyPrefix.split(":").filter(Boolean).join("-");
23
- }
24
18
  export async function setupTestStack(options) {
25
19
  const jwtSecret = options.jwtSecret ?? DEFAULT_JWT_SECRET;
26
20
  const enabledHooks = options.systemHooks ?? ["sse", "search"];
@@ -5,7 +5,7 @@
5
5
  // production services (delivery-service uses it to run cross-feature notify
6
6
  // calls without a real dispatcher). Hence the runtime classification despite
7
7
  // living under `testing/` — no vitest imports, no test side-effects.
8
- import { ANONYMOUS_ROLE } from "../engine/system-user.js";
8
+ import { createAnonymousUser } from "../engine/system-user.js";
9
9
  import { DEFAULT_LOCALE } from "../i18n/request-locale.js";
10
10
  import { createNoopMetricsHandle, getFallbackTracer } from "../observability/index.js";
11
11
  import { createTzContext } from "../time/index.js";
@@ -31,11 +31,8 @@ export function bridgeStub(opts) {
31
31
  // anonymous default values when no user is passed explicitly. Test code
32
32
  // that cares about identity passes its own SessionUser here and gets it
33
33
  // back on ctx.
34
- const stubUser = opts?.user ?? {
35
- id: "00000000-0000-0000-0000-000000000000",
36
- tenantId: "00000000-0000-0000-0000-000000000000", // @cast-boundary engine-bridge
37
- roles: [ANONYMOUS_ROLE],
38
- };
34
+ const stubUser = opts?.user ??
35
+ createAnonymousUser("00000000-0000-0000-0000-000000000000"); // @cast-boundary engine-bridge
39
36
  return {
40
37
  user: stubUser,
41
38
  query: notAvailable("query"), // @cast-boundary engine-bridge
@@ -10,7 +10,7 @@ export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPe
10
10
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
11
11
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.js";
12
12
  export type { ActionFormFooterAction, ActionFormRedirect, ActionFormScreenDefinition, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartPanel, DashboardChartTone, DashboardCustomPanel, DashboardFeedPanel, DashboardFilterDefinition, DashboardListPanel, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardTimeRangeDefinition, EditExtensionSection, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, FieldCondition, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.js";
13
- export { metricField } from "../engine/types/screen.js";
13
+ export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
14
14
  export type { TargetRef } from "../engine/types/target-ref.js";
15
15
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.js";
16
16
  export type { WorkspaceDefinition } from "../engine/types/workspace.js";
@@ -24,7 +24,7 @@ export { computeDerivedCellValue, roundDerivedCellValue } from "../engine/embedd
24
24
  export { parseRefTarget } from "../engine/parse-ref-target.js";
25
25
  export { evalFieldCondition, explicitListScreenId, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
26
26
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
27
- export { metricField } from "../engine/types/screen.js";
27
+ export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
28
28
  export { ACTION_FORM_ENTITY, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
29
29
  export { formalLocaleTag } from "./formality.js";
30
30
  export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES, REFERENCE_LOOKUP_SOURCES, SYSTEM_REFERENCE_LABELS, } from "./list-row-meta.js";
@@ -109,10 +109,12 @@ export function findPackageChangelogFiles(cwd) {
109
109
  for (const name of pkgNames) {
110
110
  if (seenNames.has(name))
111
111
  continue;
112
+ // Claimed by the nearest scope even without a changelog, so a farther
113
+ // node_modules can't contribute another version of the same package.
114
+ seenNames.add(name);
112
115
  const changelogPath = join(scopeDir, name, "src", "changes.json");
113
116
  if (!existsSync(changelogPath))
114
117
  continue;
115
- seenNames.add(name);
116
118
  const realPath = realpathSync(changelogPath);
117
119
  if (seenRealPaths.has(realPath))
118
120
  continue;
@@ -305,9 +307,30 @@ function logManualMarkerOutcome(out, manualEntries, markerVersion) {
305
307
  : ` ⚠ Marker moved past the manual change(s) above to ${markerVersion}; the upgrade guard no longer lists them, so make sure they are migrated.`);
306
308
  }
307
309
  }
310
+ // Records the codemods that already wrote files before a later one failed, so
311
+ // a re-run resumes after them instead of replaying non-idempotent codemods.
312
+ function writePartialMarker(out, targetDir, pending, manualEntries, failedVersion, ran) {
313
+ // skip: no codemod wrote files before the failure, nothing to resume after
314
+ if (ran.length === 0)
315
+ return;
316
+ const doneBeforeFailure = pending.filter((e) => compareVersions(e.version, failedVersion) < 0);
317
+ const version = markerVersionForPending(doneBeforeFailure, manualEntries, "");
318
+ // skip: no pending entry completed before the failure, so no marker version to record
319
+ if (version === "")
320
+ return;
321
+ const pendingManual = manualEntries.map((e) => ({ version: e.version, title: e.title }));
322
+ writeUpgradeMarker(targetDir, {
323
+ version,
324
+ appliedAt: Temporal.Now.instant().toString(),
325
+ codemods: ran,
326
+ ...(pendingManual.length > 0 && { pendingManual }),
327
+ });
328
+ out.err(` ⚠ ${ran.length} codemod(s) already applied; wrote marker at ${version} so a re-run resumes at the failed one.`);
329
+ }
308
330
  // Runs every pending breaking entry's codemod, oldest version first (so a
309
331
  // later codemod can assume an earlier one already ran). Stops on the first
310
- // failure — no partial marker. Writes the marker whenever dryRun is false —
332
+ // failure; codemods that already ran are kept in a partial marker. Writes the
333
+ // marker whenever dryRun is false —
311
334
  // even with zero pending entries, so an already-current app still gets a
312
335
  // bootstrap marker recording its installed version (fw#2299).
313
336
  // kumiko-lint-ignore complexity-budget sequential codemod runner with zero-pending bootstrap marker
@@ -364,6 +387,8 @@ markerVersion) {
364
387
  const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
365
388
  if (!scriptPath) {
366
389
  out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
390
+ if (!dryRun)
391
+ writePartialMarker(out, targetDir, pending, manualEntries, e.version, ran);
367
392
  return 1;
368
393
  }
369
394
  out.log(` → ${e.version} · running ${e.codemod}${dryRun ? " (dry-run)" : ""}`);
@@ -372,6 +397,8 @@ markerVersion) {
372
397
  out.log(result.output);
373
398
  if (!result.ok) {
374
399
  out.err(` ✗ ${e.version} · ${e.codemod} failed`);
400
+ if (!dryRun)
401
+ writePartialMarker(out, targetDir, pending, manualEntries, e.version, ran);
375
402
  return 1;
376
403
  }
377
404
  ran.push({ version: e.version, codemod: e.codemod, title: e.title });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.336.1",
3
+ "version": "0.338.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -206,8 +206,8 @@
206
206
  "./package.json": "./package.json"
207
207
  },
208
208
  "dependencies": {
209
- "@cosmicdrift/kumiko-http": "0.336.1",
210
- "@cosmicdrift/kumiko-types": "0.336.1",
209
+ "@cosmicdrift/kumiko-http": "0.338.0",
210
+ "@cosmicdrift/kumiko-types": "0.338.0",
211
211
  "bullmq": "^5.76.7",
212
212
  "bun-types": "^1.3.13",
213
213
  "hono": "^4.13.1",
@@ -223,7 +223,7 @@
223
223
  "zod": "^4.4.3"
224
224
  },
225
225
  "devDependencies": {
226
- "@cosmicdrift/kumiko-dispatcher-live": "0.336.1",
226
+ "@cosmicdrift/kumiko-dispatcher-live": "0.338.0",
227
227
  "bun-types": "^1.3.13",
228
228
  "pino-pretty": "^13.1.3"
229
229
  },
package/src/changes.json CHANGED
@@ -1,4 +1,157 @@
1
1
  [
2
+ {
3
+ "version": "0.338.0",
4
+ "type": "improvement",
5
+ "title": "ctx.configFor(tenantId) resolves config for another tenant",
6
+ "detail": "Query and write handler contexts get `configFor(tenantId)` next to `config`, present when the config feature wired its accessor factory. The accessor runs as the target tenant's system user on a db scoped to that tenant, so the caller's user-scope values never leak into the result. Calls for another tenant throw `AccessDeniedError` unless the caller is the system identity or has the `SystemAdmin` role; the caller's own tenant needs no privilege and returns the same accessor as `ctx.config`.",
7
+ "migration": "No code change needed. Handlers that compute values for a tenant other than the caller's (limits, quotas, billing previews) call `ctx.configFor(tenantId)` instead of passing `ctx.config`."
8
+ },
9
+ {
10
+ "version": "0.338.0",
11
+ "type": "improvement",
12
+ "title": "escapeHatch grants split the SYSTEM identity switch from raw access",
13
+ "detail": "escapeHatch: new optional `grants` (`systemIdentity`, `globalWrites`, `unsafeRaw`) narrows what a declaration unlocks. Without `grants` the declaration keeps unlocking all three. An empty or unknown `grants` list is a boot error. Bundled handlers that only needed the raw runner now declare `grants: [\"unsafeRaw\"]`, so they no longer gain the SYSTEM identity switch.",
14
+ "migration": "No action needed: `grants` is additive and an escapeHatch without it behaves as before.\nDeclare `grants: [\"unsafeRaw\"]` on handlers that only call ctx.db.unsafeRaw to drop the SYSTEM identity switch."
15
+ },
16
+ {
17
+ "version": "0.338.0",
18
+ "type": "improvement",
19
+ "title": "boot warning for file and image fields without a personal annotation",
20
+ "detail": "Boot validation warns for every `file`, `image`, `files` and `images` field without a `personal` annotation. A forget with strategy `delete` keeps the binary of such a field and only severs the uploader link, so the stance has to be declared.",
21
+ "migration": "Mark fields that hold personal data with `personal: \"self\"` or `personal: { of: \"<ownerField>\" }`, and business data with `personal: false, reason: \"is_business_data\"`. Unannotated fields keep their binary on forget."
22
+ },
23
+ {
24
+ "version": "0.338.0",
25
+ "type": "fix",
26
+ "title": "reference label search applies target read access on the plaintext path",
27
+ "detail": "Searching a list by a reference field's label now respects the target entity's row-level `access.read` and the label field's own `access.read` on the plaintext (ILIKE) path. Before, a viewer who could not read the target row or its label could probe names through the match results."
28
+ },
29
+ {
30
+ "version": "0.338.0",
31
+ "type": "fix",
32
+ "title": "/api/schema answers 401 instead of 400 tenant_required after logout",
33
+ "detail": "With `anonymousAccess` and an authoritative tenant resolver, a request without a token to `/api/schema` ran through tenant resolution and failed with 400 `tenant_required`. Paths that need a session but no tenant (`/api/auth/*` and `/api/schema`) are now listed in `isSessionRequiredApiPath` and answer 401 `missing_token` before any tenant lookup.",
34
+ "migration": "No code change needed."
35
+ },
36
+ {
37
+ "version": "0.338.0",
38
+ "type": "breaking",
39
+ "title": "searchable text fields must not declare a restricted access.read",
40
+ "detail": "Boot validation rejects a text field that is `searchable` (or `find: \"fuzzy\"`) and also has a restricted `access.read`. The search index is not filtered per role, so search matches leaked the value to roles that cannot read the field.",
41
+ "migration": "Remove `searchable` / `find: \"fuzzy\"` from the field, or remove the read restriction. Write-only `access.write` restrictions are unaffected."
42
+ },
43
+ {
44
+ "version": "0.338.0",
45
+ "type": "breaking",
46
+ "title": "signature routes with a :param segment or wildcard must not match framework paths",
47
+ "detail": "Boot validation rejects `entry: \"signature\"` extra routes whose pattern, including `:param` segments, matches a framework path such as `/api/write`. Such a route skipped the JWT, origin and CSRF guards for that path.",
48
+ "migration": "Give the route a static prefix, for example `/api/webhooks/:provider` instead of `/api/:provider` or `/:provider/write`."
49
+ },
50
+ {
51
+ "version": "0.338.0",
52
+ "type": "fix",
53
+ "title": "planned Scaleway purposes listed as planned sub-processor entry",
54
+ "detail": "The sub-processor list now carries the planned Scaleway purposes (key custody and transactional email) as their own `status: \"planned\"` entry. The active Scaleway entry only names the backup mirror, so the public `planned` section and change notifications see the upcoming processing."
55
+ },
56
+ {
57
+ "version": "0.338.0",
58
+ "type": "improvement",
59
+ "title": "unsafeRaw audit events carry the declared escapeHatch reason",
60
+ "detail": "`ctx.db.unsafeRaw(reason)` and `ctx.systemDb.unsafeRaw(reason)` now write the reason declared on the handler or hook `escapeHatch` into the escape-hatch audit event, not the string passed at the call. A call-site string can no longer put arbitrary text into the audit trail.",
61
+ "migration": "No action needed: calls that pass the same reason as the declaration audit unchanged. Calls with a different string now audit the declared reason."
62
+ },
63
+ {
64
+ "version": "0.337.1",
65
+ "type": "improvement",
66
+ "title": "Designer reads and creates r.secretNamespace, edits writeRoles on secrets",
67
+ "detail": "The Designer source model now parses `r.secretNamespace(...)` as its own pattern kind `secretNamespace` instead of falling back to `unknown`, renders it back and can add it through the pattern library. A Zod `nameSchema` or an options identifier is kept verbatim. The `r.secret` form gains a `writeRoles` list and no longer offers the `system` scope, which the secret type never allowed."
68
+ },
69
+ {
70
+ "version": "0.337.0",
71
+ "type": "fix",
72
+ "title": "variant() ignores caller-supplied resolvedOverlays",
73
+ "detail": "`derivatives.variant()` now discards a caller-supplied `resolvedOverlays`, so a literal overlay payload can no longer bypass the overlay resolver. The guarded admin seed throws instead of silently picking by id when a user row's `insertedAt` is not an instant."
74
+ },
75
+ {
76
+ "version": "0.337.0",
77
+ "type": "breaking",
78
+ "title": "Anonymous callers on protected handlers get 401 unauthenticated, and a stale auth cookie no longer blocks public handlers",
79
+ "migration": "Tests or clients that expect 403 / access_denied for an anonymous caller (no session, the anonymousAccess user, or a JWT carrying only the anonymous role) on a role-gated or openToAll handler now get 401 / unauthenticated; update those assertions. Signed-in callers without the role still get 403 access_denied. With anonymousAccess wired, a request whose auth cookie fails verification or whose session is no longer live continues as anonymous outside /api/auth/* (public handlers answer 200, protected ones 401 unauthenticated) instead of failing with 401 invalid_token / session_invalid; bearer tokens and /api/auth/* keep the explicit 401."
80
+ },
81
+ {
82
+ "version": "0.337.0",
83
+ "type": "fix",
84
+ "title": "Anonymous SSE signals no longer carry row id or version",
85
+ "detail": "An anonymous /api/sse connection gets every row signal of a declared liveEntities entity, including rows the anonymous query hides. The frame now carries only aggregateType, eventType and createdAt, so a public viewer can no longer see which hidden rows exist or change; signed-in connections keep id and version. LiveEvent.data.id and version are optional in kumiko-renderer accordingly."
86
+ },
87
+ {
88
+ "version": "0.337.0",
89
+ "type": "breaking",
90
+ "title": "buildServer refuses partially wired session callbacks",
91
+ "migration": "If you call buildServer with auth.sessionCreator or auth.sessionRevoker, also wire the other two of sessionCreator, sessionRevoker and sessionChecker (the sessions feature's sessionStore provides all three; runProdApp and runDevApp already do this). Without a sessionChecker a logout never invalidated the JWT. A sessionChecker alone stays allowed."
92
+ },
93
+ {
94
+ "version": "0.337.0",
95
+ "type": "fix",
96
+ "title": "Job triggers accept short handler and event names",
97
+ "detail": "r.job trigger.on now qualifies a short name against the job's own feature (write handler, then query handler, then defineEvent), like notification triggers, and the boot error lists every name it tried."
98
+ },
99
+ {
100
+ "version": "0.337.0",
101
+ "type": "improvement",
102
+ "title": "Boot rejects a collapsed relatedList group without a header; expandableRow groupBy keys are required i18n keys",
103
+ "detail": "Boot fails when `groupBy.collapsedWhen` names a group that has neither `label` nor a `labels` entry, because its rows could never be opened. The required surface keys now include `groupBy.label` and `groupBy.labels` of an entityList `expandableRow`, as they already did for projectionDetail relatedList sections.",
104
+ "migration": "Add translations for expandableRow `groupBy` header keys if the i18n check reports them missing."
105
+ },
106
+ {
107
+ "version": "0.337.0",
108
+ "type": "fix",
109
+ "title": "json format falls back to text, IPv4-first egress pin, structural claim comparison for identity switch",
110
+ "detail": "The `json` format no longer throws on BigInt or circular values and falls back to text. `resolvePublicHost` prefers an IPv4 address on dual-stack hosts. Identity-switch claim comparison no longer treats structurally equal object claims in a different key order as a different identity."
111
+ },
112
+ {
113
+ "version": "0.337.0",
114
+ "type": "fix",
115
+ "title": "reference search and sort skip soft-deleted targets, blind-index sweep resolves tables in public, key-manager retry releases 5xx bodies",
116
+ "detail": "List search and sort by a reference field now ignore soft-deleted target rows: a deleted customer's name no longer matches a reference search and no longer decides the position in a label sort. The blind-index sweep checks table existence in the `public` schema instead of relying on the session `search_path`. The Scaleway key-manager retry cancels the response body of a retried 5xx instead of leaving it open."
117
+ },
118
+ {
119
+ "version": "0.337.0",
120
+ "type": "fix",
121
+ "title": "boot validator rejects redirect on list row actions, httpRoute with non-literal anonymous is a parse error, local const shadows same-named import in feature-ast, r.nav strips undefined properties",
122
+ "detail": "The boot validator now rejects a `redirect` on `entityList`, `projectionList` and `relatedList` row actions, where it was silently ignored. The feature-ast parser reports an `httpRoute` whose `anonymous` is a non-literal expression as a parse error instead of rewriting it to `false` on round-trip, and a local non-literal `const` no longer resolves to a same-named import. `r.nav()` strips explicitly-`undefined` properties for every registration path."
123
+ },
124
+ {
125
+ "version": "0.337.0",
126
+ "type": "fix",
127
+ "title": "isRedisSseBroker narrows on a kind discriminant, tenant-lifecycle gate cache is bounded",
128
+ "detail": "`isRedisSseBroker` now narrows on a `kind: \"redis\"` discriminant that `createRedisSseBroker` sets, so an app-owned broker that happens to expose `close()` is no longer mistaken for the Redis one. The tenant-lifecycle gate cache is capped at 5000 entries, so anonymous requests with random `X-Tenant` ids can no longer grow it without bound."
129
+ },
130
+ {
131
+ "version": "0.337.0",
132
+ "type": "fix",
133
+ "title": "search extensions never see sensitive row fields on named events, streams cannot hard-delete (gate message now says so), codemod walkers and namespace imports hardened",
134
+ "detail": "The search consumer no longer hands `sensitive` columns of the projection row to `searchPayloadExtension` contributors when a named domain event triggers the re-index. A `skipApplyErrors` rebuild option passed for a multi-stream projection is now logged as ignored instead of dropped silently. The irreversible-operation gate error for stream entry handlers says that streams cannot declare an agent risk (streams always resolve `mid`, so a hard delete or `forget` inside a stream is denied; dispatch a risk `high` write handler directly). The `migrate-open-to-all` codemod skips symlinks and `dist`/`build`/`.next`/`.git`, and the `unprocessable-error-details-reason` codemod follows namespace imports and reports member-access callees it cannot resolve."
135
+ },
136
+ {
137
+ "version": "0.337.0",
138
+ "type": "fix",
139
+ "title": "search remove no longer creates indexes, upgrade --apply keeps a partial marker on codemod failure, test stack syncs indexes and cleans queue keys, codemod walkers skip symlinks",
140
+ "detail": "Meilisearch `remove` and `removeBatch` no longer configure (and thereby implicitly create) the tenant index before deleting. `kumiko-upgrade --apply` writes a partial upgrade marker when a later codemod fails, so a re-run resumes at the failed codemod instead of replaying the ones that already wrote files. The changelog lookup claims a package name in the nearest `node_modules` even when it has no `changes.json`, instead of listing a farther version's changelog. The test stack syncs missing indexes (including unique) on a persistent dev DB and removes the BullMQ keys of its derived per-stack queues on cleanup. `bridgeStub` defaults to the real anonymous identity. The `migrate-cross-tenant` and `migrate-db-raw` codemods share one walker with `migrate-open-to-all` that skips symlinks and `dist`/`build`/`.next`/`.git`."
141
+ },
142
+ {
143
+ "version": "0.337.0",
144
+ "type": "fix",
145
+ "title": "Removed unreachable PAT rate-limit guard from the httpRoute chain",
146
+ "detail": "The httpRoute guard chain no longer includes the PAT rate-limit guard. PATs are rejected on httpRoutes before it could count, so behavior is unchanged."
147
+ },
148
+ {
149
+ "version": "0.337.0",
150
+ "type": "improvement",
151
+ "title": "r.secretNamespace declares a family of runtime-named secret keys",
152
+ "detail": "`r.secretNamespace(name, { label, scope: \"tenant\", writeRoles?, nameSchema? })` declares the prefix `<feature>:<name>.` (kebab-cased) and returns `{ prefix, keyFor(name) }`. `secrets:write:set` and `secrets:write:delete` accept a key under that prefix when the suffix is non-empty and passes `nameSchema`; `writeRoles` applies to every key in the namespace. Namespaces are kept out of `getAllSecretKeys` and the generated secrets screen. `Registry.findSecretNamespace(key)` resolves the namespace of a key. step-dispatcher declares `webhook-auth`, so webhook auth secrets (`step-dispatcher:webhook-auth.<name>`) stay settable through the API.",
153
+ "migration": "No code change needed. A feature that stores secrets under a runtime-chosen suffix declares a namespace instead of one `r.secret` per key."
154
+ },
2
155
  {
3
156
  "version": "0.336.1",
4
157
  "type": "fix",
@@ -0,0 +1,48 @@
1
+ import { lstatSync, readdirSync, statSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ const BUILD_AND_VENDOR_DIR_NAMES: readonly string[] = [
5
+ "node_modules",
6
+ "dist",
7
+ "build",
8
+ ".next",
9
+ ".git",
10
+ ];
11
+
12
+ export interface CollectSourceFilesOptions {
13
+ readonly isMigratableFile: (name: string) => boolean;
14
+ readonly extraSkippedDirNames?: readonly string[];
15
+ }
16
+
17
+ export function collectSourceFiles(
18
+ paths: readonly string[],
19
+ options: CollectSourceFilesOptions,
20
+ ): string[] {
21
+ const skippedDirNames = new Set([
22
+ ...BUILD_AND_VENDOR_DIR_NAMES,
23
+ ...(options.extraSkippedDirNames ?? []),
24
+ ]);
25
+
26
+ function walkDir(dir: string, out: string[]): void {
27
+ for (const name of readdirSync(dir)) {
28
+ if (skippedDirNames.has(name)) continue;
29
+ const full = join(dir, name);
30
+ const stat = lstatSync(full);
31
+ // Symlinks can point back at a parent directory (workspace links) and loop forever.
32
+ if (stat.isSymbolicLink()) continue;
33
+ if (stat.isDirectory()) {
34
+ walkDir(full, out);
35
+ continue;
36
+ }
37
+ if (options.isMigratableFile(name)) out.push(full);
38
+ }
39
+ }
40
+
41
+ const files: string[] = [];
42
+ for (const p of paths) {
43
+ const abs = resolve(p);
44
+ if (statSync(abs).isDirectory()) walkDir(abs, files);
45
+ else if (options.isMigratableFile(abs)) files.push(abs);
46
+ }
47
+ return files;
48
+ }
@@ -6,8 +6,7 @@
6
6
  // Usage:
7
7
  // bun scripts/codemod/migrate-cross-tenant.ts [--dry-run] <path...>
8
8
 
9
- import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
10
- import { join, resolve } from "node:path";
9
+ import { readFileSync, writeFileSync } from "node:fs";
11
10
  import {
12
11
  type CallExpression,
13
12
  Node,
@@ -16,6 +15,7 @@ import {
16
15
  type PropertyAssignment,
17
16
  SyntaxKind,
18
17
  } from "ts-morph";
18
+ import { collectSourceFiles } from "./collect-source-files.js";
19
19
 
20
20
  export interface Rewrite {
21
21
  readonly file: string;
@@ -264,28 +264,8 @@ function isMigratableFile(name: string): boolean {
264
264
  return /\.(ts|tsx)$/.test(name);
265
265
  }
266
266
 
267
- function walkDir(dir: string, out: string[]): void {
268
- for (const name of readdirSync(dir)) {
269
- if (name === "node_modules") continue;
270
- const full = join(dir, name);
271
- const stat = statSync(full);
272
- if (stat.isDirectory()) {
273
- walkDir(full, out);
274
- continue;
275
- }
276
- if (isMigratableFile(name)) out.push(full);
277
- }
278
- }
279
-
280
267
  function collectFiles(paths: string[]): string[] {
281
- const files: string[] = [];
282
- for (const p of paths) {
283
- const abs = resolve(p);
284
- const stat = statSync(abs);
285
- if (stat.isDirectory()) walkDir(abs, files);
286
- else if (isMigratableFile(abs)) files.push(abs);
287
- }
288
- return files;
268
+ return collectSourceFiles(paths, { isMigratableFile });
289
269
  }
290
270
 
291
271
  async function main(): Promise<void> {
@@ -7,9 +7,9 @@
7
7
  // Usage:
8
8
  // bun scripts/codemod/migrate-db-raw.ts [--dry-run] [--global-tables a,b] <path...>
9
9
 
10
- import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
- import { join, resolve } from "node:path";
10
+ import { readFileSync, writeFileSync } from "node:fs";
12
11
  import { Node, Project, type SourceFile, SyntaxKind, VariableDeclarationKind } from "ts-morph";
12
+ import { collectSourceFiles } from "./collect-source-files.js";
13
13
 
14
14
  const DEFAULT_GLOBAL_TABLES: ReadonlySet<string> = new Set([
15
15
  "userTable",
@@ -334,28 +334,11 @@ function isMigratableFile(name: string): boolean {
334
334
  return true;
335
335
  }
336
336
 
337
- function walkDir(dir: string, out: string[]): void {
338
- for (const name of readdirSync(dir)) {
339
- if (name === "node_modules" || name === "__tests__") continue;
340
- const full = join(dir, name);
341
- const stat = statSync(full);
342
- if (stat.isDirectory()) {
343
- walkDir(full, out);
344
- continue;
345
- }
346
- if (isMigratableFile(name)) out.push(full);
347
- }
348
- }
349
-
350
337
  function collectFiles(paths: string[]): string[] {
351
- const files: string[] = [];
352
- for (const p of paths) {
353
- const abs = resolve(p);
354
- const stat = statSync(abs);
355
- if (stat.isDirectory()) walkDir(abs, files);
356
- else if (isMigratableFile(abs)) files.push(abs);
357
- }
358
- return files;
338
+ return collectSourceFiles(paths, {
339
+ isMigratableFile,
340
+ extraSkippedDirNames: ["__tests__"],
341
+ });
359
342
  }
360
343
 
361
344
  async function main(): Promise<void> {