@cosmicdrift/kumiko-framework 0.362.0 → 0.363.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/auth-middleware.js +7 -5
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +2 -2
- package/dist/bun-db/query.d.ts +6 -4
- package/dist/bun-db/query.js +66 -92
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.js +6 -6
- package/dist/db/event-store-executor-read.js +9 -9
- package/dist/db/migrate-generator.js +5 -3
- package/dist/db/migrate-runner.js +31 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db.d.ts +3 -2
- package/dist/db/tenant-db.js +4 -10
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/entity-list-screens.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +1 -1
- package/dist/engine/boot-validator/screens.js +2 -1
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.js +3 -6
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +3 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +6 -3
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/index.d.ts +2 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +1 -3
- package/dist/engine/soft-delete-cleanup.js +2 -1
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -13
- package/dist/engine/template-variables.d.ts +29 -0
- package/dist/engine/template-variables.js +77 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/files/file-routes.js +1 -1
- package/dist/files/local-provider.js +2 -1
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/job-runner.js +5 -5
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.js +45 -14
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/search/meilisearch-adapter.js +3 -4
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +4 -4
- package/src/changes.json +44 -0
|
@@ -27,6 +27,7 @@ import { executeQuery } from "./dispatch-query.js";
|
|
|
27
27
|
import { executeWrite } from "./dispatch-write.js";
|
|
28
28
|
import { dispatcherSpanAttributes, isFailedWriteResult, NestedWriteRollback, } from "./dispatcher-utils.js";
|
|
29
29
|
import { createMemberReaderFn } from "./member-reader.js";
|
|
30
|
+
import { createDeferringNotify } from "./notify-scope.js";
|
|
30
31
|
import { createGatedIdentitySwitch, createGatedMemberReader, createGatedProjectionReader, isSystemIdentity, systemIdentitySwitchDenied, } from "./system-identity-switch.js";
|
|
31
32
|
import { buildPersonalDataGate, rootWriteOrigin } from "./write-origin.js";
|
|
32
33
|
// Framework/pipeline stays bundled-features-free, so this can't import the
|
|
@@ -47,8 +48,10 @@ export const DEFAULT_MEMBER_READ_TIMEOUT_MS = 10_000;
|
|
|
47
48
|
// dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
|
|
48
49
|
// appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
|
|
49
50
|
export function resolveDbSource(ctx, tx) {
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
return tx ?? resolveAppPoolDb(ctx);
|
|
52
|
+
}
|
|
53
|
+
function resolveAppPoolDb(ctx) {
|
|
54
|
+
return ctx.appContext.db; // @cast-boundary db-operator
|
|
52
55
|
}
|
|
53
56
|
// ctx.appendEvent — append a domain event onto a specific aggregate stream
|
|
54
57
|
// in the current tx, then fire matching inline projections. Core logic
|
|
@@ -248,7 +251,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
248
251
|
// that is meant to outlive the request. Computed before `systemDb` below
|
|
249
252
|
// so its guarded escape hatch (ctx.systemDb.outsideTransaction) can wrap
|
|
250
253
|
// this same TenantDb instead of a second, independently-built one.
|
|
251
|
-
const outsideTxSource = ctx.outsideTransactionDb ??
|
|
254
|
+
const outsideTxSource = ctx.outsideTransactionDb ?? resolveAppPoolDb(ctx);
|
|
252
255
|
const rawDbOutsideTransaction = outsideTxSource
|
|
253
256
|
? buildTenantScopedDb(outsideTxSource, undefined)
|
|
254
257
|
: undefined;
|
|
@@ -279,9 +282,40 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
279
282
|
userId: user.id,
|
|
280
283
|
...(reqCtx && { requestId: reqCtx.requestId }),
|
|
281
284
|
});
|
|
282
|
-
|
|
283
|
-
|
|
285
|
+
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
286
|
+
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
287
|
+
const bridgeSink = afterCommitHooks ?? [];
|
|
288
|
+
// Query bridges and post-commit contexts get a throwaway sink: hooks pushed there never run.
|
|
289
|
+
const hasLiveCommitSink = afterCommitHooks !== undefined && isPostCommit !== true;
|
|
290
|
+
const scheduleAfterCommit = (hook) => {
|
|
291
|
+
bridgeSink.push(hook);
|
|
292
|
+
};
|
|
293
|
+
const notifyFactory = context._notifyFactory;
|
|
294
|
+
const notifyJobDispatcher = isNotifyJobDispatcher(jobRunner) ? jobRunner : undefined;
|
|
295
|
+
// Inside the write tx the app pool's slots can all be held by waiting write txs, so the
|
|
296
|
+
// delivery's own DB work (broadcast lookup, attempt log, channel sends) runs on the outside-tx pool.
|
|
297
|
+
const notifyInTransactionFactory = notifyFactory && tx !== undefined
|
|
298
|
+
? (notifyUser, notifyTenantId) => notifyFactory(notifyUser, notifyTenantId, notifyJobDispatcher, outsideTxSource, context._fileProviderResolver)
|
|
284
299
|
: undefined;
|
|
300
|
+
const notify = buildNotify();
|
|
301
|
+
function buildNotify() {
|
|
302
|
+
if (!notifyFactory)
|
|
303
|
+
return undefined;
|
|
304
|
+
const notifyAfterCommit = notifyFactory(user, user.tenantId, notifyJobDispatcher, undefined, context._fileProviderResolver);
|
|
305
|
+
if (!notifyInTransactionFactory)
|
|
306
|
+
return notifyAfterCommit;
|
|
307
|
+
const notifyInTransaction = notifyInTransactionFactory(user, user.tenantId);
|
|
308
|
+
// A rolled-back write must leave no notification behind.
|
|
309
|
+
if (!hasLiveCommitSink)
|
|
310
|
+
return notifyInTransaction;
|
|
311
|
+
return createDeferringNotify({
|
|
312
|
+
registry,
|
|
313
|
+
tenantId: user.tenantId,
|
|
314
|
+
notifyAfterCommit,
|
|
315
|
+
notifyNow: notifyInTransaction,
|
|
316
|
+
scheduleAfterCommit,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
285
319
|
// Mirror notify: only built when the config feature wired its factory.
|
|
286
320
|
const config = context._configAccessorFactory && db
|
|
287
321
|
? context._configAccessorFactory({
|
|
@@ -361,9 +395,6 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
361
395
|
const metricsFor = meter
|
|
362
396
|
? (targetFeatureName) => createSafeMetricsHandle(meter, targetFeatureName)
|
|
363
397
|
: () => createNoopMetricsHandle();
|
|
364
|
-
// Cross-feature bridge: ctx.query/write share the current tx + afterCommitHooks sink.
|
|
365
|
-
// queryAs/writeAs to anyone but the caller itself needs r.systemScope() or { escapeHatch } (system-identity-switch.ts).
|
|
366
|
-
const bridgeSink = afterCommitHooks ?? [];
|
|
367
398
|
// A failed inner write has already appended its event and projections when a later
|
|
368
399
|
// step (e.g. an inTransaction hook) fails, and the outer handler may swallow the
|
|
369
400
|
// failure result. The savepoint makes the failure undo exactly the inner write; the
|
|
@@ -374,7 +405,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
374
405
|
const innerAfterCommitHooks = [];
|
|
375
406
|
try {
|
|
376
407
|
const result = await runInSavepoint(tx, async (savepointTx) => {
|
|
377
|
-
const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary
|
|
408
|
+
const innerResult = await executeWrite(ctx, targetType, payload, asUser, origin, savepointTx, // @cast-boundary db-runner savepoint handle is a transaction handle
|
|
378
409
|
innerAfterCommitHooks);
|
|
379
410
|
if (isFailedWriteResult(innerResult))
|
|
380
411
|
throw new NestedWriteRollback(innerResult);
|
|
@@ -396,9 +427,6 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
396
427
|
const writeAsUser = async (asUser, targetType, payload) => isPostCommit
|
|
397
428
|
? unwrapSingle(await runBatch(ctx, [{ type: targetType, payload }], asUser, undefined, origin))
|
|
398
429
|
: executeNestedWriteInSavepoint(asUser, targetType, payload);
|
|
399
|
-
const scheduleAfterCommit = (hook) => {
|
|
400
|
-
bridgeSink.push(hook);
|
|
401
|
-
};
|
|
402
430
|
const identitySwitchAudit = { reason: identitySwitchGrantReason, report: reportEscapeHatch };
|
|
403
431
|
const identitySwitch = createGatedIdentitySwitch(`handler "${type}"`, user, hasIdentitySwitchGrant, {
|
|
404
432
|
// Inherits the caller's origin, so switching to SYSTEM cannot shed an anonymous root.
|
|
@@ -733,9 +761,12 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
733
761
|
const locale = reqCtx?.locale ?? safeUserLocale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
734
762
|
// The payload-bucket digest is dispatcher-internal; handlers must not be
|
|
735
763
|
// able to compute bucket keys.
|
|
736
|
-
const { _rateLimitPayloadDigest: _omitPayloadDigest,
|
|
764
|
+
const { _rateLimitPayloadDigest: _omitPayloadDigest,
|
|
765
|
+
// Bound to this call's tx: an outer context's factory must not leak into a tx-less one.
|
|
766
|
+
_notifyInTransactionFactory: _omitOuterNotifyInTransactionFactory, ...contextForHandler } = context;
|
|
737
767
|
const handlerContext = {
|
|
738
768
|
...contextForHandler,
|
|
769
|
+
...(notifyInTransactionFactory && { _notifyInTransactionFactory: notifyInTransactionFactory }),
|
|
739
770
|
registry,
|
|
740
771
|
db: exposedDb,
|
|
741
772
|
dbOutsideTransaction,
|
|
@@ -781,7 +812,7 @@ export async function buildHandlerContext(ctx, type, user, origin, tx, afterComm
|
|
|
781
812
|
_userId: user.id,
|
|
782
813
|
_tenantId: user.tenantId,
|
|
783
814
|
_handlerType: type,
|
|
784
|
-
scheduleAfterCommit,
|
|
815
|
+
...(hasLiveCommitSink && { scheduleAfterCommit }),
|
|
785
816
|
...(includeDeleted && { includeDeleted: true }),
|
|
786
817
|
...bridge,
|
|
787
818
|
}; // @cast-boundary engine-bridge
|
|
@@ -57,8 +57,7 @@ export async function acquireConsumerState(tx, name, instanceId, rearmCooldownMs
|
|
|
57
57
|
const cooldownElapsed = Temporal.Instant.compare(state.updatedAt, cooldownDeadline) <= 0;
|
|
58
58
|
if (cooldownElapsed && state.rearmCount < maxRearmCount) {
|
|
59
59
|
const rearmed = await rearmDeadConsumer(tx, name, instanceId);
|
|
60
|
-
const rearmedState = rearmed &&
|
|
61
|
-
coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
|
|
60
|
+
const rearmedState = rearmed && coerceRow(rearmed, extractTableInfo(eventConsumerStateTable));
|
|
62
61
|
if (rearmedState)
|
|
63
62
|
return { state: rearmedState, skip: null };
|
|
64
63
|
}
|
|
@@ -149,7 +148,7 @@ export async function markProcessing(tx, name, instanceId) {
|
|
|
149
148
|
export async function fetchPendingEvents(tx, cursor, batchSize, pendingRanges = []) {
|
|
150
149
|
const rawRows = await selectPendingAndNewEventRows(tx, cursor, pendingRanges, batchSize);
|
|
151
150
|
const info = extractTableInfo(eventsTable);
|
|
152
|
-
return rawRows.map((row) => coerceRow(row, info));
|
|
151
|
+
return rawRows.map((row) => coerceRow(row, info));
|
|
153
152
|
}
|
|
154
153
|
// Deliver events to the consumer's handler in events.id order, or — when
|
|
155
154
|
// the consumer wires a batchHandler — through it in one call first. Halt-
|
package/dist/pipeline/index.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pip
|
|
|
30
30
|
export { createLifecycleHooks } from "./lifecycle-pipeline.js";
|
|
31
31
|
export type { MspRebuildDeps } from "./msp-rebuild.js";
|
|
32
32
|
export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
|
|
33
|
+
export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
|
|
33
34
|
export type { ProjectionProgress, RebuildResult } from "./projection-rebuild.js";
|
|
34
35
|
export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
|
|
35
36
|
export type { ProjectionStatus } from "./projection-state.js";
|
package/dist/pipeline/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { createIdempotencyGuard } from "./idempotency.js";
|
|
|
15
15
|
export { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "./irreversible-operation-gate.js";
|
|
16
16
|
export { createLifecycleHooks } from "./lifecycle-pipeline.js";
|
|
17
17
|
export { rebuildMultiStreamProjection } from "./msp-rebuild.js";
|
|
18
|
+
export { assertNotifyTargetsOwnTenant, createDeferringNotify } from "./notify-scope.js";
|
|
18
19
|
export { getAllProjectionProgress, getProjectionState, listProjectionsWithState, rebuildProjection, } from "./projection-rebuild.js";
|
|
19
20
|
export { createProjectionStateTable, ProjectionStatuses, projectionStateTable, } from "./projection-state.js";
|
|
20
21
|
export { runProjectionsForEvent } from "./projections-runner.js";
|
|
@@ -34,7 +34,7 @@ async function runInDiscardedReadOnlySavepoint(tx, timeoutMs, bindIdleTimeout, f
|
|
|
34
34
|
await runInSavepoint(tx, async (sp) => {
|
|
35
35
|
// kumiko-lint-ignore raw-sql transaction characteristic, no query helper
|
|
36
36
|
await asRawClient(sp).unsafe(memberReadSessionSettingsSql(timeoutMs, bindIdleTimeout));
|
|
37
|
-
// @cast-boundary
|
|
37
|
+
// @cast-boundary db-runner savepoint handle — structurally a DbTx, same shape runInSavepoint's caller relies on
|
|
38
38
|
settled.outcome = { value: await fn(sp) };
|
|
39
39
|
// Always discarded: RELEASE SAVEPOINT would leave the outer tx read-only too.
|
|
40
40
|
throw new SavepointDiscard();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { TenantId } from "../engine/types/identifiers.js";
|
|
2
|
+
import type { NotifyFn, NotifyOptions, Registry } from "../engine/types/index.js";
|
|
3
|
+
import type { AfterCommitHook } from "./dispatcher-utils.js";
|
|
4
|
+
export declare function assertNotifyTargetsOwnTenant(options: NotifyOptions, tenantId: TenantId): void;
|
|
5
|
+
type DeferringNotifyDeps = {
|
|
6
|
+
readonly registry: Pick<Registry, "getAllNotifications"> | undefined;
|
|
7
|
+
readonly tenantId: TenantId;
|
|
8
|
+
readonly notifyAfterCommit: NotifyFn;
|
|
9
|
+
readonly notifyNow: NotifyFn;
|
|
10
|
+
readonly scheduleAfterCommit: (hook: AfterCommitHook) => void;
|
|
11
|
+
};
|
|
12
|
+
export declare function createDeferringNotify(deps: DeferringNotifyDeps): NotifyFn;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { assertNotificationVariables } from "../engine/template-variables.js";
|
|
2
|
+
import { AccessDeniedError, FrameworkReasons } from "../errors/index.js";
|
|
3
|
+
function isTenantBroadcast(to) {
|
|
4
|
+
return typeof to === "object" && to !== null && !Array.isArray(to) && "tenant" in to;
|
|
5
|
+
}
|
|
6
|
+
export function assertNotifyTargetsOwnTenant(options, tenantId) {
|
|
7
|
+
const { to } = options;
|
|
8
|
+
if (isTenantBroadcast(to) && to.tenant !== tenantId) {
|
|
9
|
+
throw new AccessDeniedError({
|
|
10
|
+
message: "ctx.notify can only broadcast to the caller's own tenant",
|
|
11
|
+
details: { reason: FrameworkReasons.notifyForeignTenantBroadcast },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Without `immediate`, a notify inside a write transaction waits for the commit so a
|
|
16
|
+
// rolled-back write leaves no notification behind.
|
|
17
|
+
export function createDeferringNotify(deps) {
|
|
18
|
+
const { registry, tenantId, notifyAfterCommit, notifyNow, scheduleAfterCommit } = deps;
|
|
19
|
+
async function notifyDeferringUntilCommit(notificationType, options) {
|
|
20
|
+
assertNotifyTargetsOwnTenant(options, tenantId);
|
|
21
|
+
// Fail the write now: after the commit the problem would only surface in the render job.
|
|
22
|
+
if (registry)
|
|
23
|
+
assertNotificationVariables(registry, notificationType, options.data);
|
|
24
|
+
if (options.immediate === true)
|
|
25
|
+
return notifyNow(notificationType, options);
|
|
26
|
+
scheduleAfterCommit(async () => {
|
|
27
|
+
await notifyAfterCommit(notificationType, options);
|
|
28
|
+
});
|
|
29
|
+
return { kind: "deferred" };
|
|
30
|
+
}
|
|
31
|
+
return notifyDeferringUntilCommit;
|
|
32
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { readNonEmptyStringArray, } from "../api/sse-broker.js";
|
|
2
3
|
import { collectSearchableSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/index.js";
|
|
3
4
|
import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db/index.js";
|
|
@@ -520,7 +521,7 @@ const TENANT_MEMBERSHIP_UPDATED_EVENT_TYPE = "tenant-membership.updated";
|
|
|
520
521
|
const TENANT_MEMBERSHIP_DELETED_EVENT_TYPE = "tenant-membership.deleted";
|
|
521
522
|
function readUserIdFromPreviousSnapshot(payload) {
|
|
522
523
|
const previous = payload["previous"];
|
|
523
|
-
if (
|
|
524
|
+
if (!isPlainObject(previous))
|
|
524
525
|
return undefined;
|
|
525
526
|
const userId = previous["userId"];
|
|
526
527
|
return typeof userId === "string" && userId.length > 0 ? userId : undefined;
|
|
@@ -124,14 +124,14 @@ export function createGatedProjectionReader(callerLabel, hasGrant, ungated, audi
|
|
|
124
124
|
function readIdentitySwitchFn(context, key) {
|
|
125
125
|
if (!(key in context))
|
|
126
126
|
return undefined;
|
|
127
|
-
const value = context
|
|
127
|
+
const value = Reflect.get(context, key);
|
|
128
128
|
return typeof value === "function" ? value : undefined; // @cast-boundary engine-bridge — checked via typeof above
|
|
129
129
|
}
|
|
130
130
|
// context's own keys only — never touch a property of the resolved value, which may be a Proxy that throws on any get.
|
|
131
131
|
function readDbLikeValue(context, key) {
|
|
132
132
|
if (!(key in context))
|
|
133
133
|
return undefined;
|
|
134
|
-
const value = context
|
|
134
|
+
const value = Reflect.get(context, key);
|
|
135
135
|
return typeof value === "object" && value !== null ? value : undefined;
|
|
136
136
|
}
|
|
137
137
|
function unavailableIdentitySwitchFn(callerLabel, kind) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { ErrorStatusCode, Meilisearch } from "meilisearch";
|
|
2
3
|
// meilisearch's waitTask() RESOLVES (never rejects) once the server task
|
|
3
4
|
// reaches a terminal state, even "failed" — a rejected doc (e.g. an id over
|
|
@@ -23,10 +24,8 @@ const INDEX_LIST_PAGE_SIZE = 100;
|
|
|
23
24
|
// What an unconfigured Meilisearch index reports as searchableAttributes.
|
|
24
25
|
const MEILI_ALL_ATTRIBUTES = "*";
|
|
25
26
|
function isIndexNotFound(err) {
|
|
26
|
-
const cause = typeof err === "object" && err !== null ? err.cause : null;
|
|
27
|
-
return (
|
|
28
|
-
cause !== null &&
|
|
29
|
-
cause.code === ErrorStatusCode.INDEX_NOT_FOUND);
|
|
27
|
+
const cause = typeof err === "object" && err !== null && "cause" in err ? err.cause : null;
|
|
28
|
+
return errorCodeOf(cause) === ErrorStatusCode.INDEX_NOT_FOUND;
|
|
30
29
|
}
|
|
31
30
|
// Exported for unit tests (index-name / primary-key shape) without a live Meili.
|
|
32
31
|
export function meilisearchTenantIndex(prefix, tenantId) {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// jsonb columns (secrets feature) or JSON-stringified TEXT columns
|
|
3
3
|
// (EnvelopeCipher for config values / entity fields). One wire shape for
|
|
4
4
|
// every envelope-encrypted store in the framework.
|
|
5
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
5
6
|
export function encodeStoredEnvelope(envelope) {
|
|
6
7
|
return {
|
|
7
8
|
ciphertext: envelope.ciphertext.toString("base64"),
|
|
@@ -21,12 +22,11 @@ export function decodeStoredEnvelope(stored) {
|
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
export function isStoredEnvelope(value) {
|
|
24
|
-
if (!value
|
|
25
|
+
if (!isPlainObject(value))
|
|
25
26
|
return false;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
typeof
|
|
29
|
-
typeof
|
|
30
|
-
typeof
|
|
31
|
-
typeof v["kekVersion"] === "number");
|
|
27
|
+
return (typeof value["ciphertext"] === "string" &&
|
|
28
|
+
typeof value["iv"] === "string" &&
|
|
29
|
+
typeof value["authTag"] === "string" &&
|
|
30
|
+
typeof value["encryptedDek"] === "string" &&
|
|
31
|
+
typeof value["kekVersion"] === "number");
|
|
32
32
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
1
|
import { columnNamesOf, tableExists } from "../db/schema-inspection.js";
|
|
2
|
+
import { schemaTableName } from "../db/table-introspection.js";
|
|
3
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);
|
|
@@ -29,8 +29,7 @@ export async function pushEntityProjectionTables(stack, registry) {
|
|
|
29
29
|
if (seen.has(proj.table))
|
|
30
30
|
continue;
|
|
31
31
|
seen.add(proj.table);
|
|
32
|
-
const
|
|
33
|
-
const physical = tableRec[KUMIKO_NAME_SYMBOL];
|
|
32
|
+
const physical = schemaTableName(proj.table) ?? tableToMeta(proj.table).tableName;
|
|
34
33
|
if (!(await tableExists(stack.db, `public.${physical}`))) {
|
|
35
34
|
missing[projName] = proj.table;
|
|
36
35
|
continue;
|
|
@@ -1,20 +1,19 @@
|
|
|
1
|
-
import { KUMIKO_META_SYMBOL, KUMIKO_NAME_SYMBOL, } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
1
|
import { pgTypeToSqlType } from "../db/dialect.js";
|
|
3
2
|
import { alterTableAddColumn, createIndexIfNotExists, executeDdlStatement, } from "../db/queries/ddl.js";
|
|
4
3
|
import { truncateTablesRestartIdentity } from "../db/queries/test-stack.js";
|
|
5
4
|
import { renderTableDdl } from "../db/render-ddl.js";
|
|
6
5
|
import { tableExists } from "../db/schema-inspection.js";
|
|
7
6
|
import { buildEntityTable, toTableName } from "../db/table-builder.js";
|
|
7
|
+
import { asEntityTableMeta, schemaTableName } from "../db/table-introspection.js";
|
|
8
8
|
function tableNameOf(table) {
|
|
9
9
|
if (typeof table !== "object" || table === null) {
|
|
10
10
|
throw new Error("table-helpers: table is not a SchemaTable object");
|
|
11
11
|
}
|
|
12
|
-
const
|
|
13
|
-
if (
|
|
14
|
-
return
|
|
15
|
-
if (typeof
|
|
16
|
-
return
|
|
17
|
-
}
|
|
12
|
+
const symbolName = schemaTableName(table);
|
|
13
|
+
if (symbolName !== undefined)
|
|
14
|
+
return symbolName;
|
|
15
|
+
if ("tableName" in table && typeof table.tableName === "string")
|
|
16
|
+
return table.tableName;
|
|
18
17
|
throw new Error("table-helpers: table has no name");
|
|
19
18
|
}
|
|
20
19
|
/**
|
|
@@ -36,26 +35,11 @@ export async function unsafeEnsureEntityTable(db, entity, entityName) {
|
|
|
36
35
|
// Tables produced by the native dialect already carry EntityTableMeta-shape
|
|
37
36
|
// (source/columns/indexes). renderTableDdl converts that to CREATE TABLE +
|
|
38
37
|
// CREATE INDEX statements executed via db/queries/test-stack.
|
|
39
|
-
function isMetaShape(v) {
|
|
40
|
-
return (typeof v === "object" &&
|
|
41
|
-
v !== null &&
|
|
42
|
-
typeof v.tableName === "string" &&
|
|
43
|
-
Array.isArray(v.columns) &&
|
|
44
|
-
Array.isArray(v.indexes) &&
|
|
45
|
-
(v.source === "managed" || v.source === "unmanaged"));
|
|
46
|
-
}
|
|
47
38
|
export function tableToMeta(table) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const fromSymbol = table[KUMIKO_META_SYMBOL];
|
|
53
|
-
if (isMetaShape(fromSymbol))
|
|
54
|
-
return fromSymbol;
|
|
55
|
-
}
|
|
56
|
-
if (isMetaShape(table))
|
|
57
|
-
return table;
|
|
58
|
-
throw new Error("unsafePushTables: argument is not a SchemaTable / EntityTableMeta");
|
|
39
|
+
const meta = asEntityTableMeta(table);
|
|
40
|
+
if (!meta)
|
|
41
|
+
throw new Error("unsafePushTables: argument is not a SchemaTable / EntityTableMeta");
|
|
42
|
+
return meta;
|
|
59
43
|
}
|
|
60
44
|
/** Applies ALTER TABLE ADD COLUMN for exactly the given columns. */
|
|
61
45
|
export async function addMissingColumns(db, tableName, columns) {
|
package/dist/stack/test-stack.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
1
2
|
import { buildServer } from "../api/server.js";
|
|
2
3
|
import { createSseBroker } from "../api/sse-broker.js";
|
|
3
4
|
import { validateBoot, validateNavBootSubset } from "../engine/boot-validator/index.js";
|
|
@@ -269,11 +270,7 @@ export async function setupTestStack(options) {
|
|
|
269
270
|
// always-real `testRedis.redis`, independent of whatever extraContext
|
|
270
271
|
// did to `appContext.redis` — an explicit `context.rateLimit` from
|
|
271
272
|
// extraContext still wins (same precedence buildServer documents).
|
|
272
|
-
|
|
273
|
-
// dynamic extension point) — appContext's inferred type doesn't carry a
|
|
274
|
-
// `rateLimit` key, so reading it back needs the same relaxed boundary
|
|
275
|
-
// extraContext itself is typed with.
|
|
276
|
-
const explicitRateLimit = appContext.rateLimit;
|
|
273
|
+
const explicitRateLimit = propertyOf(appContext, "rateLimit");
|
|
277
274
|
const needsOwnRateLimitResolver = explicitRateLimit === undefined &&
|
|
278
275
|
(appContext.redis === undefined || options.rateLimitNowMs !== undefined);
|
|
279
276
|
const rateLimitFallback = needsOwnRateLimitResolver
|
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Serializes a FormData instance to multipart/form-data bytes.
|
|
3
|
-
*
|
|
4
|
-
* Returns the encoded body and the Content-Type header value (including the
|
|
5
|
-
* generated boundary). Pass both directly to app.request or fetch.
|
|
6
|
-
*/
|
|
7
1
|
export declare function buildMultipartBody(fd: FormData): Promise<{
|
|
8
2
|
body: BodyInit;
|
|
9
3
|
contentType: string;
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* Returns the encoded body and the Content-Type header value (including the
|
|
20
20
|
* generated boundary). Pass both directly to app.request or fetch.
|
|
21
21
|
*/
|
|
22
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
22
23
|
export async function buildMultipartBody(fd) {
|
|
23
24
|
const boundary = `KumikoBnd${Date.now()}${Math.random().toString(36).slice(2, 8)}`;
|
|
24
25
|
const enc = new TextEncoder();
|
|
@@ -64,12 +65,11 @@ export function patchFileInstanceofForBunTest() {
|
|
|
64
65
|
return;
|
|
65
66
|
Object.defineProperty(File, Symbol.hasInstance, {
|
|
66
67
|
value(instance) {
|
|
67
|
-
if (
|
|
68
|
+
if (!isPlainObject(instance))
|
|
68
69
|
return false;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
typeof
|
|
72
|
-
typeof f["arrayBuffer"] === "function");
|
|
70
|
+
return (typeof instance["name"] === "string" &&
|
|
71
|
+
typeof instance["size"] === "number" &&
|
|
72
|
+
typeof instance["arrayBuffer"] === "function");
|
|
73
73
|
},
|
|
74
74
|
configurable: true,
|
|
75
75
|
});
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
* Used by ambient-independence regression tests (kumiko-framework#1525/#1550).
|
|
4
4
|
*/
|
|
5
5
|
export async function withoutAmbientTemporal(fn) {
|
|
6
|
-
const savedGlobal = globalThis
|
|
7
|
-
|
|
6
|
+
const savedGlobal = Reflect.get(globalThis, "Temporal");
|
|
7
|
+
Reflect.deleteProperty(globalThis, "Temporal");
|
|
8
8
|
try {
|
|
9
9
|
return await fn();
|
|
10
10
|
}
|
|
11
11
|
finally {
|
|
12
12
|
if (savedGlobal === undefined)
|
|
13
|
-
|
|
13
|
+
Reflect.deleteProperty(globalThis, "Temporal");
|
|
14
14
|
else
|
|
15
|
-
globalThis
|
|
15
|
+
Reflect.set(globalThis, "Temporal", savedGlobal);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
1
|
+
export { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
@@ -1,4 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export function isPlainObject(value) {
|
|
3
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4
|
-
}
|
|
1
|
+
export { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.363.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>",
|
|
@@ -210,8 +210,8 @@
|
|
|
210
210
|
"./package.json": "./package.json"
|
|
211
211
|
},
|
|
212
212
|
"dependencies": {
|
|
213
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
214
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
213
|
+
"@cosmicdrift/kumiko-http": "0.363.0",
|
|
214
|
+
"@cosmicdrift/kumiko-types": "0.363.0",
|
|
215
215
|
"bullmq": "^5.76.7",
|
|
216
216
|
"bun-types": "^1.3.13",
|
|
217
217
|
"hono": "^4.13.1",
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
"zod": "^4.4.3"
|
|
228
228
|
},
|
|
229
229
|
"devDependencies": {
|
|
230
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
230
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.363.0",
|
|
231
231
|
"bun-types": "^1.3.13",
|
|
232
232
|
"pino-pretty": "^13.1.3"
|
|
233
233
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,48 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.363.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Fewer unchecked casts in bundled features, dev-server, server-runtime, testing and cli",
|
|
6
|
+
"detail": "Casts are replaced by shared type guards, typed query rows and direct Bun typings. Behavior stays the same, except that a tier-resolver registration without a build() factory now fails with a clear error at boot, the kumiko CLI prints the thrown value of non-Error failures instead of \"undefined\"."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.363.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Remove unknown cast-boundary reasons and share propertyOf",
|
|
12
|
+
"detail": "Framework internals read untyped properties through the shared propertyOf from kumiko-types instead of private copies and casts. The as-casts guard knows four more boundary reasons (phantom-brand, third-party-type-gap, dom-event-target, feature-exports) and normalizes whitespace in cast targets so re-indenting a multi-line cast no longer counts as a regression. A stored inbound-mail cursor that is not a JSON object is now treated like a missing cursor."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.363.0",
|
|
16
|
+
"type": "breaking",
|
|
17
|
+
"title": "ctx.notify in write handlers delivers after commit",
|
|
18
|
+
"detail": "Inside a write handler's transaction ctx.notify only records the notification and delivers it once the write has committed, so a rolled-back write sends nothing and leaves no attempt event. Delivery no longer takes a connection from the app pool while the write holds one, which deadlocked at pool max N with N parallel writes. With immediate: true the call stays synchronous and runs its DB work on the outside-transaction pool.",
|
|
19
|
+
"migration": "NotifyResult is now a union, NotifyDispatched ({ kind: \"dispatched\", deliveries }) or NotifyDeferred ({ kind: \"deferred\" }). In a write handler without immediate: true ctx.notify returns deferred and delivers after commit (a rollback means no notification). Code that reads the result (for example a \"send test message\" handler showing sent/failed) sets immediate: true, which delivers synchronously on the outside-transaction pool and returns dispatched; narrow on kind before reading deliveries. Errors such as a missing idempotency Redis, a tenant broadcast without the tenant feature or an unknown notification type no longer throw in a deferred write handler, they land in the afterCommit log. Notifications from jobs, from seeding and from handlers without a transaction stay synchronous. The boot default wires tenantUserIdsQuery for to: { tenant } when the tenant feature is mounted, so an app-side tenantUserIdsQuery wiring can go. Custom NotifyFactory implementations receive an optional 4th parameter deliveryDb, and DeliveryService.notify an optional 6th."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.363.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "Shared type guards and fewer unchecked casts in framework internals",
|
|
25
|
+
"detail": "\"@cosmicdrift/kumiko-types/type-guards\" exports isPlainObject, isOneOf and errorCodeOf. The framework replaces many unchecked as-casts with narrowing, generic query rows and table-introspection helpers. encryptPiiFieldValues is generic over the row and returns PiiEncryptedRow<TRow>, so callers read encrypted fields without casts. Behavior stays the same."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.363.0",
|
|
29
|
+
"type": "improvement",
|
|
30
|
+
"title": "r.notification declares variables, template and meta; applyBootSeeds seeds them",
|
|
31
|
+
"detail": "r.notification accepts variables (typed with templateVariable.text, link, money, date and list), template (per locale body, document html) and meta (per locale subject and more). Boot validation requires a kebab-case name, a meta.<locale>.subject for every template locale and valid placeholder names. templateVariablesToZod turns the variables into a Zod schema. applyBootSeeds calls seedNotificationTemplates, which writes the text as system templates of kind mail and fails loudly when notifications declare a template but the template-resolver feature is not mounted."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.363.0",
|
|
35
|
+
"type": "improvement",
|
|
36
|
+
"title": "r.notification works without trigger and ships localized templates",
|
|
37
|
+
"detail": "trigger, recipient and data are optional and must be declared together, a notification without trigger is sent explicitly through ctx.notify. localizedTemplate groups body and meta per locale and is mutually exclusive with template and meta. The variable names link, money and date are reserved for template helpers. ctx.notify validates the data against the declared variables and fails the write on a missing required variable. New exports are listMailLocales, hasMailTranslationKey, assertNotificationVariables and NotificationLocalizedTemplate."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"version": "0.363.0",
|
|
41
|
+
"type": "breaking",
|
|
42
|
+
"title": "ctx.notify rejects a broadcast to another tenant",
|
|
43
|
+
"detail": "A notify with to: { tenant } now throws an AccessDeniedError (reason notify_foreign_tenant_broadcast) unless the tenant is the caller's own, before any delivery work. In a write handler it fails synchronously instead of landing in the afterCommit log. Notifications fired from seed hooks during self-signup no longer use the app pool inside the signup transaction, which deadlocked at pool max N with N parallel signups; they deliver after commit. ctx.scheduleAfterCommit is now absent in query bridges and post-commit contexts, where hooks pushed to it never ran; callers that check for it (cache-sync announce, set-tenant-tier onAssigned, form-draft discard, seed hooks) now run inline there instead of being dropped.",
|
|
44
|
+
"migration": "to: { tenant } must be the tenant of the caller (user.tenantId of the handler or job context), otherwise notify throws; in a write handler the throw is synchronous. Seed-hook notifications in the signup transaction run after commit and return { kind: \"deferred\" } (immediate: true runs synchronously on the outside-transaction pool, which cannot see rows the signup transaction has not committed yet, so a tenant broadcast there finds no new member). No known consumer broadcasts to a foreign tenant."
|
|
45
|
+
},
|
|
2
46
|
{
|
|
3
47
|
"version": "0.362.0",
|
|
4
48
|
"type": "fix",
|