@cosmicdrift/kumiko-framework 0.390.0 → 0.391.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.
@@ -8,6 +8,10 @@ Self-service account dashboard: MFA enrollment, recovery codes and the user's ow
8
8
 
9
9
  Requires: `sessions`, `auth-mfa`
10
10
 
11
+ ## admin-nav
12
+
13
+ Administration group in the sidebar of this sample app.
14
+
11
15
  ## admin-shell
12
16
 
13
17
  Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `audit:screen:audit-log-all-tenants`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`, optional `cap-overview:screen:my-caps`/`cap-overview:screen:tenant-cap-list`, optional `document-designer` template navs) and the two overview dashboards, built from the metrics list (`metrics` option, default DEFAULT_METRICS). Mount after user, tenant, audit, jobs, metrics and metrics-system; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.
@@ -538,12 +542,14 @@ Category: operations
538
542
 
539
543
  ## readiness
540
544
 
541
- One-call tenant-onboarding probe: `readiness:query:status` rolls up every config key and secret declared `required: true` across all mounted features and reports which still lack a usable value for the calling tenant, plus a single `ready` boolean. Provider-features under an `r.extensionSelector`-declared extension point count only while their provider is the selected one — a tenant on the inmemory mail transport is not blocked by unset SMTP keys. Mount it (together with `config` and `secrets`) when an admin UI needs a settings checklist before the first mail-send or file-write; the per-concern lists stay available via `config:query:readiness` and `secrets:query:list`.
545
+ One-call tenant-onboarding probe: `readiness:query:status` rolls up every config key and secret declared `required: true` across all mounted features and reports which still lack a usable value for the calling tenant, plus a single `ready` boolean. Provider-features under an `r.extensionSelector`-declared extension point count only while their provider is the selected one — a tenant on the inmemory mail transport is not blocked by unset SMTP keys. Mount it (together with `config` and `secrets`) when an admin UI needs a settings checklist before the first mail-send or file-write; the per-concern lists stay available via `config:query:readiness` and `secrets:query:list`. `readiness:write:run-probes` runs the live probes features register with `r.probe` (mail, files, mailboxes, PDF service and more) as a job; the system-check screen shows the latest run and the history once `nav` is passed.
542
546
 
543
547
  Category: operations
544
548
 
545
549
  Requires: `config`, `secrets`
546
550
 
551
+ Optional requires: `user`
552
+
547
553
  ## renderer-foundation
548
554
 
549
555
  Plugin registry for content rendering (mail HTML, PDF, images): call `foundation.createRendererForTenant({ tenantId, kind })` at render time to get the right renderer plugin selected by kind, with tenant-level overrides via the `rendererPluginByKind` config key. Requires `template-resolver` (declared via `r.requires`). Low-level building block — add `renderer-mail-html` (or write a custom plugin via `r.useExtension("renderer", name, { kinds, render })`) rather than using this feature alone.
@@ -16,6 +16,8 @@ export type { JwtHelper } from "./jwt.ts";
16
16
  export { loadJwtSecretOrKeyring } from "./jwt.ts";
17
17
  export { publicOriginOf } from "./origin-middleware.ts";
18
18
  export type { PostAuthLandingArgs, PostAuthLandingResolver, } from "./post-auth-landing.ts";
19
+ export type { TimedRunResult } from "./readiness.ts";
20
+ export { runWithTimeout } from "./readiness.ts";
19
21
  export type { RedisSseBroker } from "./redis-sse-broker.ts";
20
22
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.ts";
21
23
  export { readonlyRequestContext as requestContext } from "./request-context-scopes.ts";
package/dist/api/index.js CHANGED
@@ -9,6 +9,7 @@ export { ExtraRouteRejection, signatureRoute } from "./extra-route.js";
9
9
  export { cacheControlHeader, cachedResponse, computeRevisionEtag, computeStrongEtag, computeWeakEtag, etagMatches, } from "./http-cache.js";
10
10
  export { loadJwtSecretOrKeyring } from "./jwt.js";
11
11
  export { publicOriginOf } from "./origin-middleware.js";
12
+ export { runWithTimeout } from "./readiness.js";
12
13
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker, } from "./redis-sse-broker.js";
13
14
  export { readonlyRequestContext as requestContext } from "./request-context-scopes.js";
14
15
  export { buildRequestContextDataFromRequest } from "./request-id-middleware.js";
@@ -18,6 +18,17 @@ export type ReadinessProbeOptions = {
18
18
  readonly timeoutMs?: number;
19
19
  };
20
20
  export declare function createReadinessProbe(checks: readonly ReadinessCheck[], opts?: ReadinessProbeOptions): () => Promise<ReadinessResult>;
21
+ export type TimedRunResult<T> = {
22
+ readonly ok: true;
23
+ readonly value: T;
24
+ readonly latencyMs: number;
25
+ } | {
26
+ readonly ok: false;
27
+ readonly error: unknown;
28
+ readonly timedOut: boolean;
29
+ readonly latencyMs: number;
30
+ };
31
+ export declare function runWithTimeout<T>(run: () => Promise<T>, timeoutMs: number): Promise<TimedRunResult<T>>;
21
32
  export declare function dbPingCheck(db: DbConnection): ReadinessCheck;
22
33
  export declare function redisPingCheck(redis: Redis): ReadinessCheck;
23
34
  export declare function dispatcherLagCheck(db: DbConnection, consumerNames: readonly string[], maxLagEvents: bigint): ReadinessCheck;
@@ -23,31 +23,29 @@ export function createReadinessProbe(checks, opts = {}) {
23
23
  };
24
24
  };
25
25
  }
26
- async function runOne(check, timeoutMs) {
26
+ class TimeoutError extends Error {
27
+ }
28
+ export async function runWithTimeout(run, timeoutMs) {
27
29
  const start = performance.now();
30
+ const elapsedMs = () => Math.round(performance.now() - start);
28
31
  let timer;
29
32
  try {
30
- await Promise.race([
31
- check.run(),
33
+ const value = await Promise.race([
34
+ run(),
32
35
  new Promise((_, reject) => {
33
- timer = setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
36
+ timer = setTimeout(() => reject(new TimeoutError(`timeout after ${timeoutMs}ms`)), timeoutMs);
34
37
  // Don't keep the event loop alive on a hung probe during shutdown.
35
38
  timer.unref?.();
36
39
  }),
37
40
  ]);
38
- return {
39
- name: check.name,
40
- ok: true,
41
- latencyMs: Math.round(performance.now() - start),
42
- };
41
+ return { ok: true, value, latencyMs: elapsedMs() };
43
42
  }
44
- catch (err) {
45
- const message = err instanceof Error ? err.message : String(err);
43
+ catch (error) {
46
44
  return {
47
- name: check.name,
48
45
  ok: false,
49
- latencyMs: Math.round(performance.now() - start),
50
- error: message,
46
+ error,
47
+ timedOut: error instanceof TimeoutError,
48
+ latencyMs: elapsedMs(),
51
49
  };
52
50
  }
53
51
  finally {
@@ -55,6 +53,17 @@ async function runOne(check, timeoutMs) {
55
53
  clearTimeout(timer);
56
54
  }
57
55
  }
56
+ async function runOne(check, timeoutMs) {
57
+ const result = await runWithTimeout(check.run, timeoutMs);
58
+ if (result.ok)
59
+ return { name: check.name, ok: true, latencyMs: result.latencyMs };
60
+ return {
61
+ name: check.name,
62
+ ok: false,
63
+ latencyMs: result.latencyMs,
64
+ error: result.error instanceof Error ? result.error.message : String(result.error),
65
+ };
66
+ }
58
67
  // --- Standard checks --------------------------------------------------------
59
68
  export function dbPingCheck(db) {
60
69
  return {
@@ -16,7 +16,7 @@
16
16
  // SubProcessor has no "removed" state yet: a removed entry is deleted from
17
17
  // the array, so until the notification cron exists a removal is documented
18
18
  // only in the changelog. Removed so far: Mailbox.org (mailbox hosting),
19
- // replaced by ALL-INKL.COM.
19
+ // replaced by ALL-INKL.COM, and Cloudflare (DNS/CDN), replaced by Hetzner DNS.
20
20
  /**
21
21
  * Platform-wide sub-processor list.
22
22
  *
@@ -33,16 +33,6 @@ export const KUMIKO_SUB_PROCESSORS = [
33
33
  appliesTo: ["all-tiers"],
34
34
  status: "active",
35
35
  },
36
- {
37
- name: "Cloudflare, Inc.",
38
- purpose: "DNS, CDN, DDoS-Protection, WAF",
39
- region: "Global (US-headquartered)",
40
- dpa: "https://www.cloudflare.com/cloudflare-customer-dpa",
41
- addedAt: "2024-01-01",
42
- appliesTo: ["all-tiers"],
43
- sccRequired: true,
44
- status: "active",
45
- },
46
36
  {
47
37
  name: "Sendinblue SAS (Brevo)",
48
38
  purpose: "Transactional Email Delivery",
@@ -65,9 +65,30 @@ const INTEGER_PG_TYPES = new Set([
65
65
  export function instantToTimestamptzParam(instant) {
66
66
  return Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds).toString();
67
67
  }
68
+ function instantOfIsoString(value) {
69
+ try {
70
+ return Temporal.Instant.from(value);
71
+ }
72
+ catch {
73
+ // skip: not an ISO instant, Postgres parses (or rejects) the text itself
74
+ return undefined;
75
+ }
76
+ }
77
+ // Event payloads carry instants as ISO strings with microseconds; a projection rebuild binds them
78
+ // as-is, so without the same truncation the replayed row differs from the live one.
79
+ function timestamptzInstantOf(value) {
80
+ if (isTemporalInstant(value))
81
+ return value;
82
+ if (typeof value === "string")
83
+ return instantOfIsoString(value);
84
+ return undefined;
85
+ }
68
86
  function prepareDriverNeutralScalar(value, pgType) {
69
- if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
70
- return { kind: "param", sql: "", bound: instantToTimestamptzParam(value) };
87
+ if (pgType === "timestamptz" || pgType === "timestamptz(3)") {
88
+ const instant = timestamptzInstantOf(value);
89
+ if (instant !== undefined) {
90
+ return { kind: "param", sql: "", bound: instantToTimestamptzParam(instant) };
91
+ }
71
92
  }
72
93
  // Sent as text, so Postgres rejects 3.5 for an integer column instead of rounding it.
73
94
  if (typeof value === "number" && pgType !== undefined && INTEGER_PG_TYPES.has(pgType)) {
@@ -1,5 +1,6 @@
1
1
  import { extractPgError } from "../pg-error.js";
2
2
  import { asRawClient } from "../query.js";
3
+ import { toPostgresArrayLiteral } from "./postgres-array-literal.js";
3
4
  import { PROJECTION_REBUILD_SCHEMA } from "./shadow-swap.js";
4
5
  import { quoteTableIdent } from "./table-ops.js";
5
6
  // Re-exported for back-compat — the generic DDL helpers moved to ./ddl so
@@ -89,7 +90,7 @@ export async function transferPublicSchemaOwnership(db, owner) {
89
90
  JOIN pg_namespace n ON n.oid = c.relnamespace
90
91
  JOIN pg_roles r ON r.oid = c.relowner
91
92
  WHERE n.nspname = ANY($2::text[]) AND c.relkind IN ('r', 'p', 'S', 'v', 'm') AND r.rolname <> $1
92
- AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype IN ('a', 'i'))`, [owner, ownedSchemas]);
93
+ AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.objid = c.oid AND d.deptype IN ('a', 'i'))`, [owner, toPostgresArrayLiteral(ownedSchemas)]);
93
94
  for (const { nspname, relname, relkind } of relations) {
94
95
  const kind = OWNED_RELATION_KINDS[relkind];
95
96
  if (kind === undefined)
@@ -1,5 +1,6 @@
1
1
  import { type KnownTables } from "./known-table-names.ts";
2
2
  import type { AnyDb } from "./query.ts";
3
3
  import type { RowLevelSecurityMode } from "./render-rls-ddl.ts";
4
- export declare function assertRowLevelSecurityTableState(db: AnyDb, mode: RowLevelSecurityMode, knownTables: KnownTables): Promise<void>;
4
+ export type RowLevelSecurityCheckScope = "database" | "declared tables";
5
+ export declare function assertRowLevelSecurityTableState(db: AnyDb, mode: RowLevelSecurityMode, knownTables: KnownTables, scope?: RowLevelSecurityCheckScope): Promise<void>;
5
6
  export declare function assertRowLevelSecurityBootState(db: AnyDb, mode: RowLevelSecurityMode, knownTables: KnownTables): Promise<void>;
@@ -28,8 +28,9 @@ async function assertEnforcingRole(db) {
28
28
  function listingOf(entries) {
29
29
  return entries.map((entry) => `${entry.table} (${entry.missing.join(", ")})`).join("; ");
30
30
  }
31
- async function assertAllTenantTablesProtected(db, knownTables) {
31
+ async function assertAllTenantTablesProtected(db, knownTables, scope) {
32
32
  const incomplete = (await readTenantTablesRowLevelSecurity(db))
33
+ .filter((table) => scope === "database" || knownTables.has(table.tableName))
33
34
  .map((table) => ({ table: table.tableName, missing: missingPartsOf(table) }))
34
35
  .filter((entry) => entry.missing.length > 0);
35
36
  // skip: every tenant table is protected
@@ -59,8 +60,8 @@ async function assertAllTenantTablesProtected(db, knownTables) {
59
60
  message: `rowLevelSecurity is "on" but tenant tables lack row-level security. ${sections.join(" ")}`,
60
61
  });
61
62
  }
62
- async function assertNoLiveRowLevelSecurity(db) {
63
- const tables = await readTablesWithRowLevelSecurityEnabled(db);
63
+ async function assertNoLiveRowLevelSecurity(db, knownTables, scope) {
64
+ const tables = (await readTablesWithRowLevelSecurityEnabled(db)).filter((table) => scope === "database" || knownTables.has(table));
64
65
  // skip: no live row-level security to disable
65
66
  if (tables.length === 0)
66
67
  return;
@@ -70,13 +71,13 @@ async function assertNoLiveRowLevelSecurity(db) {
70
71
  }
71
72
  // Table state only, no role check: the role is a runtime property of the boot connection, so tools
72
73
  // that connect with another role (CI, `schema validate`) use this one.
73
- export async function assertRowLevelSecurityTableState(db, mode, knownTables) {
74
+ export async function assertRowLevelSecurityTableState(db, mode, knownTables, scope = "database") {
74
75
  if (mode === "off") {
75
- await assertNoLiveRowLevelSecurity(db);
76
+ await assertNoLiveRowLevelSecurity(db, knownTables, scope);
76
77
  // skip: off-mode check done
77
78
  return;
78
79
  }
79
- await assertAllTenantTablesProtected(db, knownTables);
80
+ await assertAllTenantTablesProtected(db, knownTables, scope);
80
81
  }
81
82
  export async function assertRowLevelSecurityBootState(db, mode, knownTables) {
82
83
  if (mode === "on")
@@ -43,6 +43,10 @@ export declare function validateColumnRendererForm(featureName: string, screenId
43
43
  readonly field: string;
44
44
  readonly renderer?: unknown;
45
45
  }): void;
46
+ export declare function validateEnumOptionTones(prefix: string, column: {
47
+ readonly field: string;
48
+ readonly renderer?: unknown;
49
+ }): void;
46
50
  export declare function assertRefTargetRegistered(prefix: string, subject: string, refTarget: string, currentFeatureName: string, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
47
51
  export declare function validateListFacets(prefix: string, facets: readonly ListFacetSpec[], columns: readonly ListColumnSpec[], currentFeatureName: string, featureMap: ReadonlyMap<string, FeatureDefinition>): void;
48
52
  export {};
@@ -1,3 +1,5 @@
1
+ import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
2
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
1
3
  import { REFERENCE_LOOKUP_SOURCES } from "../../ui-types/list-row-meta.js";
2
4
  import { parseRefTarget } from "../parse-ref-target.js";
3
5
  import { qualifyEntityName } from "../qualified-name.js";
@@ -6,6 +8,7 @@ import { validateListBulkActions } from "./list-bulk-actions.js";
6
8
  import { validateListTableOptions } from "./list-table-options.js";
7
9
  import { validateRelatedListGroupingRules } from "./related-list-table-rules.js";
8
10
  import { assertWriteHandlerRegistered, isRowClickAction, rejectRedirectOnListRowAction, resolveRowActionNavigateTarget, validateClientActionName, validateDrawerTargetAction, validateRowActionNavigateParams, } from "./screens-actions.js";
11
+ import { isSelectOptionTone } from "./select-option-tone.js";
9
12
  // validateScreenHasNavArea runs once per screen; the index is built once per
10
13
  // featureMap (immutable during boot) instead of re-scanning every feature each call.
11
14
  const navAreaIndexByFeatureMap = new WeakMap();
@@ -91,6 +94,7 @@ export function validateRelatedListSection(args) {
91
94
  }
92
95
  for (const col of section.columns) {
93
96
  const normalizedCol = normalizeListColumn(col);
97
+ validateEnumOptionTones(`[Feature ${featureName}] ${where}`, normalizedCol);
94
98
  if (normalizedCol.refEntity !== undefined) {
95
99
  assertRefTargetRegistered(`[Feature ${featureName}] ${where}`, `column "${normalizedCol.field}" (refEntity)`, normalizedCol.refEntity, featureName, featureMap);
96
100
  }
@@ -226,6 +230,7 @@ export function rejectInertColumnSortable(featureName, screenId, screenType, col
226
230
  // string. A key omitted on the client only triggers a warning, not a boot
227
231
  // failure.
228
232
  export function validateColumnRendererForm(featureName, screenId, column) {
233
+ validateEnumOptionTones(`[Feature ${featureName}] Screen "${screenId}"`, column);
229
234
  const renderer = column.renderer;
230
235
  // skip: only the PlatformComponent form ({ react: { __component: "..." } })
231
236
  // is validated structurally. Function, string-QN and null/undefined
@@ -254,6 +259,26 @@ export function validateColumnRendererForm(featureName, screenId, column) {
254
259
  `a client-side columnRenderers entry.`);
255
260
  }
256
261
  }
262
+ // A typo'd tone would silently fall back to the neutral pill.
263
+ export function validateEnumOptionTones(prefix, column) {
264
+ const { renderer } = column;
265
+ const isEnumOption = typeof renderer === "object" &&
266
+ renderer !== null &&
267
+ propertyOf(renderer, "format") === "enumOption";
268
+ const tones = isEnumOption ? propertyOf(renderer, "tones") : undefined;
269
+ // skip: only an enumOption column with tones has tones to check.
270
+ if (tones === undefined)
271
+ return;
272
+ const invalid = typeof tones !== "object" || tones === null
273
+ ? [String(tones)]
274
+ : Object.values(tones)
275
+ .filter((tone) => !isSelectOptionTone(tone))
276
+ .map(String);
277
+ if (invalid.length > 0) {
278
+ throw new Error(`${prefix} column "${column.field}" has enumOption tones ${invalid.join(", ")} — ` +
279
+ `expected one of ${SELECT_OPTION_TONES.join(", ")}.`);
280
+ }
281
+ }
257
282
  // Shared entity-target resolution for reference facets, refEntity columns
258
283
  // (projectionList/relatedList) and refEntity fields (projectionDetail).
259
284
  export function assertRefTargetRegistered(prefix, subject, refTarget, currentFeatureName, featureMap) {
@@ -1,6 +1,7 @@
1
1
  import { createInitialFeatureBuilderState } from "./feature-builder-state.js";
2
2
  import { buildConfigEventsJobsMethods } from "./feature-config-events-jobs.js";
3
3
  import { buildEntityHandlerMethods } from "./feature-entity-handlers.js";
4
+ import { buildProbeMethods } from "./feature-probes.js";
4
5
  import { buildUiExtensionsMethods } from "./feature-ui-extensions.js";
5
6
  import { isObjectArgument, throwObjectArgumentRequired, unwrapArrayForm } from "./object-form.js";
6
7
  // `TExports` lets the setup callback hand back a typed object that
@@ -89,6 +90,7 @@ export function defineFeature(name, setup, settings) {
89
90
  ...buildEntityHandlerMethods(state, name),
90
91
  ...buildConfigEventsJobsMethods(state, name),
91
92
  ...buildUiExtensionsMethods(state, name),
93
+ ...buildProbeMethods(state, name),
92
94
  };
93
95
  const exports = setup(registrar); // @cast-boundary engine-bridge
94
96
  return {
@@ -147,6 +149,7 @@ export function defineFeature(name, setup, settings) {
147
149
  metrics: state.metrics,
148
150
  secretKeys: state.secretKeys,
149
151
  secretNamespaces: state.secretNamespaces,
152
+ ...(Object.keys(state.probes).length > 0 && { probes: state.probes }),
150
153
  projections: state.projections,
151
154
  entityProjectionExtensions: state.entityProjectionExtensions,
152
155
  multiStreamProjections: state.multiStreamProjections,
@@ -3,6 +3,7 @@ import type { BootCheckFn, FeatureMetricDef, SecretKeyDefinition, SecretNamespac
3
3
  import type { EntityDefinition } from "@cosmicdrift/kumiko-types/fields";
4
4
  import type { AuthClaimsFn, ClaimKeyDefinition, EventDef, EventMigrationDef, QueryHandlerDef, StreamHandlerDef, WriteHandlerDef } from "@cosmicdrift/kumiko-types/handlers";
5
5
  import type { LifecycleHookFn, OwnedFn, PhasedHook, PostDeleteHookFn, PostQueryHookFn, PostSaveHookFn, PreDeleteHookFn, SearchPayloadContributorFn, ValidationHookFn } from "@cosmicdrift/kumiko-types/hooks";
6
+ import type { ProbeDefinition } from "@cosmicdrift/kumiko-types/probe";
6
7
  import type { EntityProjectionExtension, MultiStreamProjectionDefinition, ProjectionDefinition } from "@cosmicdrift/kumiko-types/projection";
7
8
  import type { RelationDefinition } from "@cosmicdrift/kumiko-types/relations";
8
9
  import type { TreeActionDef } from "@cosmicdrift/kumiko-types/tree-node";
@@ -49,6 +50,7 @@ export type FeatureBuilderState = {
49
50
  metrics: Record<string, FeatureMetricDef>;
50
51
  secretKeys: Record<string, SecretKeyDefinition>;
51
52
  secretNamespaces: Record<string, SecretNamespaceDefinition>;
53
+ probes: Record<string, ProbeDefinition>;
52
54
  projections: Record<string, ProjectionDefinition>;
53
55
  multiStreamProjections: Record<string, MultiStreamProjectionDefinition>;
54
56
  entityProjectionExtensions: Record<string, EntityProjectionExtension[]>;
@@ -42,6 +42,7 @@ export function createInitialFeatureBuilderState() {
42
42
  metrics: {},
43
43
  secretKeys: {},
44
44
  secretNamespaces: {},
45
+ probes: {},
45
46
  projections: {},
46
47
  multiStreamProjections: {},
47
48
  entityProjectionExtensions: {},
@@ -0,0 +1,5 @@
1
+ import type { ProbeOptions } from "@cosmicdrift/kumiko-types/probe";
2
+ import type { FeatureBuilderState } from "./feature-builder-state.ts";
3
+ export declare function buildProbeMethods(state: FeatureBuilderState, featureName: string): {
4
+ probe(options: ProbeOptions): void;
5
+ };
@@ -0,0 +1,46 @@
1
+ import { isKebabSegment, QnTypes, qn, toKebab } from "./qualified-name.js";
2
+ const PROBE_SCOPES = ["tenant", "system"];
3
+ const PROBE_COSTS = ["free", "paid"];
4
+ function assertValidProbe(featureName, options) {
5
+ const prefix = `[Feature ${featureName}] r.probe()`;
6
+ if (typeof options.name !== "string" || options.name.length === 0) {
7
+ throw new Error(`${prefix}: name must be a non-empty string`);
8
+ }
9
+ if (!isKebabSegment(options.name)) {
10
+ throw new Error(`${prefix}: name "${options.name}" must be kebab-case`);
11
+ }
12
+ if (!PROBE_SCOPES.includes(options.scope)) {
13
+ throw new Error(`${prefix} "${options.name}": scope must be one of ${PROBE_SCOPES.join(", ")}`);
14
+ }
15
+ if (!PROBE_COSTS.includes(options.cost)) {
16
+ throw new Error(`${prefix} "${options.name}": cost must be one of ${PROBE_COSTS.join(", ")}`);
17
+ }
18
+ for (const field of ["area", "label"]) {
19
+ if (typeof options[field] !== "string" || options[field].length === 0) {
20
+ throw new Error(`${prefix} "${options.name}": ${field} must be a non-empty i18n key`);
21
+ }
22
+ }
23
+ if (options.timeoutMs !== undefined && !(options.timeoutMs > 0)) {
24
+ throw new Error(`${prefix} "${options.name}": timeoutMs must be > 0`);
25
+ }
26
+ if (typeof options.run !== "function") {
27
+ throw new Error(`${prefix} "${options.name}": run must be a function`);
28
+ }
29
+ if (options.targets !== undefined && typeof options.targets !== "function") {
30
+ throw new Error(`${prefix} "${options.name}": targets must be a function`);
31
+ }
32
+ }
33
+ export function buildProbeMethods(state, featureName) {
34
+ return {
35
+ probe(options) {
36
+ assertValidProbe(featureName, options);
37
+ if (state.probes[options.name]) {
38
+ throw new Error(`[Feature ${featureName}] Probe "${options.name}" already registered. Probe names must be unique per feature.`);
39
+ }
40
+ state.probes[options.name] = {
41
+ ...options,
42
+ qualifiedName: qn(toKebab(featureName), QnTypes.probe, options.name),
43
+ };
44
+ },
45
+ };
46
+ }
@@ -11,6 +11,7 @@ export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN,
11
11
  export type { DeleteContext, PostDeleteHookFn, PostSaveHookFn, PreDeleteHookFn, PreSaveHookFn, SaveContext, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
12
12
  export type { EntityId } from "@cosmicdrift/kumiko-types/identifiers";
13
13
  export type { ContentCollectionDefinition, NavDefinition, NavIconKey, } from "@cosmicdrift/kumiko-types/nav";
14
+ export type { ProbeContext, ProbeCost, ProbeDefinition, ProbeOptions, ProbeScope, ProbeTarget, ProbeText, ProbeVerdict, } from "@cosmicdrift/kumiko-types/probe";
14
15
  export type { MspErrorMode, MultiStreamApplyFn, MultiStreamProjectionDefinition, ProjectionDefinition, } from "@cosmicdrift/kumiko-types/projection";
15
16
  export type { RelationDefinition } from "@cosmicdrift/kumiko-types/relations";
16
17
  export type { ActionFormScreenDefinition, BundledListsOptions, ConfigEditScreenDefinition, DashboardCustomPanel, DashboardI18nText, EditFieldSpec, EditRelatedListSection, EditSectionSpec, EntityEditScreenDefinition, EntityListScreenDefinition, FieldCondition, FieldFormatRegistry, ListColumnSpec, ListFacetSpec, ListTableOptions, MetricNavigate, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RowAction, RowActionWriteHandler, ScreenDefinition, SecretMintScreenDefinition, ToolbarAction, UnitKey, } from "@cosmicdrift/kumiko-types/screen";
@@ -70,6 +71,7 @@ export { from, normalizeAccessEntry, } from "./ownership.ts";
70
71
  export { pagedListSchema, pageRows, } from "./paged-list.ts";
71
72
  export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target.ts";
72
73
  export { buildPipelineSteps, stepsPipeline } from "./pipeline.ts";
74
+ export { ProbeFailure } from "./probe-failure.ts";
73
75
  export { projectAppSchemaForRoles } from "./project-app-schema-for-roles.ts";
74
76
  export { defineApply, defineMspApply, setFields } from "./projection-helpers.ts";
75
77
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.ts";
@@ -55,6 +55,7 @@ export { from, normalizeAccessEntry, } from "./ownership.js";
55
55
  export { pagedListSchema, pageRows, } from "./paged-list.js";
56
56
  export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target.js";
57
57
  export { buildPipelineSteps, stepsPipeline } from "./pipeline.js";
58
+ export { ProbeFailure } from "./probe-failure.js";
58
59
  export { projectAppSchemaForRoles } from "./project-app-schema-for-roles.js";
59
60
  export { defineApply, defineMspApply, setFields } from "./projection-helpers.js";
60
61
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name.js";
@@ -0,0 +1,5 @@
1
+ import type { ProbeText } from "@cosmicdrift/kumiko-types/probe";
2
+ export declare class ProbeFailure extends Error {
3
+ readonly text: ProbeText;
4
+ constructor(text: ProbeText);
5
+ }
@@ -0,0 +1,9 @@
1
+ // Thrown by a probe to report a translatable failure; any other error maps to a generic text so raw messages never reach the run.
2
+ export class ProbeFailure extends Error {
3
+ text;
4
+ constructor(text) {
5
+ super(text.key);
6
+ this.text = text;
7
+ this.name = "ProbeFailure";
8
+ }
9
+ }
@@ -10,6 +10,7 @@ export declare const QnTypes: {
10
10
  readonly secret: "secret";
11
11
  readonly screen: "screen";
12
12
  readonly nav: "nav";
13
+ readonly probe: "probe";
13
14
  };
14
15
  export type QnType = string;
15
16
  export type ParsedQn = {
@@ -22,6 +22,7 @@ export const QnTypes = {
22
22
  secret: "secret",
23
23
  screen: "screen",
24
24
  nav: "nav",
25
+ probe: "probe",
25
26
  };
26
27
  const QN_SEGMENT = /^[a-z][a-z0-9-]*$/;
27
28
  function validateSegment(value, label, context) {
@@ -6,6 +6,8 @@
6
6
  import { index, integer, table as pgTable, sql, text, timestamp } from "../db/dialect.js";
7
7
  // sql now comes from native dialect
8
8
  import { tableExists } from "../db/index.js";
9
+ import { acquireNamespacedAdvisoryLock } from "../db/queries/advisory-lock.js";
10
+ import { ES_OPS_ADVISORY_LOCK_KEY } from "../db/queries/es-ops.js";
9
11
  import { unsafePushTables } from "../db/table-push.js";
10
12
  export const esOperationsTable = pgTable("kumiko_es_operations", {
11
13
  // Filename without extension serves as ID. Chronologically sortable
@@ -25,10 +27,17 @@ export const esOperationsTable = pgTable("kumiko_es_operations", {
25
27
  }, (t) => ({
26
28
  typeIdx: index("kumiko_es_operations_type_idx").on(t.operationType),
27
29
  }));
28
- // Convenience for tests + boot-time setup (idempotent). Mirrors the
29
- // createEventsTable pattern in event-store/events-schema.ts.
30
+ const ES_OPERATIONS_DDL_LOCK_KEY = "kumiko_es_operations:ddl";
31
+ // Replicas boot concurrently and CREATE TABLE IF NOT EXISTS is not race-safe: the loser trips
32
+ // pg_type_typname_nsp_index. The lock transaction only carries the lock; the DDL runs on `db`.
30
33
  export async function createEsOperationsTable(db) {
31
- if (!(await tableExists(db, "public.kumiko_es_operations"))) {
32
- await unsafePushTables(db, { kumikoEsOperations: esOperationsTable });
33
- }
34
+ // skip: the table exists, no lock needed on the common boot path.
35
+ if (await tableExists(db, "public.kumiko_es_operations"))
36
+ return;
37
+ await db.begin(async (lockTx) => {
38
+ await acquireNamespacedAdvisoryLock(lockTx, ES_OPS_ADVISORY_LOCK_KEY, ES_OPERATIONS_DDL_LOCK_KEY);
39
+ if (!(await tableExists(db, "public.kumiko_es_operations"))) {
40
+ await unsafePushTables(db, { kumikoEsOperations: esOperationsTable });
41
+ }
42
+ });
34
43
  }
@@ -5,7 +5,12 @@ export type SchemaCliOut = {
5
5
  readonly log: (line: string) => void;
6
6
  readonly err: (line: string) => void;
7
7
  };
8
- export type RunSchemaCliOptions = {
8
+ type RunSchemaCliRowLevelSecurityOption = {
9
+ /** The mode the app's runtime boots with (`AppConfig.rowLevelSecurity`). Wins over the schema
10
+ * file, so `apply` heals the same mode the boot check enforces. */
11
+ readonly rowLevelSecurity?: RowLevelSecurityMode;
12
+ };
13
+ export type RunSchemaCliOptions = RunSchemaCliRowLevelSecurityOption & ({
9
14
  readonly features?: undefined;
10
15
  readonly kmsSlots?: undefined;
11
16
  } | {
@@ -17,7 +22,7 @@ export type RunSchemaCliOptions = {
17
22
  * `[]` when the app wires none. This CLI parses no env schema, so the
18
23
  * rebuild's KMS wiring cannot derive them itself. */
19
24
  readonly kmsSlots: readonly string[];
20
- };
25
+ });
21
26
  export type SchemaCliContext = {
22
27
  readonly argv: readonly string[];
23
28
  readonly out: SchemaCliOut;
@@ -41,3 +46,4 @@ export declare function loadSchemaModOrReport(ctx: SchemaCliContext): Promise<Sc
41
46
  export declare function loadRowLevelSecurityModeOrReport(ctx: SchemaCliContext): Promise<RowLevelSecurityMode | undefined>;
42
47
  export declare function readDatabaseUrlOrReport(out: SchemaCliOut): string | undefined;
43
48
  export declare function migrationsDirExistsOrReport(ctx: SchemaCliContext): boolean;
49
+ export {};
@@ -91,11 +91,14 @@ export async function loadSchemaModOrReport(ctx) {
91
91
  }
92
92
  }
93
93
  // `apply` needs only the mode: migrations and rebuilds on a role that RLS binds see nothing unless
94
- // the connection runs in system scope, which only an "on" connection sets. Without a schema file
95
- // (dev CLI against a bare checkout) there is nothing to enforce.
94
+ // the connection runs in system scope, which only an "on" connection sets. The prod image ships no
95
+ // kumiko/schema.ts, so without one apply falls back to the runtime's default instead of "off":
96
+ // otherwise the framework tables stay unhealed and the boot check of an "on" app fails.
96
97
  export async function loadRowLevelSecurityModeOrReport(ctx) {
98
+ if (ctx.options.rowLevelSecurity !== undefined)
99
+ return ctx.options.rowLevelSecurity;
97
100
  if (!existsSync(ctx.schemaFile))
98
- return "off";
101
+ return DEFAULT_ROW_LEVEL_SECURITY_MODE;
99
102
  return (await loadSchemaModOrReport(ctx))?.rowLevelSecurity;
100
103
  }
101
104
  export function readDatabaseUrlOrReport(out) {
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { assertRowLevelSecurityTableState, createDbConnection, generateMigration, } from "../db/index.js";
4
- import { knownTablesOf } from "../db/known-table-names.js";
4
+ import { isLiveFrameworkTable } from "../db/known-table-names.js";
5
5
  import { readMigrationSqlTexts } from "../db/migrate-runner.js";
6
6
  import { diffReplayAgainstSnapshot, replayMigrationsDir } from "../db/replay-migration-sql.js";
7
7
  import { validateBoot } from "../engine/boot-validator.js";
@@ -62,31 +62,27 @@ function checkBootValidity(out, schemaMod) {
62
62
  return false;
63
63
  }
64
64
  }
65
+ function declaredTablesOf(schemaMod) {
66
+ const names = new Set(schemaMod.entityMetas.map((meta) => meta.tableName));
67
+ return { has: (tableName) => names.has(tableName) || isLiveFrameworkTable(tableName) };
68
+ }
65
69
  // 4. Live row-level security table state — the table half of the boot check. Only with a DATABASE_URL;
66
- // without one the command stays database-free.
70
+ // without one the command stays database-free. Scoped to the tables kumiko/schema.ts declares plus the
71
+ // framework tables, because a local DATABASE_URL may point at a dev database several apps share.
67
72
  async function checkLiveRowLevelSecurity(out, schemaMod) {
68
73
  const dbUrl = process.env["DATABASE_URL"];
69
74
  if (!dbUrl) {
70
75
  out.log(" · row-level security: skipped — set DATABASE_URL to check the live database.");
71
76
  return true;
72
77
  }
73
- let features;
74
- try {
75
- features = featureDefinitionsOf(schemaMod.features);
76
- }
77
- catch {
78
- // skip: the boot check already reported the unreadable FEATURES export
79
- return true;
80
- }
81
- const knownTables = features === undefined ? { has: () => true } : knownTablesOf(features);
82
78
  const { db, close } = createDbConnection({
83
79
  url: dbUrl,
84
80
  maxConnections: 1,
85
81
  rowLevelSecurity: schemaMod.rowLevelSecurity,
86
82
  });
87
83
  try {
88
- await assertRowLevelSecurityTableState(db, schemaMod.rowLevelSecurity, knownTables);
89
- out.log(" ✓ row-level security: live database matches the configured mode");
84
+ await assertRowLevelSecurityTableState(db, schemaMod.rowLevelSecurity, declaredTablesOf(schemaMod), "declared tables");
85
+ out.log(" ✓ row-level security: live database matches the configured mode for this app's tables and the framework tables (tables of other apps sharing the database are not checked)");
90
86
  return true;
91
87
  }
92
88
  catch (e) {
@@ -4,4 +4,5 @@ export declare function findFeaturesDirs(cwd: string): string[];
4
4
  export declare function resolveCodemodScript(repoRoot: string, codemodField: string | undefined): string | null;
5
5
  export declare function runUpgradeCli(argv: readonly string[], cwd: string, out: UpgradeCliOut, options?: {
6
6
  readonly repoRoot?: string;
7
+ readonly codemodResolveFrom?: string;
7
8
  }): Promise<number>;
@@ -213,6 +213,10 @@ function resolveCodemodScriptAt(codemodScriptsRoot, codemodField) {
213
213
  }
214
214
  return resolved;
215
215
  }
216
+ function findCodemodScriptsRootPreferring(resolveFrom, repoRoot) {
217
+ const own = resolveFrom === undefined ? null : findCodemodScriptsRoot(resolveFrom);
218
+ return own ?? findCodemodScriptsRoot(repoRoot);
219
+ }
216
220
  export function resolveCodemodScript(repoRoot, codemodField) {
217
221
  const scriptsRoot = findCodemodScriptsRoot(repoRoot);
218
222
  if (!scriptsRoot)
@@ -388,7 +392,7 @@ function writePartialMarker(out, targetDir, pending, manualEntries, failedVersio
388
392
  // even with zero pending entries, so an already-current app still gets a
389
393
  // bootstrap marker recording its installed version.
390
394
  // kumiko-lint-ignore complexity-budget sequential codemod runner with zero-pending bootstrap marker
391
- async function applyCodemods(out, pending, repoRoot, targetDir, dryRun,
395
+ async function applyCodemods(out, pending, codemodLocation, targetDir, dryRun,
392
396
  // Installed version for the zero-pending bootstrap marker — never a
393
397
  // `--from` filter override (that would permanently skip real pending
394
398
  // codemods once the CI guard compares against the fake marker).
@@ -443,14 +447,16 @@ markerVersion, previous, fromVersion, manualDetails) {
443
447
  }
444
448
  return 0;
445
449
  }
446
- const codemodScriptsRoot = findCodemodScriptsRoot(repoRoot);
450
+ const { repoRoot, resolveFrom: codemodResolveFrom } = codemodLocation;
451
+ const codemodScriptsRoot = findCodemodScriptsRootPreferring(codemodResolveFrom, repoRoot);
447
452
  if (codemodScriptsRoot === null) {
448
- out.err(` ✗ could not locate @cosmicdrift/kumiko-feature-ast/src/scripts/codemod — is @cosmicdrift/kumiko-cli or @cosmicdrift/kumiko-dev-server installed? searched from ${repoRoot} upward`);
453
+ const searched = codemodResolveFrom === undefined ? repoRoot : `${codemodResolveFrom} and ${repoRoot}`;
454
+ out.err(` ✗ could not locate @cosmicdrift/kumiko-feature-ast/src/scripts/codemod — is @cosmicdrift/kumiko-cli or @cosmicdrift/kumiko-dev-server installed? searched from ${searched} upward`);
449
455
  return 1;
450
456
  }
451
457
  const ran = [];
452
458
  for (const e of codemodEntries) {
453
- const scriptPath = resolveCodemodScript(repoRoot, e.codemod);
459
+ const scriptPath = resolveCodemodScriptAt(codemodScriptsRoot, e.codemod);
454
460
  if (!scriptPath) {
455
461
  out.err(` ✗ ${e.version} · ${e.title} — invalid codemod path "${e.codemod}"`);
456
462
  if (!dryRun)
@@ -681,7 +687,7 @@ function planUpgrade(args, cwd, targetDir) {
681
687
  value: { ...baseline.value, targetInstalledVersion, allEntries, pending },
682
688
  };
683
689
  }
684
- async function applyPlannedUpgrade(out, args, plan, repoRoot, targetDir) {
690
+ async function applyPlannedUpgrade(out, args, plan, codemodLocation, targetDir) {
685
691
  // Marker must reflect what is actually installed under the target (or
686
692
  // cwd), not the filter baseline above — otherwise CI stays green forever.
687
693
  const markerVersion = plan.targetInstalledVersion;
@@ -690,7 +696,7 @@ async function applyPlannedUpgrade(out, args, plan, repoRoot, targetDir) {
690
696
  return failUpgrade(out, `Could not detect the installed Kumiko version under ${targetDir}.`, "Refusing to write an upgrade marker without it (--from is only a filter).");
691
697
  }
692
698
  out.log("");
693
- const code = await applyCodemods(out, plan.pending, repoRoot, targetDir, dryRun, markerVersion ?? plan.currentVersion, carryOf(plan.markerRead), plan.currentVersion, getFlag(args, "verbose") ? manualDetailsOf(plan.allEntries) : undefined);
699
+ const code = await applyCodemods(out, plan.pending, codemodLocation, targetDir, dryRun, markerVersion ?? plan.currentVersion, carryOf(plan.markerRead), plan.currentVersion, getFlag(args, "verbose") ? manualDetailsOf(plan.allEntries) : undefined);
694
700
  out.log("");
695
701
  return code;
696
702
  }
@@ -802,7 +808,13 @@ export async function runUpgradeCli(argv, cwd, out, options) {
802
808
  if (planned.kind === "error")
803
809
  return failUpgrade(out, ...planned.lines);
804
810
  if (getFlag(args, "apply")) {
805
- return applyPlannedUpgrade(out, args, planned.value, options?.repoRoot ?? cwd, targetDir);
811
+ const codemodLocation = {
812
+ repoRoot: options?.repoRoot ?? cwd,
813
+ ...(options?.codemodResolveFrom !== undefined && {
814
+ resolveFrom: options.codemodResolveFrom,
815
+ }),
816
+ };
817
+ return applyPlannedUpgrade(out, args, planned.value, codemodLocation, targetDir);
806
818
  }
807
819
  if (getFlag(args, "json"))
808
820
  return reportUpgradeJson(out, planned.value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.390.0",
3
+ "version": "0.391.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -199,7 +199,7 @@
199
199
  "./package.json": "./package.json"
200
200
  },
201
201
  "dependencies": {
202
- "@cosmicdrift/kumiko-types": "0.390.0",
202
+ "@cosmicdrift/kumiko-types": "0.391.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "hono": "^4.13.1",
205
205
  "i18next": "^26.1.0",
@@ -209,7 +209,7 @@
209
209
  "temporal-polyfill": "^0.3.2",
210
210
  "uuid": "^14.0.0",
211
211
  "write-excel-file": "^4.1.1",
212
- "zod": "^4.4.3"
212
+ "zod": "^4.6.7"
213
213
  },
214
214
  "peerDependencies": {
215
215
  "bun-types": "^1.4.4",
@@ -221,7 +221,7 @@
221
221
  }
222
222
  },
223
223
  "devDependencies": {
224
- "@cosmicdrift/kumiko-testing": "0.390.0",
224
+ "@cosmicdrift/kumiko-testing": "0.391.0",
225
225
  "bun-types": "^1.4.4",
226
226
  "fflate": "^0.8.2",
227
227
  "meilisearch": "^0.58.0",
package/src/changes.json CHANGED
@@ -1,4 +1,58 @@
1
1
  [
2
+ {
3
+ "version": "0.391.0",
4
+ "type": "fix",
5
+ "title": "Concurrent boots create kumiko_es_operations without a race",
6
+ "detail": "CREATE TABLE IF NOT EXISTS is not race-safe in Postgres, so replicas booting against a fresh database could fail on pg_type_typname_nsp_index. createEsOperationsTable now takes an advisory lock and checks again before it creates the table."
7
+ },
8
+ {
9
+ "version": "0.391.0",
10
+ "type": "improvement",
11
+ "title": "r.probe registers live readiness probes with a per-probe timeout",
12
+ "detail": "A feature registers a probe with r.probe({ name, scope, cost, area, label, timeoutMs?, targets?, run }). scope \"system\" probes run only for SystemAdmins, \"tenant\" probes also for TenantAdmins of that tenant; cost \"paid\" probes run only when the caller asks for them. targets splits one probe into one result per target, such as one per mailbox. runWithTimeout from the api barrel is the shared timeout runner the readiness checks and probe runs use. A probe's context holds db, registry, obs, tz and services plus the run fields; the run job's dispatch, event emitter and escape-hatch powers stay with the job. The readiness status query still makes no network call."
13
+ },
14
+ {
15
+ "version": "0.391.0",
16
+ "type": "improvement",
17
+ "title": "The boot validator checks enumOption tones",
18
+ "detail": "A list column with renderer { format: \"enumOption\", tones } fails boot when a tone is not one of the select-option tones."
19
+ },
20
+ {
21
+ "version": "0.391.0",
22
+ "type": "fix",
23
+ "title": "Cloudflare is no longer listed as a sub-processor",
24
+ "detail": "DNS moved to Hetzner, so Cloudflare, Inc. no longer processes data for the platform. The entry is gone from KUMIKO_SUB_PROCESSORS, which means it is missing from /api/compliance/sub-processors and from the sub-processor RSS feed."
25
+ },
26
+ {
27
+ "version": "0.390.1",
28
+ "type": "fix",
29
+ "title": "schema apply in the production image uses the runtime's row-level security mode",
30
+ "detail": "The app image ships only kumiko/migrations, not kumiko/schema.ts. Without the schema file `schema apply` ran with row-level security \"off\", so kumiko_events, kumiko_snapshots and kumiko_archived_streams were not healed and the boot of an app in \"on\" mode (the default) failed with \"framework tables without it\". Without a schema file apply now uses the same default as runProdApp. RunSchemaCliOptions takes an optional rowLevelSecurity, which wins over the schema file; the scaffolded bin/kumiko.ts passes appConfig.rowLevelSecurity, like bin/main.ts does for runProdApp. Apps that do not set rowLevelSecurity need no change. An app that sets rowLevelSecurity \"off\" and keeps a hand-written bin/kumiko.ts must pass rowLevelSecurity: \"off\" to runSchemaCli, otherwise apply enables row-level security on the framework tables and the app's \"off\" boot check fails."
31
+ },
32
+ {
33
+ "version": "0.390.1",
34
+ "type": "fix",
35
+ "title": "Projection rebuilds no longer report drift on timestamptz columns",
36
+ "detail": "Event payloads keep the microseconds of an instant, the live write stores milliseconds. A rebuild bound the payload's ISO string unchanged and wrote a different value, which the shadow diff reported as column drift. ISO instant strings bound to a timestamptz column are now truncated to milliseconds like Temporal.Instant values."
37
+ },
38
+ {
39
+ "version": "0.390.1",
40
+ "type": "improvement",
41
+ "title": "The live row-level security check of `kumiko check` covers only the app's own tables",
42
+ "detail": "`schema validate` (the boot step of `kumiko check`) checks the live database when DATABASE_URL is set. On a dev database several apps share it failed on the other apps' tables. It now checks the tables kumiko/schema.ts declares plus the framework tables; tables no feature declares any more are still reported by the boot check, which runs against the app's own database. An unreachable database still fails the check."
43
+ },
44
+ {
45
+ "version": "0.390.1",
46
+ "type": "fix",
47
+ "title": "kumiko-upgrade runs the codemods of its own feature-ast install",
48
+ "detail": "The kumiko-upgrade bins of kumiko-cli and kumiko-dev-server now look for the codemod scripts from their own package first, so a feature-ast copy hoisted above the app (for example in a workspace parent) can no longer supply codemods of another version. runUpgradeCli takes an optional codemodResolveFrom for that; without it the search still starts at the repo root."
49
+ },
50
+ {
51
+ "version": "0.390.1",
52
+ "type": "fix",
53
+ "title": "zod range moved to ^4.6.7",
54
+ "detail": "All framework packages and the scaffold template require zod ^4.6.7, which fixes GHSA-MRHX-C7XC-M3W5."
55
+ },
2
56
  {
3
57
  "version": "0.390.0",
4
58
  "type": "fix",