@cosmicdrift/kumiko-framework 0.390.1 → 0.391.1
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/features.md +7 -1
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.js +1 -0
- package/dist/api/readiness.d.ts +11 -0
- package/dist/api/readiness.js +23 -14
- package/dist/compliance/sub-processors.js +1 -11
- package/dist/db/row-level-security-boot-check.js +4 -4
- package/dist/engine/boot-validator/screens-lists.d.ts +4 -0
- package/dist/engine/boot-validator/screens-lists.js +25 -0
- package/dist/engine/define-feature.js +3 -0
- package/dist/engine/feature-builder-state.d.ts +2 -0
- package/dist/engine/feature-builder-state.js +1 -0
- package/dist/engine/feature-probes.d.ts +5 -0
- package/dist/engine/feature-probes.js +46 -0
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/probe-failure.d.ts +5 -0
- package/dist/engine/probe-failure.js +9 -0
- package/dist/engine/qualified-name.d.ts +1 -0
- package/dist/engine/qualified-name.js +1 -0
- package/dist/es-ops/operations-schema.js +14 -5
- package/dist/migrations/kumiko-drift.js +4 -4
- package/package.json +3 -3
- package/src/changes.json +30 -0
package/agents/features.md
CHANGED
|
@@ -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.
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/api/readiness.d.ts
CHANGED
|
@@ -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;
|
package/dist/api/readiness.js
CHANGED
|
@@ -23,31 +23,29 @@ export function createReadinessProbe(checks, opts = {}) {
|
|
|
23
23
|
};
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
-
|
|
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
|
-
|
|
33
|
+
const value = await Promise.race([
|
|
34
|
+
run(),
|
|
32
35
|
new Promise((_, reject) => {
|
|
33
|
-
timer = setTimeout(() => reject(new
|
|
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 (
|
|
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
|
-
|
|
50
|
-
|
|
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",
|
|
@@ -42,17 +42,17 @@ async function assertAllTenantTablesProtected(db, knownTables, scope) {
|
|
|
42
42
|
const sections = [
|
|
43
43
|
...(frameworkTables.length > 0
|
|
44
44
|
? [
|
|
45
|
-
`framework tables without it: ${listingOf(frameworkTables)}. Run \`kumiko
|
|
45
|
+
`framework tables without it: ${listingOf(frameworkTables)}. Run \`bun kumiko-schema apply\`: it adds the missing policies to the framework tables (\`bun kumiko-schema generate\` reports no changes for them).`,
|
|
46
46
|
]
|
|
47
47
|
: []),
|
|
48
48
|
...(known.length > 0
|
|
49
49
|
? [
|
|
50
|
-
`tables of this app without it: ${listingOf(known)}. Run \`kumiko
|
|
50
|
+
`tables of this app without it: ${listingOf(known)}. Run \`bun kumiko-schema generate\` and apply the migration.`,
|
|
51
51
|
]
|
|
52
52
|
: []),
|
|
53
53
|
...(orphaned.length > 0
|
|
54
54
|
? [
|
|
55
|
-
`tables no feature declares anymore: ${listingOf(orphaned)}. No feature declares them: add a migration with DROP TABLE (enable the commented \`-- DESTRUCTIVE\` DROP that \`kumiko
|
|
55
|
+
`tables no feature declares anymore: ${listingOf(orphaned)}. No feature declares them: add a migration with DROP TABLE (enable the commented \`-- DESTRUCTIVE\` DROP that \`bun kumiko-schema generate\` emitted; see \`bun kumiko-schema status\`).`,
|
|
56
56
|
]
|
|
57
57
|
: []),
|
|
58
58
|
];
|
|
@@ -66,7 +66,7 @@ async function assertNoLiveRowLevelSecurity(db, knownTables, scope) {
|
|
|
66
66
|
if (tables.length === 0)
|
|
67
67
|
return;
|
|
68
68
|
throw new InternalError({
|
|
69
|
-
message: `rowLevelSecurity is "off" but row-level security is still enabled on: ${tables.join(", ")}. Without a tenant scope every statement would see zero rows. Run \`kumiko
|
|
69
|
+
message: `rowLevelSecurity is "off" but row-level security is still enabled on: ${tables.join(", ")}. Without a tenant scope every statement would see zero rows. Run \`bun kumiko-schema generate\` with rowLevelSecurity off to produce the DISABLE ROW LEVEL SECURITY migration and apply it.`,
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
// Table state only, no role check: the role is a runtime property of the boot connection, so tools
|
|
@@ -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[]>;
|
|
@@ -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
|
+
}
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/engine/index.js
CHANGED
|
@@ -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,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
|
+
}
|
|
@@ -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
|
-
|
|
29
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
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
|
}
|
|
@@ -125,11 +125,11 @@ export function formatKumikoDriftReport(report) {
|
|
|
125
125
|
// restored by apply.
|
|
126
126
|
lines.push("");
|
|
127
127
|
if (report.pending.length > 0) {
|
|
128
|
-
lines.push("Run 'kumiko
|
|
128
|
+
lines.push("Run 'bun kumiko-schema apply' to apply the pending migration(s).");
|
|
129
129
|
}
|
|
130
130
|
if (report.checksumMismatches.length > 0) {
|
|
131
131
|
lines.push("Revert the edited migration file(s) to their applied content, or hand-correct");
|
|
132
|
-
lines.push("the checksum in _kumiko_migrations — 'kumiko
|
|
132
|
+
lines.push("the checksum in _kumiko_migrations — 'bun kumiko-schema apply' cannot resolve a");
|
|
133
133
|
lines.push("checksum mismatch.");
|
|
134
134
|
}
|
|
135
135
|
if (report.missingTables.length > 0 && report.pending.length === 0) {
|
|
@@ -138,8 +138,8 @@ export function formatKumikoDriftReport(report) {
|
|
|
138
138
|
}
|
|
139
139
|
if (report.missingColumns.length > 0) {
|
|
140
140
|
lines.push("Missing column(s) — the live table predates a richer snapshot (e.g. a");
|
|
141
|
-
lines.push("ride-along column the generator now emits). Run 'kumiko
|
|
142
|
-
lines.push("produce an ALTER-TABLE migration for the new column(s), then 'kumiko
|
|
141
|
+
lines.push("ride-along column the generator now emits). Run 'bun kumiko-schema generate' to");
|
|
142
|
+
lines.push("produce an ALTER-TABLE migration for the new column(s), then 'bun kumiko-schema apply'.");
|
|
143
143
|
}
|
|
144
144
|
return lines.join("\n");
|
|
145
145
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.391.1",
|
|
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.
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.391.1",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"hono": "^4.13.1",
|
|
205
205
|
"i18next": "^26.1.0",
|
|
@@ -221,7 +221,7 @@
|
|
|
221
221
|
}
|
|
222
222
|
},
|
|
223
223
|
"devDependencies": {
|
|
224
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
224
|
+
"@cosmicdrift/kumiko-testing": "0.391.1",
|
|
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,34 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.391.1",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Boot errors name the real schema command",
|
|
6
|
+
"detail": "The drift report and the row-level-security boot check recommended `kumiko schema apply` and `kumiko schema generate`. Apps run the CLI as `bun kumiko-schema <subcommand>`, so the hints now say that."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.391.0",
|
|
10
|
+
"type": "fix",
|
|
11
|
+
"title": "Concurrent boots create kumiko_es_operations without a race",
|
|
12
|
+
"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."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.391.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "r.probe registers live readiness probes with a per-probe timeout",
|
|
18
|
+
"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."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.391.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "The boot validator checks enumOption tones",
|
|
24
|
+
"detail": "A list column with renderer { format: \"enumOption\", tones } fails boot when a tone is not one of the select-option tones."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.391.0",
|
|
28
|
+
"type": "fix",
|
|
29
|
+
"title": "Cloudflare is no longer listed as a sub-processor",
|
|
30
|
+
"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."
|
|
31
|
+
},
|
|
2
32
|
{
|
|
3
33
|
"version": "0.390.1",
|
|
4
34
|
"type": "fix",
|