@cosmicdrift/kumiko-framework 0.381.0 → 0.383.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/agents/examples/api-worker-split.md +57 -0
  2. package/agents/examples/record-detail-layout.md +20 -18
  3. package/agents/examples/screens-nav.md +3 -2
  4. package/agents/examples/writeform-section.md +2 -1
  5. package/dist/api/api-constants.d.ts +1 -0
  6. package/dist/api/api-constants.js +2 -0
  7. package/dist/api/auth-middleware.d.ts +1 -0
  8. package/dist/api/auth-middleware.js +17 -0
  9. package/dist/api/auth-routes.d.ts +1 -0
  10. package/dist/api/auth-routes.js +14 -4
  11. package/dist/api/client-error-route.d.ts +12 -0
  12. package/dist/api/client-error-route.js +113 -0
  13. package/dist/api/request-context.d.ts +6 -0
  14. package/dist/api/request-context.js +9 -0
  15. package/dist/api/routes.js +7 -4
  16. package/dist/api/server.js +11 -1
  17. package/dist/db/connection.d.ts +7 -0
  18. package/dist/db/connection.js +40 -0
  19. package/dist/db/index.d.ts +2 -1
  20. package/dist/db/index.js +2 -1
  21. package/dist/db/migrate-runner.js +4 -0
  22. package/dist/db/queries/backfill-pii.js +5 -1
  23. package/dist/db/queries/event-store.js +9 -2
  24. package/dist/db/statement-timeout.d.ts +2 -0
  25. package/dist/db/statement-timeout.js +18 -0
  26. package/dist/db/tenant-db.js +5 -12
  27. package/dist/engine/boot-validator/access-declarations.js +17 -18
  28. package/dist/engine/boot-validator/query-output-columns.js +1 -2
  29. package/dist/engine/boot-validator/screens.js +12 -16
  30. package/dist/engine/boot-validator/url-prefill-fields.js +1 -3
  31. package/dist/engine/build-config-feature-schema.js +4 -4
  32. package/dist/engine/entity-handlers.js +5 -7
  33. package/dist/engine/escape-hatch-declaration.d.ts +1 -1
  34. package/dist/engine/escape-hatch-declaration.js +5 -2
  35. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  36. package/dist/engine/escape-hatch-reason.js +35 -0
  37. package/dist/engine/extensions/escape-hatch-usage.js +2 -1
  38. package/dist/engine/feature-config-events-jobs.js +6 -4
  39. package/dist/engine/feature-ui-extensions.js +12 -6
  40. package/dist/engine/iban.d.ts +2 -0
  41. package/dist/engine/iban.js +19 -0
  42. package/dist/engine/index.d.ts +1 -1
  43. package/dist/engine/project-app-schema-for-roles.js +1 -1
  44. package/dist/engine/registry-validate.d.ts +1 -0
  45. package/dist/engine/registry-validate.js +8 -0
  46. package/dist/engine/registry.js +2 -1
  47. package/dist/engine/schema-builder.js +9 -0
  48. package/dist/engine/screen-helpers.d.ts +3 -3
  49. package/dist/engine/screen-helpers.js +1 -2
  50. package/dist/engine/steps/_read-source.js +2 -0
  51. package/dist/engine/types/index.d.ts +2 -2
  52. package/dist/engine/types/index.js +1 -1
  53. package/dist/engine/types/screen.d.ts +1 -1
  54. package/dist/engine/types/screen.js +1 -1
  55. package/dist/entrypoint/index.d.ts +1 -0
  56. package/dist/entrypoint/index.js +1 -0
  57. package/dist/event-store/event-store.d.ts +0 -3
  58. package/dist/event-store/event-store.js +1 -26
  59. package/dist/event-store/index.d.ts +1 -1
  60. package/dist/event-store/index.js +1 -1
  61. package/dist/event-store/snapshot.js +6 -1
  62. package/dist/files/file-ref-entity.d.ts +6 -6
  63. package/dist/files/file-ref-table.d.ts +6 -6
  64. package/dist/i18n/required-surface-keys.js +0 -1
  65. package/dist/jobs/job-runner.d.ts +1 -0
  66. package/dist/jobs/job-runner.js +74 -7
  67. package/dist/logging/index.d.ts +2 -1
  68. package/dist/logging/index.js +2 -1
  69. package/dist/logging/pino-logger.d.ts +1 -0
  70. package/dist/logging/pino-logger.js +35 -0
  71. package/dist/logging/request-log-fields.d.ts +1 -0
  72. package/dist/logging/request-log-fields.js +21 -0
  73. package/dist/logging/scrub.d.ts +2 -0
  74. package/dist/logging/scrub.js +21 -0
  75. package/dist/logging/utils.d.ts +1 -0
  76. package/dist/logging/utils.js +40 -6
  77. package/dist/migrations/pii-event-backfill.js +5 -1
  78. package/dist/observability/standard-metrics.d.ts +1 -0
  79. package/dist/observability/standard-metrics.js +9 -0
  80. package/dist/pipeline/msp-rebuild.js +5 -1
  81. package/dist/pipeline/projection-rebuild.js +5 -1
  82. package/dist/rate-limit/middleware.d.ts +8 -3
  83. package/dist/rate-limit/middleware.js +28 -10
  84. package/dist/rate-limit/resolver.js +10 -0
  85. package/dist/redis/client.d.ts +2 -0
  86. package/dist/redis/client.js +20 -0
  87. package/dist/redis/index.d.ts +1 -1
  88. package/dist/redis/index.js +1 -1
  89. package/dist/search/purge-subject.js +6 -8
  90. package/dist/search/reindex-entity.js +11 -10
  91. package/dist/ui-types/index.d.ts +1 -1
  92. package/dist/ui-types/index.js +1 -1
  93. package/package.json +3 -3
  94. package/src/changes.json +64 -0
@@ -11,6 +11,7 @@
11
11
  // Split out of index.ts (fw#2625) so pubsub-signal.ts can import this
12
12
  // without a same-directory cycle back through the barrel.
13
13
  import { Redis } from "ioredis";
14
+ import { scrubLogText } from "../logging/scrub.js";
14
15
  import { readPositiveIntEnv } from "../utils/env-parse.js";
15
16
  const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
16
17
  const DEFAULT_COMMAND_TIMEOUT_MS = 10_000;
@@ -26,6 +27,25 @@ export function createRedisClient(url, options = {}) {
26
27
  maxRetriesPerRequest,
27
28
  });
28
29
  }
30
+ // ioredis reconnects on its own and re-emits "error" for every failed attempt; only the
31
+ // transitions are worth a log line. A Redis outage degrades the app, it must not crash it.
32
+ export function logRedisConnectionState(redis, logger) {
33
+ let connectionLost = false;
34
+ redis.on("error", (err) => {
35
+ // skip: only the transition is logged, ioredis repeats the error on every retry
36
+ if (connectionLost)
37
+ return;
38
+ connectionLost = true;
39
+ logger.warn("redis connection lost, reconnecting", { error: scrubLogText(err.message) });
40
+ });
41
+ redis.on("ready", () => {
42
+ // skip: the initial connect is not a recovery
43
+ if (!connectionLost)
44
+ return;
45
+ connectionLost = false;
46
+ logger.info("redis recovered");
47
+ });
48
+ }
29
49
  // Redis Pub/Sub ignores the DB index and ioredis keyPrefix, so apps or environments sharing one
30
50
  // Redis instance would see each other's SSE and toggle-sync traffic. An optional prefix
31
51
  // namespaces those channels; unset keeps the unprefixed channel names.
@@ -3,6 +3,6 @@ export { createCacheSyncBusOverSignal, createDefaultCacheSyncBus, createLocalCac
3
3
  export type { TenantConfigSyncMessage, TierAssignmentSyncMessage } from "./cache-sync-topics.ts";
4
4
  export { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage, isTierAssignmentSyncMessage, } from "./cache-sync-topics.ts";
5
5
  export type { RedisClientOptions } from "./client.ts";
6
- export { createRedisClient, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.ts";
6
+ export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.ts";
7
7
  export type { PubSubSignal, PubSubSignalOptions } from "./pubsub-signal.ts";
8
8
  export { createRedisPubSubSignal } from "./pubsub-signal.ts";
@@ -1,4 +1,4 @@
1
1
  export { createCacheSyncBusOverSignal, createDefaultCacheSyncBus, createLocalCacheSyncBus, createRedisCacheSyncBus, resolveCacheSyncBus, } from "./cache-sync-bus.js";
2
2
  export { CACHE_SYNC_TOPICS, isTenantConfigSyncMessage, isTierAssignmentSyncMessage, } from "./cache-sync-topics.js";
3
- export { createRedisClient, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.js";
3
+ export { createRedisClient, logRedisConnectionState, redisChannelPrefixFromEnv, redisClientOptionsFromEnv, } from "./client.js";
4
4
  export { createRedisPubSubSignal } from "./pubsub-signal.js";
@@ -104,22 +104,20 @@ function ownershipPredicates(entity, searchableFields, entityName, subject, next
104
104
  }
105
105
  }
106
106
  }
107
- // ponytail: LIMIT/OFFSET, not a keyset cursor — mirrors reindexEntity's same
108
- // tradeoff (id type varies uuid/serial across entities). A tenant-destroy
109
- // purge is a one-time sweep, not a hot path.
110
107
  const PURGE_BATCH_SIZE = 500;
111
108
  async function collectMatchingRowsForEntity(db, tableName, whereSql, params) {
112
109
  const rows = [];
113
- let offset = 0;
110
+ let lastId;
114
111
  for (;;) {
115
- const offsetN = params.length + 1;
116
- const page = await executeRawQueryRead(db, `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE ${whereSql}
112
+ // Keyset, not OFFSET: rows deleted or changed while the loop runs must not shift the window.
113
+ const afterLastId = lastId === undefined ? "" : ` AND ${quoteIdent("id")} > $${params.length + 1}`;
114
+ const page = await executeRawQueryRead(db, `SELECT id, tenant_id FROM ${quoteIdent(tableName)} WHERE (${whereSql})${afterLastId}
117
115
  ORDER BY ${quoteIdent("id")} ASC
118
- LIMIT ${PURGE_BATCH_SIZE} OFFSET $${offsetN}`, [...params, offset]);
116
+ LIMIT ${PURGE_BATCH_SIZE}`, lastId === undefined ? params : [...params, lastId]);
119
117
  if (page.length === 0)
120
118
  break;
121
119
  rows.push(...page);
122
- offset += page.length;
120
+ lastId = page[page.length - 1]?.id;
123
121
  if (page.length < PURGE_BATCH_SIZE)
124
122
  break;
125
123
  }
@@ -6,6 +6,7 @@
6
6
  // existing row and indexes it, same as a live write would.
7
7
  import { resolveTableName } from "../db/entity-table-meta.js";
8
8
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
9
+ import { withoutStatementTimeout } from "../db/statement-timeout.js";
9
10
  import { buildSearchDocument, decryptSearchableSubjectFields, hasErasedSearchableSubjectField, } from "../pipeline/system-hooks.js";
10
11
  import { toSnakeCase } from "../utils/case.js";
11
12
  function quoteIdent(name) {
@@ -25,7 +26,10 @@ function rowToState(row, fieldNames) {
25
26
  }
26
27
  return state;
27
28
  }
28
- export async function reindexEntity(db, registry, searchAdapter, entityName, tenantId, options = {}) {
29
+ export function reindexEntity(db, registry, searchAdapter, entityName, tenantId, options = {}) {
30
+ return withoutStatementTimeout(db, (unboundedDb) => scanAndIndexEntity(unboundedDb, registry, searchAdapter, entityName, tenantId, options));
31
+ }
32
+ async function scanAndIndexEntity(db, registry, searchAdapter, entityName, tenantId, options) {
29
33
  const entity = registry.getEntity(entityName);
30
34
  if (!entity) {
31
35
  throw new Error(`reindexEntity: unknown entity "${entityName}"`);
@@ -49,7 +53,7 @@ export async function reindexEntity(db, registry, searchAdapter, entityName, ten
49
53
  wouldIndexRows: 0,
50
54
  failures: [],
51
55
  };
52
- let offset = 0;
56
+ let lastId;
53
57
  // Whether a searchable field lacks a matching read-table column depends
54
58
  // only on the table's column set (rowToState/Object.hasOwn), not on any
55
59
  // particular row's data — every row from the same SELECT * has the same
@@ -59,15 +63,12 @@ export async function reindexEntity(db, registry, searchAdapter, entityName, ten
59
63
  // entries, and the real schema problem is buried in "x failed").
60
64
  let uncheckedSchema = true;
61
65
  for (;;) {
62
- // ponytail: LIMIT/OFFSET, not a keyset cursor — id can be uuid or
63
- // serial depending on the entity, and a uniform cursor comparison
64
- // across both types needs a text cast that breaks integer ordering.
65
- // This is a one-time backfill over existing rows, not a live hot path;
66
- // switch to keyset if it ever needs to run against a churning table.
66
+ // Keyset, not OFFSET: rows deleted or changed while the loop runs must not shift the window.
67
+ const afterLastId = lastId === undefined ? "" : `AND ${quoteIdent("id")} > $3`;
67
68
  const rows = await executeRawQueryRead(db, `SELECT * FROM ${quoteIdent(tableName)}
68
- WHERE ${quoteIdent("tenant_id")} = $1 ${deletedFilter}
69
+ WHERE ${quoteIdent("tenant_id")} = $1 ${deletedFilter} ${afterLastId}
69
70
  ORDER BY ${quoteIdent("id")} ASC
70
- LIMIT $2 OFFSET $3`, [tenantId, batchSize, offset]);
71
+ LIMIT $2`, lastId === undefined ? [tenantId, batchSize] : [tenantId, batchSize, lastId]);
71
72
  if (rows.length === 0)
72
73
  break;
73
74
  if (uncheckedSchema) {
@@ -130,7 +131,7 @@ export async function reindexEntity(db, registry, searchAdapter, entityName, ten
130
131
  else if (options.dryRun) {
131
132
  result.wouldIndexRows += docs.length;
132
133
  }
133
- offset += rows.length;
134
+ lastId = rows[rows.length - 1]?.["id"];
134
135
  if (rows.length < batchSize)
135
136
  break;
136
137
  }
@@ -12,7 +12,7 @@ export type { AccessRule, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPe
12
12
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.ts";
13
13
  export type { IconKey, NavDefinition, NavIconKey } from "../engine/types/nav.ts";
14
14
  export type { ActionFormFooterAction, ActionFormLoad, ActionFormRedirect, ActionFormScreenDefinition, ActionFormSuccessFact, ActionFormSuccessLink, ConfigEditScreenDefinition, CustomScreenDefinition, CustomScreenRoute, DashboardChartKind, DashboardChartMarkerKind, DashboardChartPanel, DashboardChartRangeOption, DashboardChartRanges, DashboardChartTone, DashboardCurrencyFormat, DashboardCustomPanel, DashboardDateParam, DashboardFeedPanel, DashboardFilterDefinition, DashboardI18nText, DashboardListPanel, DashboardMoneyParam, DashboardPanelDefinition, DashboardPanelEmptyState, DashboardPanelGate, DashboardPanelQueryOptions, DashboardPanelSpan, DashboardPanelVisibility, DashboardProgressListPanel, DashboardProgressTone, DashboardRecordBinding, DashboardRefresh, DashboardRelativeTimeFormat, DashboardRelativeTimeParam, DashboardScopeDefinition, DashboardScreenDefinition, DashboardScreenPanel, DashboardStatGroupPanel, DashboardStatPanel, DashboardText, DashboardTextBlock, DashboardTextPanel, DashboardTextParam, DashboardTimeRangeDefinition, DashboardValueFormat, EditExtensionSection, EditFieldPrefill, EditFieldSpec, EditFieldsSection, EditLayout, EditRelatedListSection, EditSectionSpec, EditWriteFormSection, EmbeddedListEditSpec, EntityEditAction, EntityEditActionPlacement, EntityEditScreenDefinition, EntityListExpandableRow, EntityListFacetConfig, EntityListFacetExtraOption, EntityListScreenDefinition, EntityWriteExcludedFields, FieldAffix, FieldCondition, FieldFillAction, FieldIconKey, FieldRenderer, FormWidth, ListBadgeTone, ListColumnDisplay, ListColumnSpec, ListEmptyStateSpec, ListFacetSpec, ListSortSpec, MetricNavigate, MetricSpec, PlatformComponent, ProjectionDetailScreenDefinition, ProjectionListScreenDefinition, RecordHeaderSubtitlePart, RelatedListGroupBy, RelatedListRowTone, RelatedListToolbarAction, RowAction, RowActionClientAction, RowActionDisplay, RowActionDrawer, RowActionMode, RowActionNavigate, RowActionWriteHandler, RowFieldExtractor, ScreenDefinition, ScreenFilter, ScreenFilterOp, ScreenSlots, SecretMintConfirmStep, SecretMintScreenDefinition, SecretReveal, SecretRevealField, SecretsEditScreenDefinition, SecretsEditSection, ToolbarAction, } from "../engine/types/screen.ts";
15
- export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.ts";
15
+ export { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.ts";
16
16
  export type { TargetRef } from "../engine/types/target-ref.ts";
17
17
  export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node.ts";
18
18
  export type { WorkspaceDefinition } from "../engine/types/workspace.ts";
@@ -25,7 +25,7 @@ export { applyFormDerivations, isFormDerivationInputMode, isUnusedFallbackOperan
25
25
  export { parseRefTarget } from "../engine/parse-ref-target.js";
26
26
  export { ACTION_FORM_PSEUDO_ENTITY, availableSelectOptions, evalFieldCondition, explicitListScreenId, findUnavailableSelectOptions, isExtensionEditSection, isFieldsEditSection, isFormatSpec, isRecordLocked, isUnchangedValue, isWriteFormEditSection, normalizeEditField, normalizeListColumn, resolveNavParentScreen, sectionFieldSpecs, } from "../engine/screen-helpers.js";
27
27
  export { isOpenToAllGranted, isUiAccessGranted } from "../engine/types/handlers.js";
28
- export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
28
+ export { relatedListGroupHeaderLabel, relatedListGroupKey, } from "../engine/types/screen.js";
29
29
  export { ACTION_FORM_ENTITY, handlerFieldLabelKey, handlerTitleKey, PROJECTION_DETAIL_ENTITY, WRITE_FORM_SECTION_ENTITY, } from "../i18n/required-surface-keys.js";
30
30
  export { CARD_META_MAX, cardColumnRoles, cardFileDisplayKind } from "./card-column-roles.js";
31
31
  export { formalLocaleTag } from "./formality.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.381.0",
3
+ "version": "0.383.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>",
@@ -199,7 +199,7 @@
199
199
  "./package.json": "./package.json"
200
200
  },
201
201
  "dependencies": {
202
- "@cosmicdrift/kumiko-types": "0.381.0",
202
+ "@cosmicdrift/kumiko-types": "0.383.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-testing": "0.381.0",
218
+ "@cosmicdrift/kumiko-testing": "0.383.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
package/src/changes.json CHANGED
@@ -1,4 +1,68 @@
1
1
  [
2
+ {
3
+ "version": "0.383.0",
4
+ "type": "breaking",
5
+ "title": "Placeholder reasons are rejected at boot and at runtime",
6
+ "detail": "An empty, whitespace-only or placeholder reason (\"todo\", \"tbd\", \"fixme\", \"legacy\", \"temp\", \"wip\", \"n/a\", \"none\", \"xxx\", a \"todo:\" / \"fixme:\" / \"tbd:\" prefix, or the migrate-cross-tenant codemod marker) now fails boot for `openToAll.reason` and `escapeHatch.reason`, and throws at call time for `ctx.systemDb.unsafeRaw(reason)`, `acknowledgeCrossTenant(reason)`, `unsafeAllTenants: { reason }` on read steps and `declareEscapeHatch({ reason })`, which is no longer a no-op. The Escape-Hatch-Declared and Open-To-All-Reason guards that checked this statically are removed.",
7
+ "migration": "Replace every placeholder reason with the real justification (why this access is needed and on whose right). Reasons held in imported constants were never judged by the guards and surface now, at boot or on first use."
8
+ },
9
+ {
10
+ "version": "0.383.0",
11
+ "type": "breaking",
12
+ "title": "loadAllEventsByType and LOAD_ALL_EVENTS_ROW_LIMIT are removed",
13
+ "detail": "loadAllEventsByType buffered every event of an aggregate type in memory and had no production caller. It is removed together with LOAD_ALL_EVENTS_ROW_LIMIT and the Loadall-Events guard.",
14
+ "migration": "Use `await Array.fromAsync(streamAllEventsByType(db, aggregateType))`, or iterate the stream with `for await` to stay memory-bounded. The stream is ordered by event id; loadAllEventsByType ordered by (createdAt, id)."
15
+ },
16
+ {
17
+ "version": "0.383.0",
18
+ "type": "breaking",
19
+ "title": "metricField is removed from the screen type exports",
20
+ "detail": "metricField is removed from the engine types and ui-types exports because MetricSpec is always an object now.",
21
+ "migration": "Replace `metricField(metric)` with `metric.field`."
22
+ },
23
+ {
24
+ "version": "0.383.0",
25
+ "type": "improvement",
26
+ "title": "Text field format \"iban\"",
27
+ "detail": "createTextField({ format: \"iban\" }) validates the value server-side (spaces ignored, case-insensitive, country and check digits shape plus mod-97) and reports invalid_format with format iban, which the form maps to errors.validation.invalid_format.iban (texts in en, de, es, fr, it). The value is stored as typed."
28
+ },
29
+ {
30
+ "version": "0.383.0",
31
+ "type": "improvement",
32
+ "title": "Process logger, request context fields and redaction",
33
+ "detail": "runProdApp and runWorkerApp install a pino process logger (JSON in production) that every createFallbackLogger without an explicit logger resolves lazily, with a component field. Log lines carry requestId, correlationId, tenantId, userId, feature and handler from the request context; jobs carry tenantId and userId too. Keys such as password, token, secret, apiKey, authorization, cookie, set-cookie, x-api-key, sessionToken, privateKey, credentials, otp, iban and email are redacted to [redacted] up to depth 2. New scrubLogText masks e-mail addresses, JWTs, bearer tokens and Postgres key details in server-fault, job-failure and client-error logs."
34
+ },
35
+ {
36
+ "version": "0.383.0",
37
+ "type": "improvement",
38
+ "title": "POST /api/client-errors collects browser error reports",
39
+ "detail": "Anonymous endpoint with a strict body (errorId, kind, message, stack, screen, path, buildVersion), an 8 KB cap (413), 400 without echo, a per-IP limit of 10 per minute (429, in memory per process when Redis is absent) and 204 on success. The report is logged as a scrubbed warning; the query and hash of the path are cut. With a session the report carries tenantId and userId."
40
+ },
41
+ {
42
+ "version": "0.383.0",
43
+ "type": "improvement",
44
+ "title": "Global IP and http-route limits fail open when Redis is down",
45
+ "detail": "Behavior change: previously a Redis error answered 503 on these two limits. The l1-global-ip and http-route limits let the request through on a Redis error instead of answering 503, count it in kumiko_rate_limit_fail_open_total{label} and log a warning at most every 30 s. The l2-auth-endpoints and login/MFA limits stay fail-closed. A Redis client that is not ready fails fast instead of waiting for the command timeout. Redis connection loss and recovery are logged once per state change."
46
+ },
47
+ {
48
+ "version": "0.383.0",
49
+ "type": "breaking",
50
+ "title": "globalIpRateLimit and httpRouteRateLimit option onFailClosed is now onFailOpen",
51
+ "detail": "The option is renamed because both limits now let the request through on a Redis error; the callback receives the error and is throttled to one warning per 30 s. Both limits also accept a meter for kumiko_rate_limit_fail_open_total.",
52
+ "migration": "rename onFailClosed to onFailOpen in globalIpRateLimit and httpRouteRateLimit options; the callback no longer implies a 503, the request continues"
53
+ },
54
+ {
55
+ "version": "0.383.0",
56
+ "type": "improvement",
57
+ "title": "Prod and worker pools cap statements at 60 s and idle transactions at 300 s",
58
+ "detail": "Behavior change, not an API break. runProdApp and runWorkerApp now open their Postgres pools with statement_timeout 60 s and idle_in_transaction_session_timeout 300 s (handlers that await external calls inside a transaction are not killed early, leaked transactions still end), so one runaway query or a transaction left open no longer holds a connection indefinitely. lock_timeout stays off. Opt out per limit by setting the variable to 0, or change it, with DATABASE_STATEMENT_TIMEOUT_MS, DATABASE_IDLE_IN_TRANSACTION_TIMEOUT_MS and DATABASE_LOCK_TIMEOUT_MS (for example DATABASE_STATEMENT_TIMEOUT_MS=0); createDbConnection takes statementTimeoutMs, idleInTransactionSessionTimeoutMs and lockTimeoutMs. Migrations, projection and multi-stream rebuilds, search reindex, PII backfills and the CREATE INDEX CONCURRENTLY helpers run without the statement cap (withoutStatementTimeout). A custom long-running statement on the app pool now needs the same helper or a higher limit."
59
+ },
60
+ {
61
+ "version": "0.383.0",
62
+ "type": "fix",
63
+ "title": "Reindex and subject purge scan by keyset instead of OFFSET",
64
+ "detail": "reindexEntity and the subject purge page with WHERE id > last ORDER BY id LIMIT n, so rows deleted or changed while the scan runs no longer make it skip later rows."
65
+ },
2
66
  {
3
67
  "version": "0.380.0",
4
68
  "type": "breaking",