@cosmicdrift/kumiko-framework 0.385.0 → 0.387.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/LICENSE +21 -57
- package/README.md +2 -2
- package/agents/widgets.md +39 -39
- package/dist/api/api-constants.js +2 -2
- package/dist/api/auth-middleware.js +7 -7
- package/dist/api/auth-rate-limiters.js +7 -3
- package/dist/api/auth-routes-mfa.js +1 -1
- package/dist/api/auth-routes-session.js +1 -1
- package/dist/api/auth-routes-shared.js +7 -3
- package/dist/api/auth-routes-signup.js +1 -1
- package/dist/api/client-ip.js +3 -1
- package/dist/api/extra-route.js +1 -1
- package/dist/api/pii-leak-guard.js +4 -2
- package/dist/api/readiness.js +5 -5
- package/dist/api/redis-sse-broker.js +8 -6
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-context.js +16 -7
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +34 -33
- package/dist/api/sse-broker.js +1 -1
- package/dist/api/sse-route.d.ts +13 -13
- package/dist/api/sse-route.js +15 -15
- package/dist/auth/index.js +4 -4
- package/dist/auth/roles.d.ts +4 -4
- package/dist/auth/roles.js +24 -24
- package/dist/compliance/duration-spec.js +18 -20
- package/dist/compliance/index.js +2 -3
- package/dist/compliance/override-schema.d.ts +4 -4
- package/dist/compliance/override-schema.js +19 -22
- package/dist/compliance/profiles.d.ts +26 -29
- package/dist/compliance/profiles.js +62 -69
- package/dist/compliance/sub-processors.d.ts +28 -30
- package/dist/compliance/sub-processors.js +17 -21
- package/dist/consumer-cli.js +4 -4
- package/dist/crypto/blind-index.js +18 -17
- package/dist/crypto/ciphertext-pattern.js +1 -1
- package/dist/crypto/event-pii.js +3 -3
- package/dist/crypto/kek-source.d.ts +1 -1
- package/dist/crypto/kek-source.js +3 -2
- package/dist/crypto/kms-adapter.d.ts +1 -1
- package/dist/crypto/kms-adapter.js +4 -6
- package/dist/crypto/kms-wiring.d.ts +1 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/crypto/pg-kms-adapter.js +2 -6
- package/dist/crypto/pii-field-encryption.js +4 -4
- package/dist/crypto/subject-resolver.d.ts +1 -1
- package/dist/crypto/subject-resolver.js +2 -2
- package/dist/db/apply-entity-event.d.ts +6 -6
- package/dist/db/apply-entity-event.js +55 -55
- package/dist/db/blind-index-cleanup.js +7 -7
- package/dist/db/collect-table-metas.js +10 -11
- package/dist/db/compound-types.d.ts +1 -1
- package/dist/db/compound-types.js +12 -12
- package/dist/db/cross-tenant-reads.d.ts +8 -0
- package/dist/db/cross-tenant-reads.js +23 -0
- package/dist/db/cursor.js +3 -4
- package/dist/db/dialect.d.ts +2 -2
- package/dist/db/dialect.js +2 -2
- package/dist/db/eagerload.d.ts +3 -3
- package/dist/db/eagerload.js +21 -12
- package/dist/db/entity-table-meta.d.ts +2 -2
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/db/event-store-executor-context.d.ts +1 -1
- package/dist/db/event-store-executor-context.js +13 -13
- package/dist/db/event-store-executor-read.js +27 -28
- package/dist/db/event-store-executor-write.js +65 -70
- package/dist/db/located-timestamp.d.ts +11 -11
- package/dist/db/located-timestamp.js +29 -30
- package/dist/db/migrate-generator.d.ts +1 -1
- package/dist/db/migrate-generator.js +26 -19
- package/dist/db/migrate-runner.js +25 -25
- package/dist/db/money.d.ts +6 -6
- package/dist/db/money.js +29 -27
- package/dist/db/queries/backfill-pii.js +5 -5
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/es-ops.d.ts +1 -1
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/event-store.js +4 -2
- package/dist/db/queries/projection-rebuild.js +3 -3
- package/dist/db/queries/raw-sql.d.ts +2 -2
- package/dist/db/queries/raw-sql.js +2 -2
- package/dist/db/queries/shadow-swap.js +19 -19
- package/dist/db/queries/test-stack.d.ts +4 -1
- package/dist/db/queries/test-stack.js +15 -2
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +6 -6
- package/dist/db/rebuild-marker.js +20 -21
- package/dist/db/replay-migration-sql.js +2 -2
- package/dist/db/schema-inspection.js +4 -5
- package/dist/db/table-builder.d.ts +1 -2
- package/dist/db/table-builder.js +82 -82
- package/dist/db/tenant-db.d.ts +2 -2
- package/dist/db/tenant-db.js +9 -2
- package/dist/derivatives/delete-stored-file.js +5 -2
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-roles.js +4 -3
- package/dist/engine/boot-validator/api-ext.js +14 -14
- package/dist/engine/boot-validator/config-deps.js +7 -6
- package/dist/engine/boot-validator/detail-screens.js +3 -3
- package/dist/engine/boot-validator/entity-handler.js +60 -61
- package/dist/engine/boot-validator/gdpr-storage.js +4 -4
- package/dist/engine/boot-validator/i18n-keys.js +1 -1
- package/dist/engine/boot-validator/index.d.ts +2 -2
- package/dist/engine/boot-validator/index.js +20 -20
- package/dist/engine/boot-validator/nav.js +5 -5
- package/dist/engine/boot-validator/ownership.js +12 -9
- package/dist/engine/boot-validator/parent-ref.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +50 -57
- package/dist/engine/boot-validator/projection-list-screens.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +1 -1
- package/dist/engine/boot-validator/screens-actions.js +15 -16
- package/dist/engine/boot-validator/screens-forms.js +10 -11
- package/dist/engine/boot-validator/screens-lists.js +14 -15
- package/dist/engine/boot-validator/screens.js +34 -40
- package/dist/engine/boot-validator/security-baseline.js +3 -2
- package/dist/engine/boot-validator/transfer-graph.js +2 -2
- package/dist/engine/boot-validator/workspaces.js +3 -3
- package/dist/engine/bridge-stub.js +3 -4
- package/dist/engine/build-app-schema.js +75 -75
- package/dist/engine/build-config-feature-schema.js +50 -50
- package/dist/engine/build-target.js +20 -23
- package/dist/engine/codemod/pipeline-codemod.js +20 -13
- package/dist/engine/config-helpers.js +4 -4
- package/dist/engine/constants.js +2 -2
- package/dist/engine/create-app.js +2 -2
- package/dist/engine/define-feature.js +12 -11
- package/dist/engine/define-handler.js +12 -11
- package/dist/engine/define-step.js +1 -1
- package/dist/engine/define-workflow.d.ts +5 -5
- package/dist/engine/define-workflow.js +6 -6
- package/dist/engine/entity-handlers.js +13 -14
- package/dist/engine/event-def.js +2 -2
- package/dist/engine/extension-names.d.ts +16 -17
- package/dist/engine/extension-names.js +37 -38
- package/dist/engine/extensions/escape-hatch-usage.js +1 -1
- package/dist/engine/extensions/tenant-data.js +2 -2
- package/dist/engine/extensions/user-data.d.ts +23 -24
- package/dist/engine/factories.d.ts +14 -13
- package/dist/engine/factories.js +38 -37
- package/dist/engine/feature-ast/entity-field-types.js +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +1 -1
- package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
- package/dist/engine/feature-ast/extractors/shared.js +2 -3
- package/dist/engine/feature-ast/parse.js +13 -13
- package/dist/engine/feature-ast/patch.js +1 -1
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -1
- package/dist/engine/feature-ast/patterns.d.ts +2 -2
- package/dist/engine/feature-ast/render.js +2 -2
- package/dist/engine/feature-config-events-jobs.js +15 -12
- package/dist/engine/feature-entity-handlers.js +9 -5
- package/dist/engine/feature-manifest.d.ts +9 -9
- package/dist/engine/feature-manifest.js +6 -7
- package/dist/engine/feature-ui-extensions.d.ts +3 -3
- package/dist/engine/feature-ui-extensions.js +30 -22
- package/dist/engine/field-access.js +5 -6
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +4 -4
- package/dist/engine/object-form.d.ts +1 -0
- package/dist/engine/object-form.js +14 -4
- package/dist/engine/ownership.js +3 -3
- package/dist/engine/parse-ref-target.js +5 -7
- package/dist/engine/pattern-library/index.js +2 -2
- package/dist/engine/pattern-library/library.js +2 -2
- package/dist/engine/pattern-library/types.js +3 -3
- package/dist/engine/pipeline.js +2 -2
- package/dist/engine/project-app-schema-for-roles.js +1 -1
- package/dist/engine/projection-helpers.js +9 -9
- package/dist/engine/qualified-name.js +2 -2
- package/dist/engine/registry-facade.js +1 -1
- package/dist/engine/registry-ingest.js +12 -12
- package/dist/engine/registry-state.js +17 -19
- package/dist/engine/registry-validate.js +12 -12
- package/dist/engine/run-in.js +2 -2
- package/dist/engine/run-pipeline.js +7 -0
- package/dist/engine/schema-builder.js +40 -38
- package/dist/engine/screen-filter-ops.js +17 -20
- package/dist/engine/screen-helpers.js +4 -3
- package/dist/engine/state-machine.d.ts +12 -12
- package/dist/engine/state-machine.js +7 -7
- package/dist/engine/steps/_resolver-utils.js +4 -3
- package/dist/engine/steps/for-each.js +4 -4
- package/dist/engine/steps/unsafe-projection-delete.js +1 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
- package/dist/engine/steps/wait-for-event.js +4 -4
- package/dist/engine/steps/workflow-system-events.js +3 -3
- package/dist/engine/tier-resolver-extension.d.ts +29 -27
- package/dist/engine/tier-resolver-extension.js +26 -26
- package/dist/engine/transfer-adjacency.js +2 -2
- package/dist/engine/validate-projection-allowlist.js +7 -27
- package/dist/engine/walk-step-instances.d.ts +2 -0
- package/dist/engine/walk-step-instances.js +38 -0
- package/dist/engine/where-rule-lint.js +1 -1
- package/dist/engine/write-handler-qn-extract.js +1 -1
- package/dist/entrypoint/index.js +3 -3
- package/dist/env/index.d.ts +7 -6
- package/dist/env/index.js +10 -9
- package/dist/errors/transition-details.js +7 -7
- package/dist/errors/write-error-info.d.ts +6 -6
- package/dist/errors/write-error-info.js +6 -6
- package/dist/es-ops/context.d.ts +9 -9
- package/dist/es-ops/context.js +28 -28
- package/dist/es-ops/index.js +7 -9
- package/dist/es-ops/operations-schema.js +4 -10
- package/dist/es-ops/runner.d.ts +11 -11
- package/dist/es-ops/runner.js +49 -53
- package/dist/es-ops/types.d.ts +55 -55
- package/dist/es-ops/types.js +11 -11
- package/dist/event-store/admin-api.js +6 -7
- package/dist/event-store/errors.js +1 -1
- package/dist/event-store/event-store.js +2 -2
- package/dist/event-store/rebuild-dead-letter.js +1 -1
- package/dist/files/feature.js +9 -10
- package/dist/files/file-handle.js +5 -6
- package/dist/files/file-ref-entity.js +15 -15
- package/dist/files/file-ref-table.js +7 -8
- package/dist/files/file-routes.d.ts +7 -0
- package/dist/files/file-routes.js +11 -5
- package/dist/files/in-memory-provider.js +8 -9
- package/dist/files/local-provider.js +18 -25
- package/dist/files/provider-resolver.d.ts +7 -7
- package/dist/files/storage-tracking.js +2 -2
- package/dist/files/zip-stream.d.ts +12 -19
- package/dist/files/zip-stream.js +44 -63
- package/dist/i18n/required-surface-keys.d.ts +1 -1
- package/dist/i18n/required-surface-keys.js +8 -8
- package/dist/internal/stack-support.d.ts +1 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-helpers.d.ts +1 -2
- package/dist/jobs/job-runner-helpers.js +8 -18
- package/dist/jobs/job-runner-schedulers.d.ts +1 -1
- package/dist/jobs/job-runner-schedulers.js +3 -4
- package/dist/jobs/job-runner.js +17 -18
- package/dist/lifecycle/lifecycle.js +3 -5
- package/dist/lifecycle/signal-handlers.js +5 -4
- package/dist/logging/utils.d.ts +2 -1
- package/dist/logging/utils.js +38 -10
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/kumiko-drift.js +14 -13
- package/dist/migrations/pending-rebuilds.d.ts +21 -20
- package/dist/migrations/pending-rebuilds.js +27 -27
- package/dist/migrations/projection-table-index.d.ts +3 -3
- package/dist/migrations/projection-table-index.js +8 -8
- package/dist/observability/metric-validator.js +1 -1
- package/dist/observability/metrics-handle.js +3 -3
- package/dist/pipeline/append-event-core.js +11 -2
- package/dist/pipeline/cascade-handler.d.ts +1 -1
- package/dist/pipeline/dispatch-batch.js +2 -2
- package/dist/pipeline/dispatch-query.js +10 -10
- package/dist/pipeline/dispatch-shared.d.ts +1 -0
- package/dist/pipeline/dispatch-shared.js +42 -19
- package/dist/pipeline/dispatch-stream.js +1 -1
- package/dist/pipeline/dispatch-write.js +3 -3
- package/dist/pipeline/dispatcher-utils.d.ts +1 -1
- package/dist/pipeline/dispatcher-utils.js +1 -1
- package/dist/pipeline/event-consumer-state.js +2 -2
- package/dist/pipeline/event-dispatcher-delivery.js +1 -1
- package/dist/pipeline/event-dispatcher.js +17 -28
- package/dist/pipeline/irreversible-operation-gate.js +1 -1
- package/dist/pipeline/lifecycle-pipeline.js +9 -9
- package/dist/pipeline/member-read-only-transaction.js +1 -3
- package/dist/pipeline/msp-rebuild.js +1 -1
- package/dist/pipeline/projection-rebuild.js +13 -12
- package/dist/pipeline/system-hooks.js +39 -41
- package/dist/pipeline/system-identity-switch.js +1 -3
- package/dist/pipeline/tenant-timezone-cache.js +1 -1
- package/dist/random/generate.d.ts +29 -29
- package/dist/random/generate.js +30 -30
- package/dist/random/words.js +20 -20
- package/dist/rate-limit/middleware.js +9 -5
- package/dist/redis/client.js +2 -2
- package/dist/redis/pubsub-signal.js +14 -17
- package/dist/schema-cli.js +116 -22
- package/dist/search/index.js +4 -4
- package/dist/search/purge-subject.js +4 -5
- package/dist/search/reindex-entity.js +6 -9
- package/dist/time/boot-tz-warning.d.ts +2 -2
- package/dist/time/boot-tz-warning.js +8 -8
- package/dist/time/iana.js +10 -10
- package/dist/time/index.js +9 -9
- package/dist/time/legacy-date.d.ts +2 -2
- package/dist/time/legacy-date.js +6 -8
- package/dist/time/polyfill.js +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +1 -1
- package/dist/ui-types/app-schema.js +7 -7
- package/dist/ui-types/list-row-meta.js +4 -4
- package/dist/upgrade-cli.js +10 -9
- package/dist/utils/compare.js +1 -1
- package/package.json +4 -4
- package/src/changes.json +54 -0
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
- package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
- package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
- package/src/scripts/codemod/pii-personal-migration.ts +17 -8
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
|
@@ -4,25 +4,26 @@ import { collectSearchableSubjectFields, configuredPiiSubjectKms, decryptPiiFiel
|
|
|
4
4
|
import { createTenantDb, entityTableFromRegistry, rehydrateCompoundTypes } from "../db/index.js";
|
|
5
5
|
import { tenantChannel } from "../engine/constants.js";
|
|
6
6
|
import { InternalError } from "../errors/index.js";
|
|
7
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
8
|
+
const log = createFallbackLogger("kumiko:search");
|
|
7
9
|
// --- Search Index Consumer (async, via event-dispatcher) ---
|
|
8
10
|
//
|
|
9
|
-
// Search
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// non-inline side-effects.
|
|
11
|
+
// Search indexing runs as an async EventConsumer through the event dispatcher,
|
|
12
|
+
// rather than as a synchronous postSave/postDelete hook. This mirrors Marten's
|
|
13
|
+
// ISubscription pattern: one async path for all non-inline side effects.
|
|
13
14
|
//
|
|
14
15
|
// Event → Search-Op Mapping:
|
|
15
16
|
//
|
|
16
17
|
// <entity>.created → index(tenantId, doc) // state = event.payload
|
|
17
18
|
// <entity>.updated → index(tenantId, doc) // state = { ...previous, ...changes }
|
|
18
19
|
// <entity>.restored → index(tenantId, doc) // state = event.payload.previous
|
|
19
|
-
// <entity>.deleted
|
|
20
|
-
// <entity>.forgotten
|
|
21
|
-
// <entity>.<named>
|
|
22
|
-
// projection row
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
20
|
+
// <entity>.deleted → remove(tenantId, type, id)
|
|
21
|
+
// <entity>.forgotten → remove(tenantId, type, id)
|
|
22
|
+
// <entity>.<named> → index or remove, state read from the live
|
|
23
|
+
// projection row — a named domain event carries
|
|
24
|
+
// only a payload slice, not the full entity, so
|
|
25
|
+
// there's no fixed shape to reconstruct state from
|
|
26
|
+
// as there is for created/updated/restored.
|
|
26
27
|
// Only queried when the entity declares searchable
|
|
27
28
|
// fields/extensions; no row (hard/soft-deleted) →
|
|
28
29
|
// remove, same as the deleted branch.
|
|
@@ -36,7 +37,7 @@ import { InternalError } from "../errors/index.js";
|
|
|
36
37
|
// batchHandler collapses a turn's events to one search-op per
|
|
37
38
|
// (tenantId, entityType, entityId) and issues one indexBatch/removeBatch
|
|
38
39
|
// call per tenant instead of one round-trip per event; handler stays the
|
|
39
|
-
// per-event fallback the dispatcher
|
|
40
|
+
// per-event fallback the dispatcher uses when a batch throws.
|
|
40
41
|
export const SEARCH_CONSUMER_NAME = "system:consumer:search";
|
|
41
42
|
// An executor update with includeDeleted leaves the row deleted; indexing it would make it findable again.
|
|
42
43
|
function isUpdateOfSoftDeletedRow(event, verb) {
|
|
@@ -58,7 +59,7 @@ async function resolveSearchOperation(event, ctx, registry) {
|
|
|
58
59
|
state = reconstructStateForSearch(event.payload, verb);
|
|
59
60
|
}
|
|
60
61
|
else {
|
|
61
|
-
//
|
|
62
|
+
// Named domain event: no fixed payload shape to reconstruct
|
|
62
63
|
// state from. Only worth a projection read when the entity actually
|
|
63
64
|
// declared searchable fields/extensions — same check
|
|
64
65
|
// buildSearchDocument makes below, done here first so a non-
|
|
@@ -68,7 +69,7 @@ async function resolveSearchOperation(event, ctx, registry) {
|
|
|
68
69
|
(registry.getSearchableFields(entityName).length > 0 ||
|
|
69
70
|
registry.getSearchPayloadExtensions(entityName).length > 0);
|
|
70
71
|
// skip: entity declares no searchable fields/extensions — nothing to
|
|
71
|
-
// index, stays a no-op with no extra query, same as before
|
|
72
|
+
// index, stays a no-op with no extra query, same as before.
|
|
72
73
|
if (!entity || !isSearchable)
|
|
73
74
|
return undefined;
|
|
74
75
|
const row = await readProjectionRowForSearch(ctx, registry, entityName, entity, tenantId, event.aggregateId);
|
|
@@ -183,7 +184,7 @@ export function createSearchEventConsumer(searchAdapter, registry) {
|
|
|
183
184
|
},
|
|
184
185
|
};
|
|
185
186
|
}
|
|
186
|
-
//
|
|
187
|
+
// Reads the live projection row for a named domain event, tenant-
|
|
187
188
|
// scoped through createTenantDb the same way MultiStreamProjection appliers
|
|
188
189
|
// scope their apply()-writes (see api/server.ts), on the table the registry
|
|
189
190
|
// actually booted (entityTableFromRegistry) rather than one freshly derived
|
|
@@ -217,7 +218,7 @@ async function readProjectionRowForSearch(ctx, registry, entityName, entity, ten
|
|
|
217
218
|
}
|
|
218
219
|
return state;
|
|
219
220
|
}
|
|
220
|
-
//
|
|
221
|
+
// Subject-annotated searchable fields are ciphertext in the event
|
|
221
222
|
// payload; decrypt into the derived index only. No KMS → omit ciphertext
|
|
222
223
|
// values rather than indexing blobs.
|
|
223
224
|
export async function decryptSearchableSubjectFields(entityName, state, registry) {
|
|
@@ -242,8 +243,8 @@ export async function decryptSearchableSubjectFields(entityName, state, registry
|
|
|
242
243
|
});
|
|
243
244
|
}
|
|
244
245
|
catch (err) {
|
|
245
|
-
|
|
246
|
-
`dropping ciphertext fields for this document instead of wedging the consumer.`, err);
|
|
246
|
+
log.warn(`decryptSearchableSubjectFields failed for "${entityName}" — ` +
|
|
247
|
+
`dropping ciphertext fields for this document instead of wedging the consumer.`, { err });
|
|
247
248
|
const out = { ...state };
|
|
248
249
|
for (const name of fields) {
|
|
249
250
|
if (isPiiCiphertext(out[name]))
|
|
@@ -295,7 +296,7 @@ function warnOncePerKeyCollision(registry, entityName, key, isBaseField) {
|
|
|
295
296
|
return;
|
|
296
297
|
warned.add(dedupKey);
|
|
297
298
|
const collidesWith = isBaseField ? `base field "${key}"` : `earlier contributor key "${key}"`;
|
|
298
|
-
|
|
299
|
+
log.warn(`searchPayloadExtension on "${entityName}" tried to overwrite ` +
|
|
299
300
|
`${collidesWith} — keeping the first value. Rename the contributor key.`);
|
|
300
301
|
}
|
|
301
302
|
// Build a SearchDocument from raw field-state. Parallel to the old
|
|
@@ -307,10 +308,9 @@ export async function buildSearchDocument(entityName, entityId, state, registry)
|
|
|
307
308
|
return null;
|
|
308
309
|
const searchableFields = registry.getSearchableFields(entityName);
|
|
309
310
|
const extensions = registry.getSearchPayloadExtensions(entityName);
|
|
310
|
-
// Skip
|
|
311
|
-
// Extensions
|
|
312
|
-
// (
|
|
313
|
-
// berücksichtigen.
|
|
311
|
+
// Skip guard: no indexable payload and no extensions means there is no doc.
|
|
312
|
+
// Extensions can populate the index without searchable base fields
|
|
313
|
+
// (for example, customFields-only indexing), so both must be checked.
|
|
314
314
|
if (searchableFields.length === 0 && extensions.length === 0)
|
|
315
315
|
return null;
|
|
316
316
|
const embeddedFields = new Set();
|
|
@@ -348,15 +348,15 @@ export async function buildSearchDocument(entityName, entityId, state, registry)
|
|
|
348
348
|
fields[f] = state[f];
|
|
349
349
|
}
|
|
350
350
|
}
|
|
351
|
-
//
|
|
351
|
+
// Search-Payload-Extensions: contributors merge flat fields into the
|
|
352
352
|
// search-doc (customFields-bundle / tags / computed-counts / etc.).
|
|
353
353
|
// Sequential await — extensions are expected sync or sub-millisecond async;
|
|
354
354
|
// sequential keeps the path simple and deterministic.
|
|
355
355
|
//
|
|
356
356
|
// Precedence is base-fields-win: a contributor key that collides with a
|
|
357
|
-
// searchable
|
|
358
|
-
// and warned. A jsonb custom-field that happens to share a
|
|
359
|
-
// must not shadow the indexed
|
|
357
|
+
// searchable base field is dropped (not silently merged over the real value)
|
|
358
|
+
// and warned. A jsonb custom-field that happens to share a base field name
|
|
359
|
+
// must not shadow the indexed base field.
|
|
360
360
|
const baseFieldKeys = new Set(Object.keys(fields));
|
|
361
361
|
for (const contribute of extensions) {
|
|
362
362
|
const contributed = await contribute({ entityName, entityId, state });
|
|
@@ -395,7 +395,7 @@ export const SSE_BROADCAST_CONSUMER_NAME = "system:consumer:sse-broadcast";
|
|
|
395
395
|
export function createSseBroadcastEventConsumer(sseBroker) {
|
|
396
396
|
return {
|
|
397
397
|
name: SSE_BROADCAST_CONSUMER_NAME,
|
|
398
|
-
// Shared delivery: one cursor reads every event exactly once
|
|
398
|
+
// Shared delivery: one cursor reads every event exactly once.
|
|
399
399
|
// Fanout to every process's own SSE clients happens below, through
|
|
400
400
|
// sseBroker.pushToChannel — a Redis-backed broker (the default once
|
|
401
401
|
// REDIS_URL is set) republishes across replicas, so every pod's
|
|
@@ -425,7 +425,7 @@ export function createSseBroadcastEventConsumer(sseBroker) {
|
|
|
425
425
|
// r.multiStreamProjection's ctx.unsafeAppendEvent, or a raw
|
|
426
426
|
// event-store-executor write (e.g. `files`' fileRef.created) — never
|
|
427
427
|
// reached it, so a job could never trigger on an r.defineEvent-registered
|
|
428
|
-
// event
|
|
428
|
+
// event.
|
|
429
429
|
//
|
|
430
430
|
// This consumer closes that gap the same way search/SSE do: read every
|
|
431
431
|
// committed event off the shared cursor and re-check job triggers. It is
|
|
@@ -467,10 +467,9 @@ export function createJobTriggerEventConsumer(jobRunner, registry) {
|
|
|
467
467
|
}
|
|
468
468
|
// --- Access-Invalidation Consumer (async, via event-dispatcher) ---
|
|
469
469
|
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
// pushes an invalidation through sseBroker.publishAccessInvalidation, which
|
|
470
|
+
// This consumer is the last leg: it watches for the two event types that
|
|
471
|
+
// can make an already-issued JWT stale mid-stream and pushes an invalidation
|
|
472
|
+
// through sseBroker.publishAccessInvalidation, which
|
|
474
473
|
// dispatch-stream.ts's subscribeAccessInvalidation listener turns into an
|
|
475
474
|
// AccessDeniedError thrown into the open stream.
|
|
476
475
|
//
|
|
@@ -499,21 +498,20 @@ export function createJobTriggerEventConsumer(jobRunner, registry) {
|
|
|
499
498
|
// payload.previous, the full pre-write entity snapshot written by
|
|
500
499
|
// createEventStoreExecutor. tenantMembershipEntity declares no
|
|
501
500
|
// encrypted/PII fields, so previous.userId is plaintext — no KMS
|
|
502
|
-
// decrypt step
|
|
503
|
-
//
|
|
501
|
+
// decrypt step; the original handoff assumed encryption that doesn't
|
|
502
|
+
// apply here.
|
|
504
503
|
//
|
|
505
504
|
// Over-invalidation (e.g. tenant-membership.updated fired by something
|
|
506
505
|
// other than a role change) is fail-safe: the stream closes, the client
|
|
507
|
-
// reconnects and re-authorizes. No opt-out/allowlist —
|
|
508
|
-
//
|
|
509
|
-
// handler.
|
|
506
|
+
// reconnects and re-authorizes. No opt-out/allowlist — global-by-default
|
|
507
|
+
// specifically so this can't be silently disabled per handler.
|
|
510
508
|
//
|
|
511
509
|
// Scope note: entityEventName also emits "tenant-membership.forgotten" (DSGVO
|
|
512
510
|
// erasure) and ".restored" — .restored re-grants access so ignoring it is
|
|
513
511
|
// correct, .forgotten is not currently produced by any user-data-rights
|
|
514
512
|
// pipeline for memberships but would be revocation-relevant if it ever is.
|
|
515
|
-
// Out of
|
|
516
|
-
//
|
|
513
|
+
// Out of the stated scope (session-revoke + role/membership change), tracked
|
|
514
|
+
// rather than handled speculatively here.
|
|
517
515
|
export const ACCESS_INVALIDATION_CONSUMER_NAME = "system:consumer:access-invalidation";
|
|
518
516
|
const SESSION_REVOKED_EVENT_TYPE = "sessions:event:session-revoked";
|
|
519
517
|
const PAT_REVOKED_EVENT_TYPE = "personal-access-tokens:event:pat-revoked";
|
|
@@ -557,7 +555,7 @@ function readPatRevokedScope(payload) {
|
|
|
557
555
|
export function createAccessInvalidationEventConsumer(sseBroker) {
|
|
558
556
|
return {
|
|
559
557
|
name: ACCESS_INVALIDATION_CONSUMER_NAME,
|
|
560
|
-
// Shared delivery, same reasoning as SSE broadcast
|
|
558
|
+
// Shared delivery, same reasoning as SSE broadcast: one
|
|
561
559
|
// cursor reads every event once and calls sseBroker.publishAccessInvalidation
|
|
562
560
|
// below, which a Redis-backed broker republishes to every replica's
|
|
563
561
|
// subscribeAccessInvalidation listeners — no per-instance cursor needed
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { escapeHatchFor } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
-
import { withSystemDbUnsafeRawGrant, withUnsafeRawGrant
|
|
2
|
+
import { withSystemDbUnsafeRawGrant, withUnsafeRawGrant } from "../db/tenant-db.js";
|
|
3
3
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
4
4
|
import { AccessDeniedError, FrameworkReasons, InternalError } from "../errors/index.js";
|
|
5
5
|
export function isSystemIdentity(user) {
|
|
@@ -235,12 +235,10 @@ export function withHookEscapeHatchGrant(context, callerLabel, escapeHatch) {
|
|
|
235
235
|
}),
|
|
236
236
|
...gatedMemberReaderField(callerLabel, identityHatch, ctxQueryAsMember),
|
|
237
237
|
...gatedProjectionReaderField(callerLabel, identityHatch, ctxQueryProjection),
|
|
238
|
-
// @cast-boundary engine-bridge — withUnsafeRawGrant passes non-TenantDb values (e.g. a guard Proxy) through unchanged.
|
|
239
238
|
...(ctxDb && { db: withUnsafeRawGrant(ctxDb, rawHatch) }),
|
|
240
239
|
...(ctxDbOutsideTransaction && {
|
|
241
240
|
dbOutsideTransaction: withUnsafeRawGrant(ctxDbOutsideTransaction, rawHatch),
|
|
242
241
|
}),
|
|
243
|
-
// @cast-boundary engine-bridge — withSystemDbUnsafeRawGrant passes non-UncheckedSystemDb values through unchanged.
|
|
244
242
|
...(ctxSystemDb && {
|
|
245
243
|
systemDb: withSystemDbUnsafeRawGrant(ctxSystemDb, rawHatch, callerLabel),
|
|
246
244
|
}),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Per-dispatcher in-process cache for the resolved tenant:config:timezone
|
|
2
|
-
// value
|
|
2
|
+
// value. dispatch-shared.ts's buildHandlerContext resolves that
|
|
3
3
|
// key on every dispatch to build ctx.tz — without a cache that's a config
|
|
4
4
|
// SELECT (sometimes two, cascade + fallback) per request, often inside an
|
|
5
5
|
// open write transaction. A tenant-keyed TTL cache removes the steady-state
|
|
@@ -1,56 +1,56 @@
|
|
|
1
1
|
export type AdjNounNameOptions = {
|
|
2
|
-
/**
|
|
2
|
+
/** Separator between adj/noun (and, if applicable, suffix). Default "-". */
|
|
3
3
|
readonly separator?: string;
|
|
4
|
-
/**
|
|
5
|
-
* (no-confusable
|
|
6
|
-
*
|
|
4
|
+
/** If set, appends a random suffix of length .length
|
|
5
|
+
* (no-confusable alphabet). 3 characters are recommended = 32^3 = 32,768
|
|
6
|
+
* additional combinations per word pair. */
|
|
7
7
|
readonly suffix?: {
|
|
8
8
|
readonly length: number;
|
|
9
9
|
};
|
|
10
10
|
/** Custom Adjective-Liste — default ADJECTIVES (191 generic). */
|
|
11
11
|
readonly adjectives?: readonly string[];
|
|
12
|
-
/** Custom
|
|
13
|
-
*
|
|
14
|
-
* -receiver/-listener
|
|
12
|
+
/** Custom noun list — default NOUNS (173 generic). Apps that want
|
|
13
|
+
* domain-specific slugs (e.g. webhook features with their own
|
|
14
|
+
* -receiver/-listener nouns) provide their own list. */
|
|
15
15
|
readonly nouns?: readonly string[];
|
|
16
16
|
};
|
|
17
|
-
/** Sync
|
|
18
|
-
* (
|
|
19
|
-
*
|
|
17
|
+
/** Sync generator: produces "happy-cloud" or "happy-cloud-k8x"
|
|
18
|
+
* (with suffix). No conflict check — the caller is responsible for
|
|
19
|
+
* uniqueness, or can use generateUniqueName() for the async variant. */
|
|
20
20
|
export declare function generateAdjNounName(options?: AdjNounNameOptions): string;
|
|
21
|
-
/** Random
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
/** Random string from the no-confusable alphabet. For suffixes after
|
|
22
|
+
* collisions or as standalone short IDs (e.g. webhook verification codes
|
|
23
|
+
* to be read aloud over the phone). length ≥ 1. */
|
|
24
24
|
export declare function generateNoConfusableId(length: number): string;
|
|
25
25
|
export type GenerateUniqueNameOptions = {
|
|
26
|
-
/** Caller
|
|
27
|
-
*
|
|
26
|
+
/** Caller predicate. Returns true if the name is not yet assigned
|
|
27
|
+
* (typically: DB query "select where slug=$1" → row count === 0). */
|
|
28
28
|
readonly isAvailable: (name: string) => Promise<boolean>;
|
|
29
|
-
/**
|
|
30
|
-
* Default 3.
|
|
31
|
-
*
|
|
29
|
+
/** Maximum attempts WITHOUT a suffix before switching to suffix mode.
|
|
30
|
+
* Default 3. With 33,043 default combinations and ~150 existing
|
|
31
|
+
* tenants, p(collision) < 1% — 3 attempts are more than sufficient. */
|
|
32
32
|
readonly maxCleanAttempts?: number;
|
|
33
|
-
/** Suffix
|
|
34
|
-
*
|
|
33
|
+
/** Suffix length in collision mode. Default 3 (= 32,768 combinations
|
|
34
|
+
* per word pair). */
|
|
35
35
|
readonly suffixLength?: number;
|
|
36
|
-
/** Hard
|
|
37
|
-
*
|
|
38
|
-
* isAvailable()
|
|
36
|
+
/** Hard cap on total attempts before giving up. Default 20.
|
|
37
|
+
* Rarely reached in practice — if it is, the word list may be empty or
|
|
38
|
+
* isAvailable() may return false consistently (DB bug). */
|
|
39
39
|
readonly maxTotalAttempts?: number;
|
|
40
40
|
readonly separator?: string;
|
|
41
41
|
readonly adjectives?: readonly string[];
|
|
42
42
|
readonly nouns?: readonly string[];
|
|
43
43
|
};
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
* Attempts.
|
|
47
|
-
*
|
|
44
|
+
/** Generates a unique Adj-Noun slug by trying up to maxCleanAttempts
|
|
45
|
+
* clean word pairs, then using a random suffix up to maxTotal-
|
|
46
|
+
* Attempts. Throws if that also fails (= the caller has a bug
|
|
47
|
+
* in isAvailable, or the word list is defective).
|
|
48
48
|
*
|
|
49
|
-
*
|
|
49
|
+
* Example:
|
|
50
50
|
* const slug = await generateUniqueName({
|
|
51
51
|
* isAvailable: async (s) =>
|
|
52
52
|
* !(await db.select().from(tenants).where(eq(tenants.tenantKey, s)).then(r => r.length > 0)),
|
|
53
53
|
* });
|
|
54
|
-
* // → "happy-cloud"
|
|
54
|
+
* // → "happy-cloud" or "happy-cloud-k8x" on collision
|
|
55
55
|
*/
|
|
56
56
|
export declare function generateUniqueName(options: GenerateUniqueNameOptions): Promise<string>;
|
package/dist/random/generate.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
// Random
|
|
2
|
-
// - Tenant
|
|
3
|
-
// - Webhook
|
|
4
|
-
// - API
|
|
5
|
-
// - Tenant
|
|
6
|
-
// - Test
|
|
1
|
+
// Random generator for human-readable resource identifiers:
|
|
2
|
+
// - Tenant keys ("happy-cloud", "swift-river-k8x")
|
|
3
|
+
// - Webhook subscriber slugs ("bold-falcon-receiver" with custom nouns)
|
|
4
|
+
// - API key display names (offer 3 suggestions on creation)
|
|
5
|
+
// - Tenant subdomain suggestions in the subdomain setup screen
|
|
6
|
+
// - Test fixtures with readable identifiers
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// Authority
|
|
11
|
-
//
|
|
8
|
+
// NOT for security tokens (CSRF, session, API keys, OAuth state,
|
|
9
|
+
// reset tokens). Math.random() is not cryptographically unpredictable.
|
|
10
|
+
// Authority binding for tenant keys uses JWT + DB lookup, not slug secrecy;
|
|
11
|
+
// the slug may be guessable.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
13
|
+
// Safe across environments: Math.random() runs in Bun, Node, Metro/RN, and
|
|
14
|
+
// Expo Web. No node:crypto imports.
|
|
15
15
|
import { ADJECTIVES, NOUNS } from "./words.js";
|
|
16
|
-
// Alphabet
|
|
17
|
-
// -
|
|
18
|
-
// -
|
|
19
|
-
//
|
|
20
|
-
//
|
|
16
|
+
// Alphabet without characters that are easily confused when entered manually:
|
|
17
|
+
// - no 0/O (zero versus uppercase O)
|
|
18
|
+
// - no 1/l/I (one versus lowercase L versus uppercase I)
|
|
19
|
+
// Result: 32 characters, safe for spelling over the phone AND for copying
|
|
20
|
+
// from email text in unfamiliar fonts.
|
|
21
21
|
const NO_CONFUSABLE_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz";
|
|
22
22
|
const NO_CONFUSABLE_CHARS = Object.freeze(NO_CONFUSABLE_ALPHABET.split(""));
|
|
23
23
|
function pickRandom(arr) {
|
|
@@ -25,15 +25,15 @@ function pickRandom(arr) {
|
|
|
25
25
|
throw new Error("pickRandom: cannot pick from empty array");
|
|
26
26
|
}
|
|
27
27
|
const value = arr[Math.floor(Math.random() * arr.length)];
|
|
28
|
-
//
|
|
28
|
+
// The length check above guarantees that the index is in range.
|
|
29
29
|
if (value === undefined) {
|
|
30
30
|
throw new Error("pickRandom: indexed value undefined (sparse array?)");
|
|
31
31
|
}
|
|
32
32
|
return value;
|
|
33
33
|
}
|
|
34
|
-
/** Sync
|
|
35
|
-
* (
|
|
36
|
-
*
|
|
34
|
+
/** Sync generator: produces "happy-cloud" or "happy-cloud-k8x"
|
|
35
|
+
* (with suffix). No conflict check — the caller is responsible for
|
|
36
|
+
* uniqueness, or can use generateUniqueName() for the async variant. */
|
|
37
37
|
export function generateAdjNounName(options = {}) {
|
|
38
38
|
const sep = options.separator ?? "-";
|
|
39
39
|
const adjs = options.adjectives ?? ADJECTIVES;
|
|
@@ -44,9 +44,9 @@ export function generateAdjNounName(options = {}) {
|
|
|
44
44
|
}
|
|
45
45
|
return name;
|
|
46
46
|
}
|
|
47
|
-
/** Random
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
/** Random string from the no-confusable alphabet. For suffixes after
|
|
48
|
+
* collisions or as standalone short IDs (e.g. webhook verification codes
|
|
49
|
+
* to be read aloud over the phone). length ≥ 1. */
|
|
50
50
|
export function generateNoConfusableId(length) {
|
|
51
51
|
if (length < 1) {
|
|
52
52
|
throw new Error(`generateNoConfusableId: length must be ≥ 1 (got ${length})`);
|
|
@@ -57,17 +57,17 @@ export function generateNoConfusableId(length) {
|
|
|
57
57
|
}
|
|
58
58
|
return id;
|
|
59
59
|
}
|
|
60
|
-
/**
|
|
61
|
-
*
|
|
62
|
-
* Attempts.
|
|
63
|
-
*
|
|
60
|
+
/** Generates a unique Adj-Noun slug by trying up to maxCleanAttempts
|
|
61
|
+
* clean word pairs, then using a random suffix up to maxTotal-
|
|
62
|
+
* Attempts. Throws if that also fails (= the caller has a bug
|
|
63
|
+
* in isAvailable, or the word list is defective).
|
|
64
64
|
*
|
|
65
|
-
*
|
|
65
|
+
* Example:
|
|
66
66
|
* const slug = await generateUniqueName({
|
|
67
67
|
* isAvailable: async (s) =>
|
|
68
68
|
* !(await db.select().from(tenants).where(eq(tenants.tenantKey, s)).then(r => r.length > 0)),
|
|
69
69
|
* });
|
|
70
|
-
* // → "happy-cloud"
|
|
70
|
+
* // → "happy-cloud" or "happy-cloud-k8x" on collision
|
|
71
71
|
*/
|
|
72
72
|
export async function generateUniqueName(options) {
|
|
73
73
|
const maxClean = options.maxCleanAttempts ?? 3;
|
package/dist/random/words.js
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// PII-
|
|
1
|
+
// Word lists for human-readable resource slugs (tenant keys, webhook-
|
|
2
|
+
// subscribers, API-key display names, test fixtures). Heroku/Docker-
|
|
3
|
+
// style — adjective + noun produce pronounceable, memorable,
|
|
4
|
+
// PII-free identifiers.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
// -
|
|
8
|
-
// -
|
|
9
|
-
// -
|
|
10
|
-
// -
|
|
11
|
-
// - Lowercase, ASCII-only,
|
|
12
|
-
// - 3
|
|
13
|
-
// -
|
|
14
|
-
// -
|
|
6
|
+
// Selection criteria:
|
|
7
|
+
// - Emotionally neutral and professional (no "fluffy", "sneaky")
|
|
8
|
+
// - No animal names (Heroku cliché)
|
|
9
|
+
// - No personal names (cultural appropriation, celebrity collisions)
|
|
10
|
+
// - No thematic clusters (neither weather-only nor geography-only)
|
|
11
|
+
// - Lowercase, ASCII-only, with no hyphens within a word
|
|
12
|
+
// - 3–10 letters per word (compact slug)
|
|
13
|
+
// - Pronounceable in German AND English (user phone support)
|
|
14
|
+
// - No words with ambiguous meanings in English and German
|
|
15
15
|
//
|
|
16
|
-
// 191 × 173 = 33
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// 191 × 173 = 33,043 clean combinations. Birthday bound: from ~180
|
|
17
|
+
// tenants onward, the first collision becomes likely cumulatively (~50%);
|
|
18
|
+
// for an individual draw, p(collision) remains < 1% (see generate.ts). On
|
|
19
|
+
// collision, the suffix path in generateUniqueName is used.
|
|
20
20
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
21
|
+
// Extension: appending more words below is sufficient (sorting helps with
|
|
22
|
+
// reviews but is not required). Callers can also provide custom lists via
|
|
23
|
+
// generateUniqueName({ adjectives, nouns }).
|
|
24
24
|
export const ADJECTIVES = Object.freeze([
|
|
25
25
|
"agile",
|
|
26
26
|
"amber",
|
|
@@ -108,21 +108,25 @@ export function createFailOpenReporter(label, meter, now = Date.now) {
|
|
|
108
108
|
lastWarnAt = nowMs;
|
|
109
109
|
logger.warn("rate limiter unavailable, failing open", {
|
|
110
110
|
label,
|
|
111
|
-
// ioredis errors can carry the command args, i.e. the bucket key
|
|
111
|
+
// ioredis errors can carry the command args, i.e. the bucket key. scrubLogText masks an e-mail
|
|
112
|
+
// address in it; the client IP stays in the line as an operational datum.
|
|
112
113
|
error: scrubLogText(err instanceof Error ? err.message : String(err)),
|
|
113
114
|
});
|
|
114
115
|
};
|
|
115
116
|
}
|
|
117
|
+
const failClosedLogger = createFallbackLogger("rate-limit");
|
|
116
118
|
function defaultOnFailClosed(label) {
|
|
117
119
|
return (err) => {
|
|
118
120
|
// Loud by default — fail-closed for an unknown reason is an ops
|
|
119
121
|
// signal, not something to swallow silently. Production deploys
|
|
120
122
|
// override this with a structured logger; the default keeps the
|
|
121
123
|
// noise visible if no logger is wired.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
failClosedLogger.error("fail-closed (refusing request)", {
|
|
125
|
+
label,
|
|
126
|
+
// ioredis errors can carry the command args, i.e. the bucket key. scrubLogText masks an e-mail
|
|
127
|
+
// address in it; the client IP stays in the line as an operational datum.
|
|
128
|
+
error: scrubLogText(err instanceof Error ? err.message : String(err)),
|
|
129
|
+
});
|
|
126
130
|
};
|
|
127
131
|
}
|
|
128
132
|
function setRateLimitHeaders(c, decision) {
|
package/dist/redis/client.js
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// production defaults stay consistent and env-var wiring lives in one
|
|
9
9
|
// place. Tests / one-off scripts can still `new Redis()` directly.
|
|
10
10
|
//
|
|
11
|
-
// Split out of index.ts
|
|
12
|
-
//
|
|
11
|
+
// Split out of index.ts so pubsub-signal.ts can import this without a
|
|
12
|
+
// same-directory cycle back through the barrel.
|
|
13
13
|
import { Redis } from "ioredis";
|
|
14
14
|
import { scrubLogText } from "../logging/scrub.js";
|
|
15
15
|
import { readPositiveIntEnv } from "../utils/env-parse.js";
|
|
@@ -1,18 +1,19 @@
|
|
|
1
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
1
2
|
import { createRedisClient } from "./client.js";
|
|
2
3
|
// Redis emits 'error' on connection blips (reconnects transparently); an
|
|
3
|
-
// unhandled listener crashes the process
|
|
4
|
-
// class with BullMQ's internal client
|
|
5
|
-
function logConnectionError(
|
|
4
|
+
// unhandled listener crashes the process — job-runner hit the same
|
|
5
|
+
// class with BullMQ's internal client.
|
|
6
|
+
function logConnectionError(log, role) {
|
|
6
7
|
return (err) => {
|
|
7
|
-
|
|
8
|
-
console.error(`[kumiko:${label}] ${role} connection error:`, err.message);
|
|
8
|
+
log.error(`${role} connection error`, { error: err.message });
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
11
|
export function createRedisPubSubSignal(opts) {
|
|
12
|
+
const log = createFallbackLogger(`kumiko:${opts.label}`);
|
|
12
13
|
const publisher = createRedisClient(opts.redisUrl, opts.clientOptions);
|
|
13
14
|
const subscriber = createRedisClient(opts.redisUrl, opts.clientOptions);
|
|
14
|
-
publisher.on("error", logConnectionError(
|
|
15
|
-
subscriber.on("error", logConnectionError(
|
|
15
|
+
publisher.on("error", logConnectionError(log, "publisher"));
|
|
16
|
+
subscriber.on("error", logConnectionError(log, "subscriber"));
|
|
16
17
|
const listeners = new Set();
|
|
17
18
|
const reconnectListeners = new Set();
|
|
18
19
|
// Sole purpose: stop close() from racing the initial psubscribe (see
|
|
@@ -26,8 +27,7 @@ export function createRedisPubSubSignal(opts) {
|
|
|
26
27
|
payload = JSON.parse(message);
|
|
27
28
|
}
|
|
28
29
|
catch (err) {
|
|
29
|
-
|
|
30
|
-
console.error(`[kumiko:${opts.label}] dropping unparseable message on "${channel}":`, err);
|
|
30
|
+
log.error(`dropping unparseable message on "${channel}"`, { err });
|
|
31
31
|
// skip: unparseable payload already logged above, nothing to deliver
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
@@ -37,14 +37,14 @@ export function createRedisPubSubSignal(opts) {
|
|
|
37
37
|
// ponytail: psubscribe on one pattern rather than per-channel
|
|
38
38
|
// subscribe/unsubscribe — every instance receives every message that
|
|
39
39
|
// matches, filtered by the caller. That's strictly no worse than the
|
|
40
|
-
//
|
|
40
|
+
// per-instance-cursor model (which already read every event
|
|
41
41
|
// from Postgres in every process) and sidesteps the subscribe-ack/
|
|
42
42
|
// first-publish race a per-channel scheme would have. Revisit with
|
|
43
43
|
// per-channel subscribe only if fanout volume becomes the bottleneck.
|
|
44
44
|
//
|
|
45
45
|
// Awaited by close() below — quitting while this is still in flight (a
|
|
46
46
|
// shutdown landing immediately after construction, e.g. a test closing
|
|
47
|
-
// right after building the signal) makes ioredis
|
|
47
|
+
// right after building the signal) makes ioredis reject the queued
|
|
48
48
|
// psubscribe with "Connection is closed" instead of it completing
|
|
49
49
|
// cleanly first. Not caught here or logged when a close is already
|
|
50
50
|
// underway (`closed`), so an intentional shutdown never surfaces this
|
|
@@ -54,8 +54,7 @@ export function createRedisPubSubSignal(opts) {
|
|
|
54
54
|
// this rejection is the expected side effect, not an ops-visible error.
|
|
55
55
|
if (closed)
|
|
56
56
|
return;
|
|
57
|
-
|
|
58
|
-
console.error(`[kumiko:${opts.label}] psubscribe failed:`, err);
|
|
57
|
+
log.error("psubscribe failed", { err });
|
|
59
58
|
});
|
|
60
59
|
// ioredis re-sends the psubscribe itself (autoResubscribe) but emits no ack
|
|
61
60
|
// event; repeating it here queues behind that one on the same connection,
|
|
@@ -77,8 +76,7 @@ export function createRedisPubSubSignal(opts) {
|
|
|
77
76
|
// skip: a close() already underway intentionally quit the connection
|
|
78
77
|
if (closed)
|
|
79
78
|
return;
|
|
80
|
-
|
|
81
|
-
console.error(`[kumiko:${opts.label}] resubscribe failed:`, err);
|
|
79
|
+
log.error("resubscribe failed", { err });
|
|
82
80
|
});
|
|
83
81
|
});
|
|
84
82
|
return {
|
|
@@ -88,8 +86,7 @@ export function createRedisPubSubSignal(opts) {
|
|
|
88
86
|
// connections — this rejection is the expected side effect.
|
|
89
87
|
if (closed)
|
|
90
88
|
return;
|
|
91
|
-
|
|
92
|
-
console.error(`[kumiko:${opts.label}] publish failed:`, err);
|
|
89
|
+
log.error("publish failed", { err });
|
|
93
90
|
});
|
|
94
91
|
},
|
|
95
92
|
async publishConfirmed(channel, payload) {
|