@cosmicdrift/kumiko-framework 0.383.1 → 0.384.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.
@@ -239,7 +239,7 @@ import {
239
239
  wireCustomFieldsFor,
240
240
  } from "@cosmicdrift/kumiko-bundled-features/custom-fields";
241
241
  import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
242
- import { createEntity, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
242
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
243
243
  import { z } from "zod";
244
244
 
245
245
  // --- Entity ---
@@ -254,7 +254,12 @@ import { z } from "zod";
254
254
  export const propertyEntity = createEntity({
255
255
  table: "read_sample_cf_properties",
256
256
  fields: {
257
- name: { type: "text", required: true, maxLength: 200 },
257
+ name: createTextField({
258
+ required: true,
259
+ maxLength: 200,
260
+ personal: false,
261
+ reason: "property_name_is_business_data",
262
+ }),
258
263
  customFields: customFieldsField(),
259
264
  },
260
265
  });
@@ -274,7 +279,12 @@ export const propertyFeature = defineFeature("property-management", (r) => {
274
279
  name: "property",
275
280
  table: "read_sample_cf_properties",
276
281
  fields: {
277
- name: { type: "text", required: true, maxLength: 200 },
282
+ name: createTextField({
283
+ required: true,
284
+ maxLength: 200,
285
+ personal: false,
286
+ reason: "property_name_is_business_data",
287
+ }),
278
288
  customFields: { type: "jsonb" },
279
289
  },
280
290
  });
@@ -36,7 +36,7 @@ No dedicated automated tests in this folder yet — use it as a composition samp
36
36
  // - nav → ID/Label/Screen-Card
37
37
  // Plus optionalRequires + metric als JSON-Dump-Fallback.
38
38
 
39
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
39
+ import { createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
40
40
  import { z } from "zod";
41
41
 
42
42
  defineFeature("designerDemo", (r) => {
@@ -45,7 +45,11 @@ defineFeature("designerDemo", (r) => {
45
45
  r.entity({
46
46
  name: "task",
47
47
  fields: {
48
- title: { type: "text", required: true },
48
+ title: createTextField({
49
+ required: true,
50
+ personal: false,
51
+ reason: "demo_task_title_is_not_personal_data",
52
+ }),
49
53
  done: { type: "boolean", default: false },
50
54
  priority: { type: "select", options: ["low", "medium", "high"], default: "medium" },
51
55
  },
package/dist/db/query.js CHANGED
@@ -147,7 +147,17 @@ function assertNotTenantScoped(db, fnName) {
147
147
  "explicit cross-tenant op, or use a scoped TenantDb method.");
148
148
  }
149
149
  }
150
- const WHERE_OPERATOR_KEYS = ["gt", "gte", "lt", "lte", "ne", "in", "like", "jsonText"];
150
+ const WHERE_OPERATOR_KEYS = [
151
+ "gt",
152
+ "gte",
153
+ "lt",
154
+ "lte",
155
+ "ne",
156
+ "in",
157
+ "notIn",
158
+ "like",
159
+ "jsonText",
160
+ ];
151
161
  const _whereOperatorKeysExhaustive = true;
152
162
  void _whereOperatorKeysExhaustive;
153
163
  function isJsonTextMatchList(value) {
@@ -664,6 +674,24 @@ function buildWhereClause(info, where, startIndex, qualifier) {
664
674
  conditions.push(`${ref(col)} IN (${parts.join(", ")})`);
665
675
  }
666
676
  }
677
+ const notInVal = value.notIn;
678
+ if (Array.isArray(notInVal)) {
679
+ if (pgType === "jsonb") {
680
+ throw new Error(`where: notIn is not supported on jsonb column "${col}"`);
681
+ }
682
+ const parts = [];
683
+ for (const v of notInVal) {
684
+ const p = prepareValue(v, pgType);
685
+ if (p.kind === "literal") {
686
+ parts.push(p.literal);
687
+ }
688
+ else {
689
+ parts.push(`$${idx++}${p.sql}`);
690
+ values.push(p.bound);
691
+ }
692
+ }
693
+ conditions.push(parts.length === 0 ? "TRUE" : `${ref(col)} NOT IN (${parts.join(", ")})`);
694
+ }
667
695
  }
668
696
  else if (pgType === "jsonb" && isJsonbScalar(value)) {
669
697
  conditions.push(jsonbContainsAny(col, [value]));
@@ -1,2 +1,3 @@
1
1
  import type { FeatureDefinition } from "../types/index.ts";
2
2
  export declare function validateI18nSurfaceKeys(features: readonly FeatureDefinition[]): void;
3
+ export declare function validateTranslationLocaleConsistency(features: readonly FeatureDefinition[]): void;
@@ -1,4 +1,4 @@
1
- import { buildEffectiveTranslationKeys, featureHasI18nSurface, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
1
+ import { buildEffectiveTranslationKeys, featureHasI18nSurface, findTranslationLocaleGaps, requiredKeysFromFeature, requiredKeysFromNav, requiredKeysFromScreen, requiredKeysFromWorkspace, screenTitleKey, } from "../../i18n/required-surface-keys.js";
2
2
  import { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, } from "../build-config-feature-schema.js";
3
3
  import { EXTENSION_SELECTOR_HINT_KEY } from "../extension-selector-plugins.js";
4
4
  import { createRegistry } from "../registry.js";
@@ -86,3 +86,10 @@ export function validateI18nSurfaceKeys(features) {
86
86
  }
87
87
  }
88
88
  }
89
+ export function validateTranslationLocaleConsistency(features) {
90
+ const gaps = findTranslationLocaleGaps(features);
91
+ if (gaps.length > 0) {
92
+ const lines = gaps.map((gap) => `[i18n] Feature "${gap.featureName}": translation key "${gap.key}" is missing locale(s) ${gap.missingLocales.join(", ")}.`);
93
+ throw new Error(`${lines.join("\n")}\nEvery locale a feature uses must be maintained for every one of its translation keys; otherwise move that locale into a locale package.`);
94
+ }
95
+ }
@@ -15,7 +15,7 @@ import { validateEntityListScreens } from "./entity-list-screens.js";
15
15
  import { validateGdprStoragePersistence } from "./gdpr-storage.js";
16
16
  import { validateGlobalTenancyEntities } from "./global-tenancy.js";
17
17
  import { validateHandlerFieldReferences } from "./handler-field-references.js";
18
- import { validateI18nSurfaceKeys } from "./i18n-keys.js";
18
+ import { validateI18nSurfaceKeys, validateTranslationLocaleConsistency } from "./i18n-keys.js";
19
19
  import { collectKnownRoles, collectNavQns, collectWriteHandlerQns, validateNavCycles, validateNavs, warnOnNavAccessInversion, warnOnUnreachableNavScreens, } from "./nav.js";
20
20
  import { collectClaimKeys, validateOwnershipRules } from "./ownership.js";
21
21
  import { validateParentRefs } from "./parent-ref.js";
@@ -218,6 +218,7 @@ export function validateBoot(rawFeatures, options) {
218
218
  }
219
219
  validateDefaultWorkspaceUniqueness(allWorkspaceQns);
220
220
  validateI18nSurfaceKeys(features);
221
+ validateTranslationLocaleConsistency(features);
221
222
  validateEntityListScreens(features);
222
223
  validateSubmitPrefilledHiddenFields(features);
223
224
  validateCardMetaOverflow(features);
@@ -1,6 +1,4 @@
1
- import { createFallbackLogger } from "../../logging/utils.js";
2
1
  import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./entity-handler.js";
3
- const log = createFallbackLogger("kumiko:boot");
4
2
  // Framework-managed Timestamp-Spalten — dürfen als retention.reference
5
3
  // genutzt werden auch wenn nicht in entity.fields deklariert.
6
4
  const FRAMEWORK_TIMESTAMP_FIELDS = new Set([
@@ -146,6 +144,12 @@ export function validatePiiAndRetention(feature) {
146
144
  if (annotCount > 1) {
147
145
  throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has multiple subject-key annotations (personal: "self" / "tenant" / { of: "<field>" }). Pick one — each field belongs to exactly one subject.`);
148
146
  }
147
+ if ((field.type === "text" || field.type === "longText") &&
148
+ annotCount === 0 &&
149
+ !annot.allowPlaintext &&
150
+ !annot.subjectRef) {
151
+ throw new Error(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Build it with createTextField / createLongTextField and { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; false additionally needs { reason: "..." } stating why the value is not personal data.`);
152
+ }
149
153
  // lookupable (#818): nur text + nur MIT Subject-Annotation. Ohne
150
154
  // Subject bleibt der Wert eh Klartext — der Blind-Index wäre eine
151
155
  // zweite (deterministische!) Kopie ohne Nutzen.
@@ -247,11 +251,6 @@ export function validatePiiAndRetention(feature) {
247
251
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
248
252
  console.warn(`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`);
249
253
  }
250
- else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
251
- // The factories refuse a missing stance, but a raw literal such as
252
- // { type: "text" } still compiles and reaches the entity unchecked.
253
- log.warn(`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } via createTextField / createLongTextField; "false" additionally needs { reason: "..." } stating why the value is not personal data.`);
254
- }
255
254
  }
256
255
  }
257
256
  // --- Entity-level retention ---
@@ -456,7 +456,7 @@ function assertValidScreenMultilineRows(featureName, shortId, screen) {
456
456
  // `derived`, `totals`, `totalsMatch`) — a top-level `Array.isArray`/plain-
457
457
  // object check alone would let a function survive one level deep.
458
458
  //
459
- // Cast at the exit `as FieldDefinition`: type-system-wise our out-map only
459
+ // Cast at the exit `as EntityFieldDefinition`: type-system-wise our out-map only
460
460
  // satisfies the discriminated union with an unnarrowed `type` string — the
461
461
  // cast bridges the variant inference TS can't recompute from a generic
462
462
  // Record.
@@ -1,8 +1,8 @@
1
1
  import type { EntityTenancy } from "@cosmicdrift/kumiko-types/tenancy-brand";
2
- import type { BigIntFieldDef, BooleanFieldDef, DateFieldDef, DecimalFieldDef, DerivedFieldDef, EmbeddedFieldDef, EntityDefinition, EntityIndexDef, FieldsMap, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, ResolvedPiiFlags, RetentionDef, SelectFieldDef, TextFieldDef, TimestampFieldDef, TzFieldDef } from "./types/index.ts";
2
+ import type { BigIntFieldDef, BooleanFieldDef, DateFieldDef, DecimalFieldDef, DerivedFieldDef, EmbeddedFieldDef, EntityDefinition, EntityIndexDef, EntityLongTextFieldDef, EntityTextFieldDef, FieldsMap, FileFieldDef, FilesFieldDef, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, ResolvedPiiFlags, RetentionDef, SelectFieldDef, TextFieldDef, TimestampFieldDef, TzFieldDef } from "./types/index.ts";
3
3
  export declare function createTextField<R extends true | false = false>(overrides: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotations & {
4
4
  required?: R;
5
- }): TextFieldDef & {
5
+ }): EntityTextFieldDef & {
6
6
  required: R;
7
7
  };
8
8
  /**
@@ -25,7 +25,7 @@ export declare function createTextField<R extends true | false = false>(override
25
25
  export declare function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef;
26
26
  export declare function createLongTextField<R extends true | false = false>(overrides: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> & PersonalAnnotationsLongText & {
27
27
  required?: R;
28
- }): LongTextFieldDef & {
28
+ }): EntityLongTextFieldDef & {
29
29
  required: R;
30
30
  };
31
31
  export declare function createBooleanField<R extends true | false = false>(overrides?: Partial<Omit<BooleanFieldDef, "type" | "required">> & {
@@ -6,7 +6,7 @@ export type { KumikoEntityTypeMap, KumikoEventTypeMap, KumikoHandlerPayloadMap,
6
6
  export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsMap, } from "@cosmicdrift/kumiko-types/extension-options-map";
7
7
  export type { BootCheckContext, BootCheckFn, BootOptionName, EntityObjectDefinition, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, HookDefinition, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StackBootRequirement, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
8
8
  export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
9
- export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityEventMigration, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
9
+ export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityEventMigration, EntityFieldDefinition, EntityIndexDef, EntityLongTextFieldDef, EntityTextFieldDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, ResolvedPiiStance, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
10
10
  export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobDeferral, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadBucketNormalization, PayloadRateLimitOption, PreparedWriteEvent, ProviderPluginContext, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitOptions, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
11
11
  export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, declaredRateLimitOptions, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isRateLimitOption, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
12
12
  export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
@@ -1,72 +1,12 @@
1
1
  export { FILE_REF_ENTITY_NAME } from "./types.ts";
2
2
  export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
3
- readonly storageKey: {
4
- readonly type: "text";
5
- readonly description?: string;
6
- readonly maxLength?: number;
7
- readonly minLength?: number;
8
- readonly autoComplete?: string;
9
- readonly required?: boolean;
10
- readonly searchable?: boolean;
11
- readonly sortable?: boolean;
12
- readonly filterable?: boolean;
13
- readonly encrypted?: boolean;
14
- readonly sensitive?: boolean;
15
- readonly writeOnly?: boolean;
16
- readonly readAsInstruction?: boolean;
17
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
18
- readonly default?: string;
19
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
20
- readonly multiline?: boolean | {
21
- readonly rows?: number;
22
- };
23
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
3
+ readonly storageKey: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
24
4
  required: true;
25
5
  };
26
- readonly fileName: {
27
- readonly type: "text";
28
- readonly description?: string;
29
- readonly maxLength?: number;
30
- readonly minLength?: number;
31
- readonly autoComplete?: string;
32
- readonly required?: boolean;
33
- readonly searchable?: boolean;
34
- readonly sortable?: boolean;
35
- readonly filterable?: boolean;
36
- readonly encrypted?: boolean;
37
- readonly sensitive?: boolean;
38
- readonly writeOnly?: boolean;
39
- readonly readAsInstruction?: boolean;
40
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
41
- readonly default?: string;
42
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
43
- readonly multiline?: boolean | {
44
- readonly rows?: number;
45
- };
46
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
6
+ readonly fileName: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
47
7
  required: true;
48
8
  };
49
- readonly mimeType: {
50
- readonly type: "text";
51
- readonly description?: string;
52
- readonly maxLength?: number;
53
- readonly minLength?: number;
54
- readonly autoComplete?: string;
55
- readonly required?: boolean;
56
- readonly searchable?: boolean;
57
- readonly sortable?: boolean;
58
- readonly filterable?: boolean;
59
- readonly encrypted?: boolean;
60
- readonly sensitive?: boolean;
61
- readonly writeOnly?: boolean;
62
- readonly readAsInstruction?: boolean;
63
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
64
- readonly default?: string;
65
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
66
- readonly multiline?: boolean | {
67
- readonly rows?: number;
68
- };
69
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
9
+ readonly mimeType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
70
10
  required: true;
71
11
  };
72
12
  readonly size: {
@@ -81,73 +21,13 @@ export declare const fileRefEntity: import("@cosmicdrift/kumiko-types/fields").E
81
21
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
82
22
  required: true;
83
23
  };
84
- readonly entityType: {
85
- readonly type: "text";
86
- readonly description?: string;
87
- readonly maxLength?: number;
88
- readonly minLength?: number;
89
- readonly autoComplete?: string;
90
- readonly required?: boolean;
91
- readonly searchable?: boolean;
92
- readonly sortable?: boolean;
93
- readonly filterable?: boolean;
94
- readonly encrypted?: boolean;
95
- readonly sensitive?: boolean;
96
- readonly writeOnly?: boolean;
97
- readonly readAsInstruction?: boolean;
98
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
99
- readonly default?: string;
100
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
101
- readonly multiline?: boolean | {
102
- readonly rows?: number;
103
- };
104
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
24
+ readonly entityType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
105
25
  required: false;
106
26
  };
107
- readonly entityId: {
108
- readonly type: "text";
109
- readonly description?: string;
110
- readonly maxLength?: number;
111
- readonly minLength?: number;
112
- readonly autoComplete?: string;
113
- readonly required?: boolean;
114
- readonly searchable?: boolean;
115
- readonly sortable?: boolean;
116
- readonly filterable?: boolean;
117
- readonly encrypted?: boolean;
118
- readonly sensitive?: boolean;
119
- readonly writeOnly?: boolean;
120
- readonly readAsInstruction?: boolean;
121
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
122
- readonly default?: string;
123
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
124
- readonly multiline?: boolean | {
125
- readonly rows?: number;
126
- };
127
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
27
+ readonly entityId: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
128
28
  required: false;
129
29
  };
130
- readonly fieldName: {
131
- readonly type: "text";
132
- readonly description?: string;
133
- readonly maxLength?: number;
134
- readonly minLength?: number;
135
- readonly autoComplete?: string;
136
- readonly required?: boolean;
137
- readonly searchable?: boolean;
138
- readonly sortable?: boolean;
139
- readonly filterable?: boolean;
140
- readonly encrypted?: boolean;
141
- readonly sensitive?: boolean;
142
- readonly writeOnly?: boolean;
143
- readonly readAsInstruction?: boolean;
144
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
145
- readonly default?: string;
146
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
147
- readonly multiline?: boolean | {
148
- readonly rows?: number;
149
- };
150
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
30
+ readonly fieldName: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
151
31
  required: false;
152
32
  };
153
33
  }, "tenant">;
@@ -1,71 +1,11 @@
1
1
  export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable<import("@cosmicdrift/kumiko-types/fields").EntityDefinition<{
2
- readonly storageKey: {
3
- readonly type: "text";
4
- readonly description?: string;
5
- readonly maxLength?: number;
6
- readonly minLength?: number;
7
- readonly autoComplete?: string;
8
- readonly required?: boolean;
9
- readonly searchable?: boolean;
10
- readonly sortable?: boolean;
11
- readonly filterable?: boolean;
12
- readonly encrypted?: boolean;
13
- readonly sensitive?: boolean;
14
- readonly writeOnly?: boolean;
15
- readonly readAsInstruction?: boolean;
16
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
17
- readonly default?: string;
18
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
19
- readonly multiline?: boolean | {
20
- readonly rows?: number;
21
- };
22
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
2
+ readonly storageKey: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
23
3
  required: true;
24
4
  };
25
- readonly fileName: {
26
- readonly type: "text";
27
- readonly description?: string;
28
- readonly maxLength?: number;
29
- readonly minLength?: number;
30
- readonly autoComplete?: string;
31
- readonly required?: boolean;
32
- readonly searchable?: boolean;
33
- readonly sortable?: boolean;
34
- readonly filterable?: boolean;
35
- readonly encrypted?: boolean;
36
- readonly sensitive?: boolean;
37
- readonly writeOnly?: boolean;
38
- readonly readAsInstruction?: boolean;
39
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
40
- readonly default?: string;
41
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
42
- readonly multiline?: boolean | {
43
- readonly rows?: number;
44
- };
45
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
5
+ readonly fileName: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
46
6
  required: true;
47
7
  };
48
- readonly mimeType: {
49
- readonly type: "text";
50
- readonly description?: string;
51
- readonly maxLength?: number;
52
- readonly minLength?: number;
53
- readonly autoComplete?: string;
54
- readonly required?: boolean;
55
- readonly searchable?: boolean;
56
- readonly sortable?: boolean;
57
- readonly filterable?: boolean;
58
- readonly encrypted?: boolean;
59
- readonly sensitive?: boolean;
60
- readonly writeOnly?: boolean;
61
- readonly readAsInstruction?: boolean;
62
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
63
- readonly default?: string;
64
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
65
- readonly multiline?: boolean | {
66
- readonly rows?: number;
67
- };
68
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
8
+ readonly mimeType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
69
9
  required: true;
70
10
  };
71
11
  readonly size: {
@@ -80,73 +20,13 @@ export declare const fileRefsTable: import("../db/table-builder.ts").EntityTable
80
20
  } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
81
21
  required: true;
82
22
  };
83
- readonly entityType: {
84
- readonly type: "text";
85
- readonly description?: string;
86
- readonly maxLength?: number;
87
- readonly minLength?: number;
88
- readonly autoComplete?: string;
89
- readonly required?: boolean;
90
- readonly searchable?: boolean;
91
- readonly sortable?: boolean;
92
- readonly filterable?: boolean;
93
- readonly encrypted?: boolean;
94
- readonly sensitive?: boolean;
95
- readonly writeOnly?: boolean;
96
- readonly readAsInstruction?: boolean;
97
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
98
- readonly default?: string;
99
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
100
- readonly multiline?: boolean | {
101
- readonly rows?: number;
102
- };
103
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
23
+ readonly entityType: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
104
24
  required: false;
105
25
  };
106
- readonly entityId: {
107
- readonly type: "text";
108
- readonly description?: string;
109
- readonly maxLength?: number;
110
- readonly minLength?: number;
111
- readonly autoComplete?: string;
112
- readonly required?: boolean;
113
- readonly searchable?: boolean;
114
- readonly sortable?: boolean;
115
- readonly filterable?: boolean;
116
- readonly encrypted?: boolean;
117
- readonly sensitive?: boolean;
118
- readonly writeOnly?: boolean;
119
- readonly readAsInstruction?: boolean;
120
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
121
- readonly default?: string;
122
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
123
- readonly multiline?: boolean | {
124
- readonly rows?: number;
125
- };
126
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
26
+ readonly entityId: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
127
27
  required: false;
128
28
  };
129
- readonly fieldName: {
130
- readonly type: "text";
131
- readonly description?: string;
132
- readonly maxLength?: number;
133
- readonly minLength?: number;
134
- readonly autoComplete?: string;
135
- readonly required?: boolean;
136
- readonly searchable?: boolean;
137
- readonly sortable?: boolean;
138
- readonly filterable?: boolean;
139
- readonly encrypted?: boolean;
140
- readonly sensitive?: boolean;
141
- readonly writeOnly?: boolean;
142
- readonly readAsInstruction?: boolean;
143
- readonly format?: "email" | "url" | "phone" | "password" | "month" | "iban";
144
- readonly default?: string;
145
- readonly access?: import("@cosmicdrift/kumiko-types/fields").FieldAccess;
146
- readonly multiline?: boolean | {
147
- readonly rows?: number;
148
- };
149
- } & import("@cosmicdrift/kumiko-types/fields").ResolvedPiiFlags & {
29
+ readonly fieldName: import("@cosmicdrift/kumiko-types/fields").EntityTextFieldDef & {
150
30
  required: false;
151
31
  };
152
32
  }, "tenant">>;
@@ -23,6 +23,9 @@ const DEFAULT_PRIVILEGED_ROLES = ["Admin", "SystemAdmin"];
23
23
  // leaked URL (e.g. from a browser history screenshot) isn't a long-lived
24
24
  // credential (see "Signed-URL default expiry" in core-files.md).
25
25
  const SIGNED_URL_DEFAULT_EXPIRY_SECONDS = 15 * 60;
26
+ const MAX_BATCH_META_IDS = 100;
27
+ // Each detail() read decrypts through the KMS pool; a bounded chunk keeps one batch from hogging it.
28
+ const BATCH_META_CONCURRENCY = 10;
26
29
  // Default guard: on attached files, allow the uploader or a privileged role,
27
30
  // and for reads also everyone who may read the record. Delete stays with the
28
31
  // uploader and privileged roles: reading a record is no right to remove its
@@ -218,6 +221,25 @@ export function createFileRoutes(options) {
218
221
  return c.json({ error: "upload_failed" }, 500);
219
222
  return c.json(outcome.fileRef, 201);
220
223
  });
224
+ // GET /files/meta?ids=a,b,c — meta of many files in one request (list/card
225
+ // cells otherwise fire one request per row). Unreadable and unknown ids are
226
+ // left out of the answer, same as /files/:id/meta answering 404 for them.
227
+ // Registered before /files/:id so "meta" is never taken for an id.
228
+ api.get("/files/meta", async (c) => {
229
+ const user = getUser(c);
230
+ const ids = [...new Set((c.req.query("ids") ?? "").split(",").filter((id) => isUuid(id)))];
231
+ if (ids.length > MAX_BATCH_META_IDS)
232
+ return c.json({ error: "too_many_ids" }, 400);
233
+ const files = [];
234
+ for (let start = 0; start < ids.length; start += BATCH_META_CONCURRENCY) {
235
+ const chunk = ids.slice(start, start + BATCH_META_CONCURRENCY);
236
+ const metas = await Promise.all(chunk.map((id) => loadReadableFileMeta(id, user)));
237
+ for (const meta of metas)
238
+ if (meta)
239
+ files.push(meta);
240
+ }
241
+ return c.json({ files });
242
+ });
221
243
  // GET /files/:id — download.
222
244
  //
223
245
  // Authorization stack:
@@ -388,31 +410,28 @@ export function createFileRoutes(options) {
388
410
  const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString();
389
411
  return c.json({ url, expiresAt });
390
412
  });
391
- // GET /files/:id/meta — metadata without the bytes. Unlike the byte-serving
392
- // routes above, this returns fileName as JSON — and fileName is
393
- // `personal: "self"` (fw#2423), so it must go through the entity executor's
394
- // detail() to decrypt instead of loadFileForTenant's raw, still-encrypted read.
395
- api.get("/files/:id/meta", async (c) => {
396
- const user = getUser(c);
397
- const id = c.req.param("id");
413
+ // Metadata without the bytes. Unlike the byte-serving routes, this returns
414
+ // fileName as JSON — and fileName is `personal: "self"` (fw#2423), so it must
415
+ // go through the entity executor's detail() to decrypt instead of
416
+ // loadFileForTenant's raw, still-encrypted read.
417
+ async function loadReadableFileMeta(id, user) {
398
418
  // Same 22P02-avoidance as loadFileForTenant — detail() doesn't validate
399
419
  // id shape before it hits the UUID column.
400
420
  if (!isUuid(id))
401
- return c.json({ error: "not_found" }, 404);
421
+ return null;
402
422
  const row = await executor.detail({ id }, user, tenantDbFor(user.tenantId));
403
423
  if (!row)
404
- return c.json({ error: "not_found" }, 404);
424
+ return null;
405
425
  const fileRef = row; // @cast-boundary db-row (decrypted via executor.detail)
406
426
  // detail()'s "pass"-ownership read widens to tenantId IN (self, SYSTEM),
407
427
  // narrower in loadFileForTenant's selectMany. Restore that here so meta
408
428
  // doesn't leak reference-tenant rows.
409
- if (fileRef.tenantId !== user.tenantId) {
410
- return c.json({ error: "not_found" }, 404);
411
- }
429
+ if (fileRef.tenantId !== user.tenantId)
430
+ return null;
412
431
  const decision = await guard({ fileRef, user, operation: "read" });
413
432
  if (decision === "deny")
414
- return c.json({ error: "not_found" }, 404);
415
- return c.json({
433
+ return null;
434
+ return {
416
435
  id: fileRef.id,
417
436
  fileName: fileRef.fileName,
418
437
  mimeType: fileRef.mimeType,
@@ -420,7 +439,11 @@ export function createFileRoutes(options) {
420
439
  entityType: fileRef.entityType,
421
440
  entityId: fileRef.entityId,
422
441
  fieldName: fileRef.fieldName,
423
- });
442
+ };
443
+ }
444
+ api.get("/files/:id/meta", async (c) => {
445
+ const meta = await loadReadableFileMeta(c.req.param("id"), getUser(c));
446
+ return meta ? c.json(meta) : c.json({ error: "not_found" }, 404);
424
447
  });
425
448
  async function loadFileForTenant(id, tenantId) {
426
449
  // fileRefsTable.id is a UUID column — a malformed id must fail here, not
@@ -31,5 +31,9 @@ export type TranslationLocaleGap = {
31
31
  readonly key: string;
32
32
  readonly missingLocales: readonly string[];
33
33
  };
34
- /** Locale completeness is opt-in via @cosmicdrift/kumiko-locale-* packages. */
35
- export declare function findTranslationLocaleGaps(_features: readonly FeatureDefinition[]): readonly TranslationLocaleGap[];
34
+ /**
35
+ * Per feature, every translation key must carry every locale that any key of the
36
+ * same feature carries. A feature with a single locale across all keys is
37
+ * consistent by definition (locale packages, en-only framework features).
38
+ */
39
+ export declare function findTranslationLocaleGaps(features: readonly FeatureDefinition[]): readonly TranslationLocaleGap[];
@@ -519,7 +519,33 @@ export function buildEffectiveTranslationKeys(features) {
519
519
  }
520
520
  return out;
521
521
  }
522
- /** Locale completeness is opt-in via @cosmicdrift/kumiko-locale-* packages. */
523
- export function findTranslationLocaleGaps(_features) {
524
- return [];
522
+ function hasLocaleValue(value) {
523
+ if (value === undefined)
524
+ return false;
525
+ return typeof value === "string" ? value !== "" : true;
526
+ }
527
+ /**
528
+ * Per feature, every translation key must carry every locale that any key of the
529
+ * same feature carries. A feature with a single locale across all keys is
530
+ * consistent by definition (locale packages, en-only framework features).
531
+ */
532
+ export function findTranslationLocaleGaps(features) {
533
+ const gaps = [];
534
+ for (const feature of features) {
535
+ const entries = Object.entries(feature.translations ?? {});
536
+ const featureLocales = new Set();
537
+ for (const [, byLocale] of entries) {
538
+ for (const locale of Object.keys(byLocale))
539
+ featureLocales.add(locale);
540
+ }
541
+ if (featureLocales.size < 2)
542
+ continue;
543
+ const sortedLocales = [...featureLocales].sort();
544
+ for (const [key, byLocale] of entries) {
545
+ const missingLocales = sortedLocales.filter((locale) => !hasLocaleValue(byLocale[locale]));
546
+ if (missingLocales.length > 0)
547
+ gaps.push({ featureName: feature.name, key, missingLocales });
548
+ }
549
+ }
550
+ return gaps;
525
551
  }
@@ -1,5 +1,10 @@
1
1
  /// <reference types="temporal-polyfill/global" preserve="true" />
2
- import type { DbConnection } from "../db/connection.ts";
2
+ import type { DbRunner } from "../db/connection.ts";
3
+ export type ConsumerTransitionRejection = "unknown-consumer" | "wrong-status";
4
+ export declare class ConsumerTransitionError extends Error {
5
+ readonly rejection: ConsumerTransitionRejection;
6
+ constructor(rejection: ConsumerTransitionRejection, message: string);
7
+ }
3
8
  export type ConsumerRecoveryState = {
4
9
  readonly name: string;
5
10
  readonly instanceId: string;
@@ -10,13 +15,15 @@ export type ConsumerRecoveryState = {
10
15
  readonly lastError: string | null;
11
16
  readonly updatedAt: Temporal.Instant;
12
17
  };
13
- export declare function restartConsumer(db: DbConnection, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
14
- export declare function disableConsumer(db: DbConnection, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
15
- export declare function enableConsumer(db: DbConnection, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
16
- export declare function skipPoisonEvent(db: DbConnection, name: string, instanceId?: string): Promise<ConsumerRecoveryState & {
18
+ export declare function restartConsumer(db: DbRunner, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
19
+ export declare function disableConsumer(db: DbRunner, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
20
+ export declare function enableConsumer(db: DbRunner, name: string, instanceId?: string): Promise<ConsumerRecoveryState>;
21
+ export declare function skipPoisonEvent(db: DbRunner, name: string, instanceId?: string, options?: {
22
+ readonly requireStatus?: string;
23
+ }): Promise<ConsumerRecoveryState & {
17
24
  readonly skippedEventId: bigint | null;
18
25
  }>;
19
- export declare function getConsumerState(db: DbConnection, name: string, instanceId?: string): Promise<{
26
+ export declare function getConsumerState(db: DbRunner, name: string, instanceId?: string): Promise<{
20
27
  readonly name: string;
21
28
  readonly instanceId: string;
22
29
  readonly status: string;
@@ -26,7 +33,7 @@ export declare function getConsumerState(db: DbConnection, name: string, instanc
26
33
  readonly lastError: string | null;
27
34
  readonly updatedAt: Temporal.Instant;
28
35
  } | null>;
29
- export declare function listConsumersWithState(db: DbConnection, registeredNames: readonly string[]): Promise<ReadonlyArray<{
36
+ export declare function listConsumersWithState(db: DbRunner, registeredNames: readonly string[]): Promise<ReadonlyArray<{
30
37
  readonly name: string;
31
38
  readonly instanceId: string;
32
39
  readonly status: string;
@@ -44,4 +51,4 @@ export type ConsumerProgress = {
44
51
  readonly highWaterMark: bigint;
45
52
  readonly lag: bigint;
46
53
  };
47
- export declare function getAllConsumerProgress(db: DbConnection, registeredNames: readonly string[]): Promise<readonly ConsumerProgress[]>;
54
+ export declare function getAllConsumerProgress(db: DbRunner, registeredNames: readonly string[]): Promise<readonly ConsumerProgress[]>;
@@ -1,6 +1,6 @@
1
1
  import { advanceConsumerPastEventReturning, removePendingGapReturning, selectConsumerForUpdate, selectSnapshotXmax, updateConsumerStatusReturning, } from "../db/queries/event-consumer.js";
2
2
  import { selectNextEventIdAfter, selectSmallestVisibleIdInRanges, } from "../db/queries/event-store.js";
3
- import { coerceRow, extractTableInfo, selectMany } from "../db/query.js";
3
+ import { coerceRow, extractTableInfo, runInNewTransaction, runInSavepointIfSupported, runnerCanBeginTransaction, selectMany, } from "../db/query.js";
4
4
  import { getEventsHighWaterMark } from "../event-store/index.js";
5
5
  import { eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
6
6
  import { mergeContiguousGapRanges, rangeContainsId, splitRangeExcludingIds, toIdRanges, } from "./pending-gap-ranges.js";
@@ -33,6 +33,14 @@ function normalizeConsumerState(row) {
33
33
  updatedAt: row.updatedAt,
34
34
  };
35
35
  }
36
+ export class ConsumerTransitionError extends Error {
37
+ rejection;
38
+ constructor(rejection, message) {
39
+ super(message);
40
+ this.rejection = rejection;
41
+ this.name = "ConsumerTransitionError";
42
+ }
43
+ }
36
44
  // Ops calls default to the SHARED_INSTANCE_SENTINEL row — that's the only
37
45
  // row shared-delivery consumers have, so legacy CLI invocations without
38
46
  // --instance-id keep working. Per-instance consumers require an explicit
@@ -45,7 +53,7 @@ async function requireConsumerRow(db, name, instanceId) {
45
53
  instanceId,
46
54
  });
47
55
  if (!row) {
48
- throw new Error(`Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
56
+ throw new ConsumerTransitionError("unknown-consumer", `Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
49
57
  `For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`);
50
58
  }
51
59
  return row;
@@ -53,7 +61,7 @@ async function requireConsumerRow(db, name, instanceId) {
53
61
  async function requireConsumerRowForUpdate(tx, name, instanceId) {
54
62
  const raw = await selectConsumerForUpdate(tx, name, instanceId);
55
63
  if (!raw) {
56
- throw new Error(`Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
64
+ throw new ConsumerTransitionError("unknown-consumer", `Consumer "${name}" (instance_id="${instanceId}") has no state row — it hasn't run yet, the name is misspelled, or the instance is misspelled. ` +
57
65
  `For per-instance consumers pass the instance_id explicitly; shared consumers use the default.`);
58
66
  }
59
67
  return coerceRow(raw, extractTableInfo(eventConsumerStateTable));
@@ -69,7 +77,7 @@ async function applyConsumerStatusTransition(db, name, instanceId, targetStatus)
69
77
  export async function restartConsumer(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
70
78
  const before = await requireConsumerRow(db, name, instanceId);
71
79
  if (before.status !== "dead") {
72
- throw new Error(`Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`);
80
+ throw new ConsumerTransitionError("wrong-status", `Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`);
73
81
  }
74
82
  return applyConsumerStatusTransition(db, name, instanceId, "idle");
75
83
  }
@@ -80,10 +88,17 @@ export async function disableConsumer(db, name, instanceId = SHARED_INSTANCE_SEN
80
88
  export async function enableConsumer(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
81
89
  const before = await requireConsumerRow(db, name, instanceId);
82
90
  if (before.status !== "disabled") {
83
- throw new Error(`Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`);
91
+ throw new ConsumerTransitionError("wrong-status", `Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`);
84
92
  }
85
93
  return applyConsumerStatusTransition(db, name, instanceId, "idle");
86
94
  }
95
+ // A pool connection opens its own transaction; inside a handler transaction the
96
+ // locked read and writes join it, confined to a savepoint.
97
+ function runInOwnTransactionOrSavepoint(db, fn) {
98
+ return runnerCanBeginTransaction(db)
99
+ ? runInNewTransaction(db, fn)
100
+ : runInSavepointIfSupported(db, fn);
101
+ }
87
102
  // Delivery stops at the poison event, so ids between the cursor and it were
88
103
  // invisible during that pass (in-flight commits); keeping them as a pending gap
89
104
  // avoids losing them when the cursor jumps over.
@@ -97,6 +112,11 @@ async function invisibleWindowGap(tx, cursor, poisonId) {
97
112
  xmax: await selectSnapshotXmax(tx),
98
113
  };
99
114
  }
115
+ function assertRequiredStatus(row, name, instanceId, requiredStatus) {
116
+ if (requiredStatus !== undefined && row.status !== requiredStatus) {
117
+ throw new ConsumerTransitionError("wrong-status", `Consumer "${name}" (instance_id="${instanceId}") is "${row.status}", not "${requiredStatus}"; nothing skipped.`);
118
+ }
119
+ }
100
120
  // skipPoisonEvent skips the event whose handler failed last
101
121
  // (last_failed_event_id, set by the delivery's halt-on-poison branch): inside
102
122
  // pending_gaps it is split out of its range, otherwise the cursor moves onto
@@ -104,11 +124,12 @@ async function invisibleWindowGap(tx, cursor, poisonId) {
104
124
  // falls back to a guess: the smallest visible pending-gap id, else the next
105
125
  // event after the cursor. Single TX so concurrent dispatcher passes can't
106
126
  // double-advance. Nothing to skip → idempotent no-op.
107
- export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SENTINEL) {
108
- return db.begin(async (tx) => {
127
+ export async function skipPoisonEvent(db, name, instanceId = SHARED_INSTANCE_SENTINEL, options = {}) {
128
+ return runInOwnTransactionOrSavepoint(db, async (tx) => {
109
129
  // Locked read: a dispatcher turn committing new gaps between an unlocked
110
130
  // read and the pending_gaps overwrite below would have them dropped.
111
131
  const before = await requireConsumerRowForUpdate(tx, name, instanceId);
132
+ assertRequiredStatus(before, name, instanceId, options.requireStatus);
112
133
  const pendingGaps = before.pendingGaps;
113
134
  const failedEventId = before.lastFailedEventId;
114
135
  const failedIsPending = failedEventId !== null && pendingGaps.some((gap) => rangeContainsId(gap, failedEventId));
@@ -49,5 +49,5 @@ export type EventDispatcherOptions = {
49
49
  readonly pgClient?: PgListenClient;
50
50
  };
51
51
  export declare function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher;
52
- export type { ConsumerProgress, ConsumerRecoveryState } from "./event-dispatcher-admin.ts";
53
- export { disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher-admin.ts";
52
+ export type { ConsumerProgress, ConsumerRecoveryState, ConsumerTransitionRejection, } from "./event-dispatcher-admin.ts";
53
+ export { ConsumerTransitionError, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher-admin.ts";
@@ -573,4 +573,4 @@ export function createEventDispatcher(options) {
573
573
  runOnce,
574
574
  };
575
575
  }
576
- export { disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher-admin.js";
576
+ export { ConsumerTransitionError, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher-admin.js";
@@ -19,8 +19,8 @@ export type { ConsumerStatus, PendingGapEntry } from "./event-consumer-state.ts"
19
19
  export { ConsumerStatuses, createEventConsumerStateTable, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.ts";
20
20
  export type { EventDedup } from "./event-dedup.ts";
21
21
  export { createEventDedup } from "./event-dedup.ts";
22
- export type { ConsumerProgress, ConsumerRecoveryState, DispatcherPassResult, EventConsumer, EventConsumerBatchHandler, EventConsumerHandler, EventDispatcher, EventDispatcherOptions, } from "./event-dispatcher.ts";
23
- export { createEventDispatcher, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher.ts";
22
+ export type { ConsumerProgress, ConsumerRecoveryState, ConsumerTransitionRejection, DispatcherPassResult, EventConsumer, EventConsumerBatchHandler, EventConsumerHandler, EventDispatcher, EventDispatcherOptions, } from "./event-dispatcher.ts";
23
+ export { ConsumerTransitionError, createEventDispatcher, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher.ts";
24
24
  export type { ConsumerCursor } from "./event-dispatcher-delivery.ts";
25
25
  export { selectConsumerCursorForUpdate } from "./event-dispatcher-delivery.ts";
26
26
  export type { PruneEventsOptions, PruneEventsResult } from "./event-retention.ts";
@@ -10,7 +10,7 @@ export { createDistributedLock } from "./distributed-lock.js";
10
10
  export { createEntityCache } from "./entity-cache.js";
11
11
  export { ConsumerStatuses, createEventConsumerStateTable, eventConsumerStateTable, SHARED_INSTANCE_SENTINEL, } from "./event-consumer-state.js";
12
12
  export { createEventDedup } from "./event-dedup.js";
13
- export { createEventDispatcher, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher.js";
13
+ export { ConsumerTransitionError, createEventDispatcher, disableConsumer, enableConsumer, getAllConsumerProgress, getConsumerState, listConsumersWithState, restartConsumer, skipPoisonEvent, } from "./event-dispatcher.js";
14
14
  export { selectConsumerCursorForUpdate } from "./event-dispatcher-delivery.js";
15
15
  export { ConsumerLagError, pruneEvents } from "./event-retention.js";
16
16
  export { createIdempotencyGuard } from "./idempotency.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.383.1",
3
+ "version": "0.384.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.383.1",
202
+ "@cosmicdrift/kumiko-types": "0.384.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.383.1",
218
+ "@cosmicdrift/kumiko-testing": "0.384.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,30 @@
1
1
  [
2
+ {
3
+ "version": "0.384.0",
4
+ "type": "breaking",
5
+ "title": "Boot fails when a feature leaves a locale out of some of its translation keys",
6
+ "detail": "Within one feature, every translation key must carry every locale that any key of the same feature carries. A missing locale or an empty string fails boot with one error listing all gaps. Features with a single locale across all keys are unaffected, as are locale packages. A plural object counts as present.",
7
+ "migration": "Add the missing locale to each listed key, or move that locale out of the feature into a locale package."
8
+ },
9
+ {
10
+ "version": "0.384.0",
11
+ "type": "breaking",
12
+ "title": "Entity text fields must declare a personal stance",
13
+ "detail": "FieldsMap requires the personal stance of text and longText fields by type, and boot throws for such a field without one. The name heuristic for PII-typical names stays a warning and only applies to other field types.",
14
+ "migration": "Move raw `{ type: \"text\" }` and `{ type: \"longText\" }` entity fields to createTextField / createLongTextField with `personal` (\"self\", \"tenant\", \"ref\", `{ of }`, or false with a `reason`). tsc shows each place."
15
+ },
16
+ {
17
+ "version": "0.384.0",
18
+ "type": "improvement",
19
+ "title": "Consumer admin functions accept a transaction, notIn where operator",
20
+ "detail": "restartConsumer, skipPoisonEvent, disableConsumer, enableConsumer and the progress readers accept a DbRunner (pool or open transaction); skipPoisonEvent uses its own transaction on a pool and a savepoint inside a handler transaction. skipPoisonEvent takes { requireStatus } and checks it under its row lock; the consumer-skip handler requires \"dead\". Rejected transitions throw ConsumerTransitionError with rejection \"unknown-consumer\" or \"wrong-status\". Where clauses support { notIn: [...] }. Existing callers passing a DbConnection compile unchanged."
21
+ },
22
+ {
23
+ "version": "0.384.0",
24
+ "type": "improvement",
25
+ "title": "GET /files/meta?ids= returns file metadata in one request",
26
+ "detail": "Up to 100 comma-separated file ids per call (more answers 400 too_many_ids). The same tenant and access checks as /files/:id/meta apply per id; unknown, foreign and invalid ids are left out of the answer. The renderer's file thumbnails and file names batch their lookups through it, so a list of N files no longer issues N meta requests."
27
+ },
2
28
  {
3
29
  "version": "0.383.1",
4
30
  "type": "fix",