@cosmicdrift/kumiko-framework 0.367.0 → 0.369.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/AGENTS.md +1 -7
- package/README.md +1 -1
- package/agents/examples/access-control.md +2 -2
- package/agents/examples/anonymous-access-multitenant.md +2 -2
- package/agents/examples/anonymous-access.md +3 -3
- package/agents/examples/apex-landing.md +1 -1
- package/agents/examples/apex-surface-auth.md +6 -7
- package/agents/examples/api-worker-split.md +7 -8
- package/agents/examples/auth-claims.md +2 -1
- package/agents/examples/auth-foundation-providers.md +3 -3
- package/agents/examples/auth-mfa.md +3 -4
- package/agents/examples/basic-entity.md +3 -4
- package/agents/examples/chat-channels.md +4 -4
- package/agents/examples/compliance-profiles-demo.md +2 -3
- package/agents/examples/cross-feature-events.md +4 -4
- package/agents/examples/crypto-shredding-hr.md +14 -9
- package/agents/examples/currencies-global.md +3 -4
- package/agents/examples/currencies-per-tenant.md +3 -4
- package/agents/examples/custom-fields-basic.md +2 -3
- package/agents/examples/custom-handlers.md +3 -5
- package/agents/examples/dashboard-stats.md +2 -3
- package/agents/examples/delivery-notifications.md +5 -6
- package/agents/examples/e2e-generator.md +1 -1
- package/agents/examples/embedded-entity-form.md +2 -3
- package/agents/examples/embedded.md +2 -3
- package/agents/examples/encrypted-tenant-config.md +5 -6
- package/agents/examples/error-contract.md +3 -4
- package/agents/examples/event-sourcing.md +19 -23
- package/agents/examples/feature-toggles.md +4 -4
- package/agents/examples/field-access.md +3 -4
- package/agents/examples/files-post-processing.md +12 -13
- package/agents/examples/golden-path.md +208 -0
- package/agents/examples/idempotency.md +2 -2
- package/agents/examples/inbound-mail-triage.md +7 -7
- package/agents/examples/lane-routing.md +3 -3
- package/agents/examples/ledger-banking.md +2 -3
- package/agents/examples/ledger-invoicing.md +2 -3
- package/agents/examples/legal-pages.md +1 -1
- package/agents/examples/lifecycle-hooks.md +3 -3
- package/agents/examples/managed-config.md +3 -4
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +4 -5
- package/agents/examples/ownership.md +3 -5
- package/agents/examples/pdf-audit-export.md +2 -3
- package/agents/examples/pipeline-basics.md +2 -2
- package/agents/examples/public-share-token.md +3 -3
- package/agents/examples/rate-limiting.md +2 -1
- package/agents/examples/realtime-sse.md +2 -3
- package/agents/examples/record-detail-layout.md +1 -1
- package/agents/examples/reference-data.md +3 -3
- package/agents/examples/relations.md +2 -2
- package/agents/examples/screens-nav.md +1 -1
- package/agents/examples/search.md +2 -2
- package/agents/examples/secrets-demo.md +8 -8
- package/agents/examples/security-baseline.md +2 -2
- package/agents/examples/session-revocation.md +7 -8
- package/agents/examples/state-machine.md +181 -165
- package/agents/examples/tags-basic.md +2 -3
- package/agents/examples/tenant-isolation.md +2 -3
- package/agents/examples/tenant-settings.md +2 -2
- package/agents/examples/tier-admin.md +3 -4
- package/agents/examples/timezones.md +3 -3
- package/agents/examples/user-data-rights.md +12 -8
- package/agents/examples/user-profile.md +3 -3
- package/agents/examples/webhook-step.md +2 -3
- package/agents/examples/wizard-form.md +4 -5
- package/agents/examples/workflow-engine.md +7 -3
- package/agents/examples/writeform-section.md +1 -1
- package/agents/examples.md +1 -0
- package/agents/features.md +4 -4
- package/dist/consumer-cli.js +2 -3
- package/dist/crypto/index.d.ts +1 -1
- package/dist/crypto/index.js +1 -1
- package/dist/crypto/owner-transition-rekey.js +2 -0
- package/dist/crypto/pg-kms-adapter.js +1 -1
- package/dist/crypto/subject-resolver.d.ts +2 -0
- package/dist/crypto/subject-resolver.js +30 -15
- package/dist/db/bun-db-deprecated.d.ts +10 -0
- package/dist/{bun-db/index.js → db/bun-db-deprecated.js} +4 -3
- package/dist/db/config-seed.js +1 -1
- package/dist/db/connection.d.ts +23 -5
- package/dist/db/connection.js +10 -5
- package/dist/db/dialect.js +1 -1
- package/dist/db/entity-field-encryption.d.ts +5 -0
- package/dist/db/entity-field-encryption.js +29 -0
- package/dist/db/entity-table-meta.js +6 -5
- package/dist/db/event-store-executor-read.js +26 -5
- package/dist/db/index.d.ts +4 -4
- package/dist/db/index.js +1 -1
- package/dist/db/migrate-runner.js +1 -1
- package/dist/db/queries/backfill-pii.js +16 -9
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/event-store.d.ts +2 -0
- package/dist/db/queries/event-store.js +26 -0
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/query.d.ts +105 -1
- package/dist/db/query.js +1450 -3
- package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
- package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
- package/dist/db/tenant-db.d.ts +11 -0
- package/dist/db/tenant-db.js +51 -12
- package/dist/derivatives/derivatives-context.d.ts +1 -1
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/gdpr-storage.js +3 -3
- package/dist/engine/boot-validator/pii-retention.js +3 -3
- package/dist/engine/boot-validator/record-owned.js +3 -3
- package/dist/engine/boot-validator/screens.js +37 -1
- package/dist/engine/bridge-stub.d.ts +4 -0
- package/dist/engine/bridge-stub.js +62 -0
- package/dist/engine/build-config-feature-schema.js +197 -65
- package/dist/engine/event-helpers.d.ts +2 -1
- package/dist/engine/event-helpers.js +1 -0
- package/dist/engine/extension-selector-plugins.d.ts +3 -0
- package/dist/engine/extension-selector-plugins.js +14 -0
- package/dist/engine/feature-config-events-jobs.d.ts +2 -0
- package/dist/engine/feature-config-events-jobs.js +3 -2
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/es-ops/operations-schema.js +1 -1
- package/dist/event-store/admin-api.js +1 -1
- package/dist/event-store/archive.js +1 -1
- package/dist/event-store/errors.d.ts +2 -1
- package/dist/event-store/errors.js +6 -2
- package/dist/event-store/event-store.js +17 -9
- package/dist/event-store/events-schema.js +1 -1
- package/dist/event-store/rebuild-dead-letter.js +3 -3
- package/dist/event-store/snapshot.js +1 -1
- package/dist/event-store/upcaster-dead-letter.js +3 -3
- package/dist/files/create-file-ref-from-bytes.js +1 -1
- package/dist/files/file-routes.js +1 -1
- package/dist/i18n/settings-hub-keys.js +1 -2
- package/dist/internal/stack-support.d.ts +12 -0
- package/dist/internal/stack-support.js +12 -0
- package/dist/internal/testing.d.ts +4 -0
- package/dist/internal/testing.js +5 -0
- package/dist/jobs/job-runner.js +23 -11
- package/dist/migrations/pending-rebuilds.js +1 -1
- package/dist/migrations/pii-event-backfill.js +2 -2
- package/dist/pipeline/dispatch-shared.js +43 -23
- package/dist/pipeline/dispatch-write.js +6 -2
- package/dist/pipeline/dispatcher-utils.js +6 -2
- package/dist/pipeline/event-consumer-state.js +1 -1
- package/dist/pipeline/projection-rebuild.d.ts +1 -0
- package/dist/pipeline/projection-rebuild.js +1 -1
- package/dist/pipeline/projection-state.js +1 -1
- package/dist/pipeline/system-hooks.js +1 -1
- package/dist/schema-cli.js +3 -3
- package/dist/search/purge-subject.js +20 -10
- package/dist/seeding/index.d.ts +1 -0
- package/dist/seeding/index.js +1 -0
- package/dist/stack/index.d.ts +10 -9
- package/dist/stack/index.js +11 -19
- package/dist/testing/e2e-generator.d.ts +5 -49
- package/dist/testing/e2e-generator.js +5 -382
- package/dist/testing/handler-context.d.ts +5 -4
- package/dist/testing/handler-context.js +5 -69
- package/dist/testing/index.d.ts +10 -26
- package/dist/testing/index.js +10 -34
- package/dist/testing/real-providers.d.ts +5 -3
- package/dist/testing/real-providers.js +5 -21
- package/dist/upgrade-cli.js +6 -4
- package/package.json +37 -9
- package/src/changes.json +64 -0
- package/src/scripts/codemod/README.md +43 -0
- package/src/scripts/codemod/codemod-support.ts +276 -0
- package/src/scripts/codemod/db-factory.ts +810 -0
- package/src/scripts/codemod/feature-factory.ts +429 -0
- package/src/scripts/codemod/s3-imports.ts +274 -0
- package/src/scripts/codemod/tenant-db-options.ts +454 -0
- package/src/scripts/codemod/testing-move-targets.ts +161 -0
- package/src/scripts/codemod/testing-move.ts +520 -0
- package/dist/bun-db/connection.d.ts +0 -17
- package/dist/bun-db/connection.js +0 -40
- package/dist/bun-db/index.d.ts +0 -4
- package/dist/bun-db/query.d.ts +0 -106
- package/dist/bun-db/query.js +0 -1457
- package/dist/db/api.d.ts +0 -19
- package/dist/db/api.js +0 -23
- package/dist/db/bun-provider.d.ts +0 -2
- package/dist/db/bun-provider.js +0 -28
- package/dist/db/postgres-provider.d.ts +0 -2
- package/dist/db/postgres-provider.js +0 -21
- package/dist/db/query-api.d.ts +0 -2
- package/dist/db/query-api.js +0 -6
- package/dist/stack/db.d.ts +0 -22
- package/dist/stack/db.js +0 -67
- package/dist/stack/drain-event-consumers.d.ts +0 -4
- package/dist/stack/drain-event-consumers.js +0 -51
- package/dist/stack/drain-jobs.d.ts +0 -22
- package/dist/stack/drain-jobs.js +0 -141
- package/dist/stack/event-collector.d.ts +0 -9
- package/dist/stack/event-collector.js +0 -12
- package/dist/stack/push-entity-projection-tables.d.ts +0 -24
- package/dist/stack/push-entity-projection-tables.js +0 -74
- package/dist/stack/redis.d.ts +0 -17
- package/dist/stack/redis.js +0 -52
- package/dist/stack/request-helper.d.ts +0 -35
- package/dist/stack/request-helper.js +0 -144
- package/dist/stack/test-stack.d.ts +0 -192
- package/dist/stack/test-stack.js +0 -429
- package/dist/testing/access-assertions.d.ts +0 -2
- package/dist/testing/access-assertions.js +0 -13
- package/dist/testing/assertions.d.ts +0 -10
- package/dist/testing/assertions.js +0 -22
- package/dist/testing/boot-validator-fixture.d.ts +0 -3
- package/dist/testing/boot-validator-fixture.js +0 -101
- package/dist/testing/closed-connection-error.d.ts +0 -7
- package/dist/testing/closed-connection-error.js +0 -46
- package/dist/testing/db-cleanup.d.ts +0 -9
- package/dist/testing/db-cleanup.js +0 -31
- package/dist/testing/expect-error.d.ts +0 -2
- package/dist/testing/expect-error.js +0 -22
- package/dist/testing/file-provider-contract.d.ts +0 -2
- package/dist/testing/file-provider-contract.js +0 -102
- package/dist/testing/http-cookies.d.ts +0 -7
- package/dist/testing/http-cookies.js +0 -47
- package/dist/testing/late-bound.d.ts +0 -9
- package/dist/testing/late-bound.js +0 -31
- package/dist/testing/manual-clock.d.ts +0 -14
- package/dist/testing/manual-clock.js +0 -12
- package/dist/testing/multipart-helper.d.ts +0 -22
- package/dist/testing/multipart-helper.js +0 -77
- package/dist/testing/mutable-master-key-provider.d.ts +0 -7
- package/dist/testing/mutable-master-key-provider.js +0 -35
- package/dist/testing/observability-recorder.d.ts +0 -9
- package/dist/testing/observability-recorder.js +0 -29
- package/dist/testing/seed.d.ts +0 -9
- package/dist/testing/seed.js +0 -21
- package/dist/testing/shared-entities.d.ts +0 -286
- package/dist/testing/shared-entities.js +0 -56
- package/dist/testing/utils.d.ts +0 -1
- package/dist/testing/utils.js +0 -1
- package/dist/testing/wait-for.d.ts +0 -20
- package/dist/testing/wait-for.js +0 -47
- package/dist/testing/without-ambient-temporal.d.ts +0 -5
- package/dist/testing/without-ambient-temporal.js +0 -17
- package/dist/testing/zip-fixture.d.ts +0 -1
- package/dist/testing/zip-fixture.js +0 -33
- /package/dist/{stack/test-users.d.ts → seeding/seed-users.d.ts} +0 -0
- /package/dist/{stack/test-users.js → seeding/seed-users.js} +0 -0
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
|
|
18
18
|
import { CONFIG_EDIT_ENTITY, fieldLabelKey } from "../i18n/required-surface-keys.js";
|
|
19
19
|
import { isEncryptedAtRest } from "./config-helpers.js";
|
|
20
|
-
import { EXTENSION_SELECTOR_HINT_KEY,
|
|
20
|
+
import { EXTENSION_SELECTOR_HINT_KEY, isSelectableInSettings, settingsSelectablePluginIds, } from "./extension-selector-plugins.js";
|
|
21
21
|
import { createBooleanField, createNumberField, createSelectField, createTextField, } from "./factories.js";
|
|
22
22
|
import { isKebabSegment, toKebab } from "./qualified-name.js";
|
|
23
|
+
import { isFieldsEditSection, sectionFieldSpecs } from "./screen-helpers.js";
|
|
23
24
|
import { isOpenToAllGranted } from "./types/handlers.js";
|
|
24
25
|
// Namespace, unter dem buildAppSchema die generierten Screens/Navs einhängt
|
|
25
26
|
// (find-or-create FeatureSchema). MUSS gleich CONFIG_FEATURE aus dem config
|
|
@@ -45,7 +46,7 @@ const PROVIDER_PANEL_TITLE_ENTRY = {
|
|
|
45
46
|
es: "Proveedor",
|
|
46
47
|
};
|
|
47
48
|
const PROVIDER_SECTION_TITLE_KEY = "config.settings.provider";
|
|
48
|
-
const
|
|
49
|
+
const SECRETS_SECTION_DESCRIPTION_KEY = "config.secrets.description";
|
|
49
50
|
const audienceNavShortId = (scope) => `audience-${scope}`;
|
|
50
51
|
// Generated post-boot, never via r.nav() — the boot validator exempts exactly these QNs (an app references one to place the settings group inline).
|
|
51
52
|
export const SETTINGS_HUB_AUDIENCE_NAV_QNS = SCOPES_BROAD_TO_DEEP.map((scope) => `${SETTINGS_HUB_FEATURE}:nav:${audienceNavShortId(scope)}`);
|
|
@@ -73,25 +74,43 @@ const NO_SELECTOR_GATING = {
|
|
|
73
74
|
};
|
|
74
75
|
// Verbatim (unprefixed) keys as the client sees them — NOT getAllTranslations()
|
|
75
76
|
// (server-merged, "feature:"-prefixed, see build-app-schema.ts:77-81).
|
|
77
|
+
// Merged per locale: a locale feature (localeDe) declares the same key with only its
|
|
78
|
+
// own language, and mounted first it must not drop the owning feature's en text.
|
|
76
79
|
function collectDeclaredTranslations(registry) {
|
|
77
80
|
const declared = new Map();
|
|
78
81
|
for (const f of registry.features.values()) {
|
|
79
82
|
for (const [key, values] of Object.entries(f.translations ?? {})) {
|
|
80
|
-
|
|
81
|
-
declared.set(key, values);
|
|
83
|
+
declared.set(key, { ...values, ...declared.get(key) });
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
return declared;
|
|
85
87
|
}
|
|
88
|
+
// A selector whose plugins are all hidden from settings still renders as a select
|
|
89
|
+
// (with no option), never as a free text field.
|
|
86
90
|
function collectSelectorOptions(registry) {
|
|
87
91
|
const options = new Map();
|
|
88
92
|
for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
93
|
+
if (registry.getExtensionUsages(extensionName).length === 0)
|
|
94
|
+
continue;
|
|
95
|
+
options.set(selectorKey, settingsSelectablePluginIds(registry, extensionName));
|
|
92
96
|
}
|
|
93
97
|
return options;
|
|
94
98
|
}
|
|
99
|
+
// selector key → plugin id → plugin feature, for every selectable plugin, also those
|
|
100
|
+
// without settings of their own (no panel, but still an option with a name).
|
|
101
|
+
function collectSelectorOptionFeatures(registry) {
|
|
102
|
+
const byKey = new Map();
|
|
103
|
+
for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
|
|
104
|
+
const featureByPluginId = new Map();
|
|
105
|
+
for (const usage of registry.getExtensionUsages(extensionName).filter(isSelectableInSettings)) {
|
|
106
|
+
if (usage.featureName === undefined || featureByPluginId.has(usage.entityName))
|
|
107
|
+
continue;
|
|
108
|
+
featureByPluginId.set(usage.entityName, toKebab(usage.featureName));
|
|
109
|
+
}
|
|
110
|
+
byKey.set(selectorKey, featureByPluginId);
|
|
111
|
+
}
|
|
112
|
+
return byKey;
|
|
113
|
+
}
|
|
95
114
|
function collectVisibleSelectorOwners(registry, visible) {
|
|
96
115
|
const ownerGroupByExtension = new Map();
|
|
97
116
|
const selectorFeatures = new Set();
|
|
@@ -141,6 +160,9 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
|
141
160
|
const secretQns = new Set();
|
|
142
161
|
for (const [extension, ownerGroup] of [...ownerGroupByExtension].sort(([a], [b]) => a.localeCompare(b))) {
|
|
143
162
|
const usages = [...registry.getExtensionUsages(extension)].sort((a, b) => (a.featureName ?? "").localeCompare(b.featureName ?? ""));
|
|
163
|
+
const selectorQn = registry.getAllExtensionSelectors().get(extension);
|
|
164
|
+
if (selectorQn === undefined)
|
|
165
|
+
continue;
|
|
144
166
|
for (const usage of usages) {
|
|
145
167
|
if (usage.featureName === undefined)
|
|
146
168
|
continue;
|
|
@@ -148,11 +170,22 @@ function planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
|
148
170
|
if (usageCountByFeature.get(feature) !== 1 || selectorFeatures.has(feature))
|
|
149
171
|
continue;
|
|
150
172
|
const scopedKeys = visible.filter((v) => v.key.ownerFeature === feature);
|
|
151
|
-
|
|
173
|
+
// The provider form has no per-field access gate, so a secret with its own write
|
|
174
|
+
// roles stays on the secrets screen, which enforces them.
|
|
175
|
+
const secrets = secretsEnabled
|
|
176
|
+
? declaredSecrets.filter((s) => s.feature === feature && s.def.writeRoles === undefined)
|
|
177
|
+
: [];
|
|
152
178
|
if (scopedKeys.length === 0 && secrets.length === 0)
|
|
153
179
|
continue;
|
|
154
180
|
const plugins = pluginsByOwnerGroup.get(ownerGroup) ?? [];
|
|
155
|
-
plugins.push({
|
|
181
|
+
plugins.push({
|
|
182
|
+
feature,
|
|
183
|
+
extension,
|
|
184
|
+
selectorQn,
|
|
185
|
+
pluginId: usage.entityName,
|
|
186
|
+
scopedKeys,
|
|
187
|
+
secrets,
|
|
188
|
+
});
|
|
156
189
|
pluginsByOwnerGroup.set(ownerGroup, plugins);
|
|
157
190
|
pluginFeatures.add(feature);
|
|
158
191
|
for (const s of secrets)
|
|
@@ -204,9 +237,6 @@ function buildFeatureScreensAndNavs(scope, visible, hub, gating, secretsAccess)
|
|
|
204
237
|
}
|
|
205
238
|
return out;
|
|
206
239
|
}
|
|
207
|
-
function pluginTenantScreenId(plugin) {
|
|
208
|
-
return `${plugin.feature}-tenant`;
|
|
209
|
-
}
|
|
210
240
|
function assertUniquePanelIds(ownerGroup, panels) {
|
|
211
241
|
const seen = new Set();
|
|
212
242
|
for (const panel of panels) {
|
|
@@ -218,63 +248,38 @@ function assertUniquePanelIds(ownerGroup, panels) {
|
|
|
218
248
|
}
|
|
219
249
|
return panels;
|
|
220
250
|
}
|
|
221
|
-
//
|
|
222
|
-
//
|
|
251
|
+
// One form for the provider and the settings of every provider: a single save writes
|
|
252
|
+
// all of it, so switching sections can't drop typed input. Each plugin's fields are
|
|
253
|
+
// shown only while its provider is picked in the form; hidden fields are not saved.
|
|
223
254
|
function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins, ownerPanels, hub, secretsAccess) {
|
|
224
255
|
const selectionScreenId = `${ownerGroup}-tenant-selection`;
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
submitLabel: SAVE_PROVIDER_LABEL_KEY,
|
|
230
|
-
}),
|
|
231
|
-
dormant: true,
|
|
232
|
-
};
|
|
233
|
-
const screens = [selectionScreen];
|
|
234
|
-
const configPanels = [];
|
|
235
|
-
const secretsPanels = [];
|
|
256
|
+
const form = new ProviderFormBuilder(selectionScreenId, buildScreen(selectionScreenId, "tenant", ownerGroup, ownerKeys, ownerAccess, hub, {
|
|
257
|
+
description: EXTENSION_SELECTOR_HINT_KEY,
|
|
258
|
+
sectionTitle: PROVIDER_SECTION_TITLE_KEY,
|
|
259
|
+
}));
|
|
236
260
|
const accessRules = [ownerAccess];
|
|
237
261
|
const translations = {};
|
|
238
262
|
translations[`screen:${selectionScreenId}.title`] = PROVIDER_PANEL_TITLE_ENTRY;
|
|
239
|
-
Object.assign(translations, providerOptionTranslations(
|
|
263
|
+
Object.assign(translations, providerOptionTranslations(form.base, hub));
|
|
240
264
|
for (const plugin of plugins) {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
eq: plugin.pluginId,
|
|
245
|
-
};
|
|
265
|
+
const providerFieldId = form.fieldIdOfConfigKey(plugin.selectorQn);
|
|
266
|
+
const visible = { field: providerFieldId, eq: plugin.pluginId };
|
|
267
|
+
let pluginSection;
|
|
246
268
|
if (plugin.scopedKeys.length > 0) {
|
|
247
269
|
const pluginAccess = rolesToAccess(plugin.scopedKeys.flatMap((v) => v.roles));
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
...buildScreen(pluginTenantScreenId(plugin), "tenant", plugin.feature, ordered, pluginAccess, hub, { description: null }),
|
|
251
|
-
dormant: true,
|
|
252
|
-
});
|
|
270
|
+
const pluginScreen = buildScreen(`${plugin.feature}-tenant`, "tenant", plugin.feature, sortByMaskOrder(plugin.scopedKeys.map((v) => v.key)), pluginAccess, hub, { description: null, fieldIdOf: qualifiedFieldId });
|
|
271
|
+
pluginSection = form.addConfigFields(pluginScreen, visible);
|
|
253
272
|
accessRules.push(pluginAccess);
|
|
254
|
-
configPanels.push({
|
|
255
|
-
kind: "screen",
|
|
256
|
-
id: `${plugin.feature}-config`,
|
|
257
|
-
screen: pluginTenantScreenId(plugin),
|
|
258
|
-
visibleWhen,
|
|
259
|
-
chromeless: true,
|
|
260
|
-
});
|
|
261
273
|
}
|
|
262
274
|
if (plugin.secrets.length > 0) {
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
screens.push({ ...generated.screen, dormant: true });
|
|
275
|
+
const generated = buildSecretsEditScreen(`${plugin.feature}-tenant-secrets`, plugin.secrets, secretsAccess, hub.declaredTranslationKeys);
|
|
276
|
+
form.addSecretFields(generated.screen, plugin.feature, visible, pluginSection);
|
|
266
277
|
Object.assign(translations, generated.translations);
|
|
267
278
|
accessRules.push(secretsAccess);
|
|
268
|
-
secretsPanels.push({
|
|
269
|
-
kind: "screen",
|
|
270
|
-
id: `${plugin.feature}-secrets`,
|
|
271
|
-
screen: secretsScreenId,
|
|
272
|
-
visibleWhen,
|
|
273
|
-
chromeless: true,
|
|
274
|
-
});
|
|
275
279
|
}
|
|
276
280
|
}
|
|
277
281
|
const access = unionAccessRules(accessRules);
|
|
282
|
+
const selectionScreen = { ...form.build(access), dormant: true };
|
|
278
283
|
const dashboard = {
|
|
279
284
|
id: `${ownerGroup}-tenant`,
|
|
280
285
|
type: "dashboard",
|
|
@@ -282,16 +287,136 @@ function buildSelectorOwnerDashboard(ownerGroup, ownerKeys, ownerAccess, plugins
|
|
|
282
287
|
panels: assertUniquePanelIds(ownerGroup, [
|
|
283
288
|
{ kind: "screen", id: "selection", screen: selectionScreenId, chromeless: true },
|
|
284
289
|
...ownerPanels,
|
|
285
|
-
...configPanels,
|
|
286
|
-
...secretsPanels,
|
|
287
290
|
]),
|
|
288
291
|
access,
|
|
289
292
|
};
|
|
290
|
-
return { screens: [dashboard,
|
|
293
|
+
return { screens: [dashboard, selectionScreen], access, translations };
|
|
294
|
+
}
|
|
295
|
+
// A plugin key keeps its feature in the field id: the provider form holds the keys of
|
|
296
|
+
// several features side by side.
|
|
297
|
+
function qualifiedFieldId(k) {
|
|
298
|
+
return `${k.ownerFeature}-${k.shortKey}`;
|
|
299
|
+
}
|
|
300
|
+
class ProviderFormBuilder {
|
|
301
|
+
screenId;
|
|
302
|
+
base;
|
|
303
|
+
configKeys;
|
|
304
|
+
secretKeys = {};
|
|
305
|
+
fields;
|
|
306
|
+
fieldLabels;
|
|
307
|
+
fieldDescriptions;
|
|
308
|
+
requiredFields;
|
|
309
|
+
sections;
|
|
310
|
+
constructor(screenId, base) {
|
|
311
|
+
this.screenId = screenId;
|
|
312
|
+
this.base = base;
|
|
313
|
+
this.configKeys = { ...base.configKeys };
|
|
314
|
+
this.fields = { ...base.fields };
|
|
315
|
+
this.fieldLabels = { ...base.fieldLabels };
|
|
316
|
+
this.fieldDescriptions = { ...base.fieldDescriptions };
|
|
317
|
+
this.requiredFields = [...(base.requiredFields ?? [])];
|
|
318
|
+
this.sections = base.layout.sections.filter(isFieldsEditSection);
|
|
319
|
+
}
|
|
320
|
+
fieldIdOfConfigKey(qn) {
|
|
321
|
+
const entry = Object.entries(this.base.configKeys).find(([, keyQn]) => keyQn === qn);
|
|
322
|
+
if (entry === undefined) {
|
|
323
|
+
throw new Error(`[Settings-Hub] selector key "${qn}" is not on "${this.screenId}".`);
|
|
324
|
+
}
|
|
325
|
+
return entry[0];
|
|
326
|
+
}
|
|
327
|
+
addConfigFields(pluginScreen, visible) {
|
|
328
|
+
for (const [id, qn] of Object.entries(pluginScreen.configKeys)) {
|
|
329
|
+
this.claim(id, qn);
|
|
330
|
+
this.configKeys[id] = qn;
|
|
331
|
+
const field = pluginScreen.fields[id];
|
|
332
|
+
if (field !== undefined)
|
|
333
|
+
this.fields[id] = field;
|
|
334
|
+
}
|
|
335
|
+
Object.assign(this.fieldLabels, pluginScreen.fieldLabels);
|
|
336
|
+
Object.assign(this.fieldDescriptions, pluginScreen.fieldDescriptions);
|
|
337
|
+
this.requiredFields.push(...(pluginScreen.requiredFields ?? []));
|
|
338
|
+
const section = pluginScreen.layout.sections.find(isFieldsEditSection);
|
|
339
|
+
if (section === undefined)
|
|
340
|
+
return undefined;
|
|
341
|
+
const { groups: _groups, ...flatSection } = section;
|
|
342
|
+
const gated = {
|
|
343
|
+
...flatSection,
|
|
344
|
+
fields: gateFields(sectionFieldSpecs(section), visible),
|
|
345
|
+
};
|
|
346
|
+
this.sections.push(gated);
|
|
347
|
+
return gated;
|
|
348
|
+
}
|
|
349
|
+
// A plugin's sign-in data gets its own section when the plugin names one, else it
|
|
350
|
+
// continues the plugin's settings section.
|
|
351
|
+
addSecretFields(secretsScreen, feature, visible, pluginSection) {
|
|
352
|
+
for (const [id, qn] of Object.entries(secretsScreen.secretKeys)) {
|
|
353
|
+
this.claim(id, qn);
|
|
354
|
+
this.secretKeys[id] = qn;
|
|
355
|
+
this.fields[id] = createTextField({
|
|
356
|
+
personal: false,
|
|
357
|
+
reason: "system_metadata",
|
|
358
|
+
writeOnly: true,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
Object.assign(this.fieldLabels, secretsScreen.fieldLabels);
|
|
362
|
+
Object.assign(this.fieldDescriptions, secretsScreen.fieldHints);
|
|
363
|
+
this.requiredFields.push(...(secretsScreen.requiredFields ?? []));
|
|
364
|
+
const secretsTitleKey = `${feature}.settings.secrets`;
|
|
365
|
+
for (const section of secretsScreen.sections) {
|
|
366
|
+
// kumiko-lint-ignore section-fields-raw a secretsEdit section is a flat key list without groups
|
|
367
|
+
const fields = gateFields(section.fields, visible);
|
|
368
|
+
const ownSection = section.title === secretsTitleKey || pluginSection === undefined;
|
|
369
|
+
if (ownSection) {
|
|
370
|
+
this.sections.push({
|
|
371
|
+
title: section.title ?? `${feature}.settings`,
|
|
372
|
+
description: SECRETS_SECTION_DESCRIPTION_KEY,
|
|
373
|
+
fields,
|
|
374
|
+
});
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const index = this.sections.indexOf(pluginSection);
|
|
378
|
+
this.sections[index] = {
|
|
379
|
+
...pluginSection,
|
|
380
|
+
fields: [...sectionFieldSpecs(pluginSection), ...fields],
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
build(access) {
|
|
385
|
+
const { fieldDescriptions: _baseDescriptions, requiredFields: _baseRequired, ...base } = this.base;
|
|
386
|
+
return {
|
|
387
|
+
...base,
|
|
388
|
+
configKeys: this.configKeys,
|
|
389
|
+
...(Object.keys(this.secretKeys).length > 0 && { secretKeys: this.secretKeys }),
|
|
390
|
+
fields: this.fields,
|
|
391
|
+
fieldLabels: this.fieldLabels,
|
|
392
|
+
...(Object.keys(this.fieldDescriptions).length > 0 && {
|
|
393
|
+
fieldDescriptions: this.fieldDescriptions,
|
|
394
|
+
}),
|
|
395
|
+
...(this.requiredFields.length > 0 && { requiredFields: this.requiredFields }),
|
|
396
|
+
layout: { ...base.layout, sections: this.sections },
|
|
397
|
+
access,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
claim(id, qn) {
|
|
401
|
+
const previous = this.configKeys[id] ?? this.secretKeys[id];
|
|
402
|
+
if (previous !== undefined) {
|
|
403
|
+
throw new Error(`[Settings-Hub] "${previous}" and "${qn}" both resolve to field id "${id}" on "${this.screenId}" — rename one key.`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function gateFields(fields, visible) {
|
|
408
|
+
return fields.map((spec) => typeof spec === "string" ? { field: spec, visible } : { ...spec, visible });
|
|
291
409
|
}
|
|
292
410
|
// Everything generated for one audience scope: the audience-parent nav, the
|
|
293
411
|
// per-feature configEdit screens+navs, and (tenant-scope only, when secrets
|
|
294
412
|
// are enabled) the secrets screen. Null when nothing is visible at this scope.
|
|
413
|
+
// The secrets set handler also admits the machine role for server-side writes; like the
|
|
414
|
+
// config keys' write roles, that role must not reach a screen or nav access gate.
|
|
415
|
+
function withoutMachineRole(rule) {
|
|
416
|
+
if (rule === undefined || !("roles" in rule))
|
|
417
|
+
return rule;
|
|
418
|
+
return { ...rule, roles: rule.roles.filter((role) => role !== MACHINE_WRITE_ROLE) };
|
|
419
|
+
}
|
|
295
420
|
function buildScopeResult(scope, masked, secretsEnabled, secretsWriteHandler, declaredSecrets, hub, registry) {
|
|
296
421
|
const visible = scopedKeysAt(masked, scope);
|
|
297
422
|
// Secrets attach to the tenant-audience nav regardless of whether any
|
|
@@ -300,7 +425,7 @@ function buildScopeResult(scope, masked, secretsEnabled, secretsWriteHandler, de
|
|
|
300
425
|
if (visible.length === 0 && !includeSecrets)
|
|
301
426
|
return null;
|
|
302
427
|
const configAccess = rolesToAccess(visible.flatMap((v) => v.roles));
|
|
303
|
-
const secretsAccess = secretsWriteHandler?.access;
|
|
428
|
+
const secretsAccess = withoutMachineRole(secretsWriteHandler?.access);
|
|
304
429
|
const gating = scope === "tenant"
|
|
305
430
|
? planSelectorGating(registry, visible, declaredSecrets, secretsEnabled)
|
|
306
431
|
: NO_SELECTOR_GATING;
|
|
@@ -346,6 +471,7 @@ export function buildConfigFeatureSchema(registry) {
|
|
|
346
471
|
const hub = {
|
|
347
472
|
declaredTranslationKeys: new Set(translationsByKey.keys()),
|
|
348
473
|
selectorOptions: collectSelectorOptions(registry),
|
|
474
|
+
selectorOptionFeatures: collectSelectorOptionFeatures(registry),
|
|
349
475
|
translationsByKey,
|
|
350
476
|
};
|
|
351
477
|
const screens = [];
|
|
@@ -440,7 +566,12 @@ function buildSecretsEditScreen(screenId, secrets, access, declaredTranslationKe
|
|
|
440
566
|
if (s.def.writeRoles !== undefined)
|
|
441
567
|
fieldAccess[fieldId] = { roles: [...s.def.writeRoles] };
|
|
442
568
|
}
|
|
443
|
-
|
|
569
|
+
// Opt-in `${feature}.settings.secrets`: in a selector dashboard this section sits right
|
|
570
|
+
// under the plugin's own settings section, which already carries `${feature}.settings`.
|
|
571
|
+
const secretsTitleKey = `${feature}.settings.secrets`;
|
|
572
|
+
const titleKey = declaredTranslationKeys.has(secretsTitleKey)
|
|
573
|
+
? secretsTitleKey
|
|
574
|
+
: `${feature}.settings`;
|
|
444
575
|
sections.push({
|
|
445
576
|
...(declaredTranslationKeys.has(titleKey) && { title: titleKey }),
|
|
446
577
|
fields: fieldIds,
|
|
@@ -514,7 +645,8 @@ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
|
|
|
514
645
|
// feature's group (100% of today's apps — byte-identical output). Only a
|
|
515
646
|
// cross-feature `group` (feature !== ownerFeature) needs the owner prefix,
|
|
516
647
|
// to keep two features sharing a group from clobbering the same shortKey.
|
|
517
|
-
const fieldId =
|
|
648
|
+
const fieldId = options.fieldIdOf ??
|
|
649
|
+
((k) => (k.feature === k.ownerFeature ? k.shortKey : qualifiedFieldId(k)));
|
|
518
650
|
const seenFieldIds = new Map();
|
|
519
651
|
for (const k of keys) {
|
|
520
652
|
const id = fieldId(k);
|
|
@@ -544,7 +676,6 @@ function buildScreen(shortId, scope, feature, keys, access, hub, options = {}) {
|
|
|
544
676
|
...(Object.keys(fieldDescriptions).length > 0 && { fieldDescriptions }),
|
|
545
677
|
...(requiredFields.length > 0 && { requiredFields }),
|
|
546
678
|
...(screenDescription !== undefined && { description: screenDescription }),
|
|
547
|
-
...(options.submitLabel !== undefined && { submitLabel: options.submitLabel }),
|
|
548
679
|
layout: { sections: [section], width: "full", variant: "settings-list" },
|
|
549
680
|
access,
|
|
550
681
|
};
|
|
@@ -589,16 +720,17 @@ function buildSettingsSection(feature, scope, fieldIds, hub, options) {
|
|
|
589
720
|
}
|
|
590
721
|
// Option labels of the provider select: each plugin feature's `<feature>.settings`
|
|
591
722
|
// copy, under the option-label key the configEdit renderer already derives.
|
|
592
|
-
function providerOptionTranslations(selectionScreen,
|
|
723
|
+
function providerOptionTranslations(selectionScreen, hub) {
|
|
593
724
|
const out = {};
|
|
594
725
|
for (const [fieldId, qn] of Object.entries(selectionScreen.configKeys)) {
|
|
595
|
-
|
|
726
|
+
const featureByPluginId = hub.selectorOptionFeatures.get(qn);
|
|
727
|
+
if (featureByPluginId === undefined)
|
|
596
728
|
continue;
|
|
597
|
-
for (const
|
|
598
|
-
const label = hub.translationsByKey.get(`${
|
|
729
|
+
for (const [pluginId, feature] of featureByPluginId) {
|
|
730
|
+
const label = hub.translationsByKey.get(`${feature}.settings`);
|
|
599
731
|
if (label === undefined)
|
|
600
732
|
continue;
|
|
601
|
-
const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${
|
|
733
|
+
const key = `${fieldLabelKey(SETTINGS_HUB_FEATURE, CONFIG_EDIT_ENTITY, fieldId)}:option:${pluginId}`;
|
|
602
734
|
out[key] = label;
|
|
603
735
|
}
|
|
604
736
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { EventDef, HandlerContext } from "./types/handlers.js";
|
|
1
|
+
import type { AppendEventArgs, EventDef, HandlerContext } from "./types/handlers.js";
|
|
2
2
|
export type EmitCtx = Pick<HandlerContext, "unsafeAppendEvent">;
|
|
3
3
|
export declare function emitEvent<TPayload>(ctx: EmitCtx, eventDef: EventDef<TPayload>, args: {
|
|
4
4
|
readonly aggregateId: string;
|
|
5
5
|
readonly aggregateType: string;
|
|
6
6
|
readonly payload: TPayload;
|
|
7
|
+
readonly headers?: AppendEventArgs["headers"];
|
|
7
8
|
}): Promise<void>;
|
|
8
9
|
export declare function typedPayload<TPayload>(event: {
|
|
9
10
|
readonly type: string;
|
|
@@ -21,6 +21,7 @@ export async function emitEvent(ctx, eventDef, args) {
|
|
|
21
21
|
aggregateType: args.aggregateType,
|
|
22
22
|
type: eventDef.name,
|
|
23
23
|
payload: args.payload,
|
|
24
|
+
...(args.headers !== undefined && { headers: args.headers }),
|
|
24
25
|
};
|
|
25
26
|
await ctx.unsafeAppendEvent(appendArgs);
|
|
26
27
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Registry } from "./types/feature.js";
|
|
2
|
+
import type { RegistrarExtensionRegistration } from "./types/index.js";
|
|
2
3
|
export declare const SELECTED_EXTENSIONS_QUERY = "config:query:config-value:selected-extensions";
|
|
3
4
|
export declare const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHint";
|
|
4
5
|
export declare function selectablePluginIds(registry: Pick<Registry, "getExtensionUsages">, extensionName: string): string[];
|
|
6
|
+
export declare function isSelectableInSettings(usage: RegistrarExtensionRegistration): boolean;
|
|
7
|
+
export declare function settingsSelectablePluginIds(registry: Pick<Registry, "getExtensionUsages">, extensionName: string): string[];
|
|
5
8
|
export declare function extensionSelectorTargets(registry: Pick<Registry, "getAllExtensionSelectors">): ReadonlyMap<string, string>;
|
|
@@ -5,6 +5,20 @@ export const EXTENSION_SELECTOR_HINT_KEY = "config.settings.extensionSelectorHin
|
|
|
5
5
|
export function selectablePluginIds(registry, extensionName) {
|
|
6
6
|
return [...new Set(registry.getExtensionUsages(extensionName).map((u) => u.entityName))].sort();
|
|
7
7
|
}
|
|
8
|
+
// A plugin registered with `selectableInSettings: false` (a test inbox) stays a valid
|
|
9
|
+
// stored value for seeds and dev setups, but the generated settings never offer it.
|
|
10
|
+
const SELECTABLE_IN_SETTINGS_OPTION = "selectableInSettings";
|
|
11
|
+
export function isSelectableInSettings(usage) {
|
|
12
|
+
return usage.options?.[SELECTABLE_IN_SETTINGS_OPTION] !== false;
|
|
13
|
+
}
|
|
14
|
+
export function settingsSelectablePluginIds(registry, extensionName) {
|
|
15
|
+
return [
|
|
16
|
+
...new Set(registry
|
|
17
|
+
.getExtensionUsages(extensionName)
|
|
18
|
+
.filter(isSelectableInSettings)
|
|
19
|
+
.map((u) => u.entityName)),
|
|
20
|
+
].sort();
|
|
21
|
+
}
|
|
8
22
|
export function extensionSelectorTargets(registry) {
|
|
9
23
|
const extensionByKey = new Map();
|
|
10
24
|
for (const [extensionName, selectorKey] of registry.getAllExtensionSelectors()) {
|
|
@@ -28,6 +28,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
|
|
|
28
28
|
readonly renamedFrom?: readonly string[];
|
|
29
29
|
readonly systemOnly?: boolean;
|
|
30
30
|
readonly unsubscribe?: boolean;
|
|
31
|
+
readonly tenantTransport?: boolean;
|
|
31
32
|
}), definition?: {
|
|
32
33
|
readonly trigger?: {
|
|
33
34
|
readonly on: NameOrRef;
|
|
@@ -43,6 +44,7 @@ export declare function buildConfigEventsJobsMethods<TName extends string>(state
|
|
|
43
44
|
readonly renamedFrom?: readonly string[];
|
|
44
45
|
readonly systemOnly?: boolean;
|
|
45
46
|
readonly unsubscribe?: boolean;
|
|
47
|
+
readonly tenantTransport?: boolean;
|
|
46
48
|
}): void;
|
|
47
49
|
translations(def: TranslationsDef): void;
|
|
48
50
|
defineEvent: <const TInner extends string, TPayload>(eventName: TInner, schema: ZodType<TPayload>, options: {
|
|
@@ -40,12 +40,12 @@ function assertOwnerFieldYieldsPresentString(args) {
|
|
|
40
40
|
const { featureName, eventName, field, ownerField, owner } = args;
|
|
41
41
|
if (neverYieldsString(owner)) {
|
|
42
42
|
throw new Error(`[Feature ${featureName}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${ownerField}", whose payload schema is not a string — the subject id must be a non-empty string, so every append would fail. ` +
|
|
43
|
-
`Use a string owner field, or declare whenAbsent ("tenant" | "plaintext").`);
|
|
43
|
+
`Use a string owner field, or declare whenAbsent ("tenant" | "self" | "plaintext").`);
|
|
44
44
|
}
|
|
45
45
|
if (isAbsentable(owner)) {
|
|
46
46
|
throw new Error(`[Feature ${featureName}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${ownerField}", which the payload schema allows to be null/undefined. ` +
|
|
47
47
|
`Declare what happens then: { personal: { of: "${ownerField}", whenAbsent: "tenant" } } encrypts under the envelope tenant key, ` +
|
|
48
|
-
`whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`);
|
|
48
|
+
`whenAbsent: "self" under the event's own record key, whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`);
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
// Builds config/secrets/claims/events/jobs/notifications registrar methods.
|
|
@@ -222,6 +222,7 @@ export function buildConfigEventsJobsMethods(state, name) {
|
|
|
222
222
|
: {}),
|
|
223
223
|
...(resolvedDefinition.systemOnly === true ? { systemOnly: true } : {}),
|
|
224
224
|
...(resolvedDefinition.unsubscribe === true ? { unsubscribe: true } : {}),
|
|
225
|
+
...(resolvedDefinition.tenantTransport === true ? { tenantTransport: true } : {}),
|
|
225
226
|
};
|
|
226
227
|
},
|
|
227
228
|
translations(def) {
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { isPrincipalStatusPlugin, isTenantLifecycleStatusPlugin, type PrincipalP
|
|
|
4
4
|
export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
|
|
5
5
|
export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
|
|
6
6
|
export { type CardMetaOverflow, collectCardMetaOverflow, collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, type ValidateBootOptions, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator.js";
|
|
7
|
+
export { bridgeStub } from "./bridge-stub.js";
|
|
7
8
|
export { type BuildAppSchemaOptions, buildAppSchema } from "./build-app-schema.js";
|
|
8
9
|
export type { ConfigFeatureSchema } from "./build-config-feature-schema.js";
|
|
9
10
|
export { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE, } from "./build-config-feature-schema.js";
|
package/dist/engine/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { isPrincipalStatusPlugin, isTenantLifecycleStatusPlugin, TENANT_TEARDOWN
|
|
|
5
5
|
export { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS, PII_USER_REFERENCE_NAME_HINTS, } from "./boot-validator/entity-handler.js";
|
|
6
6
|
export { entityHasAnonymizableSubjectField } from "./boot-validator/pii-retention.js";
|
|
7
7
|
export { collectCardMetaOverflow, collectWriteHandlerQns, MAX_TRANSFER_DEPTH, SECURITY_BASELINE_FEATURE_NAMES, validateAppCustomScreenWriteQns, validateBoot, } from "./boot-validator.js";
|
|
8
|
+
export { bridgeStub } from "./bridge-stub.js";
|
|
8
9
|
export { buildAppSchema } from "./build-app-schema.js";
|
|
9
10
|
export { buildConfigFeatureSchema, SETTINGS_HUB_FEATURE, SETTINGS_HUB_WORKSPACE, } from "./build-config-feature-schema.js";
|
|
10
11
|
export { buildTarget } from "./build-target.js";
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { index, integer, table as pgTable, sql, text, timestamp } from "../db/dialect.js";
|
|
13
13
|
// sql now comes from native dialect
|
|
14
14
|
import { tableExists } from "../db/index.js";
|
|
15
|
-
import { unsafePushTables } from "../
|
|
15
|
+
import { unsafePushTables } from "../db/table-push.js";
|
|
16
16
|
export const esOperationsTable = pgTable("kumiko_es_operations", {
|
|
17
17
|
// Filename without extension serves as ID. Chronologically sortable
|
|
18
18
|
// (date-prefix convention), human-meaningful, no separate hash needed.
|
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
// Guard-Script `scripts/guard-admin-api.ts` blockt Aufrufe aus App-Code —
|
|
8
8
|
// Allowlist: samples/*/migration/, scripts/migrations/, die Definition
|
|
9
9
|
// selbst, das Guard-Script selbst.
|
|
10
|
-
import { instantToTimestamptzParam } from "../bun-db/query.js";
|
|
11
10
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
12
11
|
import { claimXactId, runAtomicEventWrite } from "../db/queries/event-store.js";
|
|
13
12
|
import { eventPredecessorExists, findExistingEventVersion, insertRawEventBatch, insertRawFirstEvent, insertRawSubsequentEvent, } from "../db/queries/event-store-admin.js";
|
|
13
|
+
import { instantToTimestamptzParam } from "../db/query.js";
|
|
14
14
|
import { IdempotentAppendConflictError, VersionConflictError } from "./errors.js";
|
|
15
15
|
function mapEventUniqueViolation(e, event) {
|
|
16
16
|
if (constraintOf(e) === "events_idempotency_uq" && event.metadata.idempotencyKey) {
|
|
@@ -3,7 +3,7 @@ import { instant, table as pgTable, sql, text, uniqueIndex, uuid } from "../db/d
|
|
|
3
3
|
import { upsertArchivedStream } from "../db/queries/event-store.js";
|
|
4
4
|
import { deleteMany, fetchOne } from "../db/query.js";
|
|
5
5
|
import { tableExists } from "../db/schema-inspection.js";
|
|
6
|
-
import { unsafePushTables } from "../
|
|
6
|
+
import { unsafePushTables } from "../db/table-push.js";
|
|
7
7
|
// Marten-aligned stream archival. Archived streams become read-only: fresh
|
|
8
8
|
// appendEvent on an archived aggregate throws, and loadAggregate returns
|
|
9
9
|
// an empty slice unless the caller passes { includeArchived: true }.
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
export declare class VersionConflictError extends Error {
|
|
3
3
|
readonly aggregateId: string;
|
|
4
4
|
readonly expectedVersion: number;
|
|
5
|
-
|
|
5
|
+
readonly currentVersion: number | undefined;
|
|
6
|
+
constructor(aggregateId: string, expectedVersion: number, currentVersion?: number);
|
|
6
7
|
}
|
|
7
8
|
export declare class IdempotentAppendConflictError extends Error {
|
|
8
9
|
readonly tenantId: string;
|
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
export class VersionConflictError extends Error {
|
|
10
10
|
aggregateId;
|
|
11
11
|
expectedVersion;
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// Undefined when the conflict surfaced as a bare unique violation with no lookup possible.
|
|
13
|
+
currentVersion;
|
|
14
|
+
constructor(aggregateId, expectedVersion, currentVersion) {
|
|
15
|
+
super(`Version conflict on aggregate ${aggregateId}: expected predecessor version ${expectedVersion}` +
|
|
16
|
+
(currentVersion === undefined ? "" : `, stream is at version ${currentVersion}`));
|
|
17
|
+
this.currentVersion = currentVersion;
|
|
14
18
|
this.name = "VersionConflictError";
|
|
15
19
|
this.aggregateId = aggregateId;
|
|
16
20
|
this.expectedVersion = expectedVersion;
|
|
@@ -2,11 +2,10 @@
|
|
|
2
2
|
import { isPersonalDataGated, UNATTRIBUTED_ORIGIN, } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
3
3
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
4
4
|
import { requestContext } from "../api/request-context.js";
|
|
5
|
-
import { instantToTimestamptzParam } from "../bun-db/query.js";
|
|
6
5
|
import { encryptEventPayloadPii } from "../crypto/event-pii.js";
|
|
7
6
|
import { constraintOf, isUniqueViolation } from "../db/pg-error.js";
|
|
8
|
-
import { claimXactId, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
|
|
9
|
-
import {
|
|
7
|
+
import { claimXactId, insertFirstEventRow, insertSubsequentEventRow, notifyPgChannel, runAtomicEventWrite, selectAggregateMaxVersion, selectEventsHighWaterMark, selectStreamMaxVersion, selectStreamMaxVersions, } from "../db/queries/event-store.js";
|
|
8
|
+
import { instantToTimestamptzParam, selectMany } from "../db/query.js";
|
|
10
9
|
import { isStreamArchived } from "./archive.js";
|
|
11
10
|
import { IdempotentAppendConflictError, SeedTimeOrderError, VersionConflictError, } from "./errors.js";
|
|
12
11
|
import { eventsTable } from "./events-schema.js";
|
|
@@ -112,21 +111,21 @@ async function assertSeedTimeNotBeforePredecessor(db, event, seedCreatedAt) {
|
|
|
112
111
|
}
|
|
113
112
|
}
|
|
114
113
|
async function insertFirstEvent(db, event, newVersion, eventVersion, seedCreatedAt) {
|
|
115
|
-
const row = await
|
|
114
|
+
const row = await insertFirstEventRow(db, {
|
|
116
115
|
aggregateId: event.aggregateId,
|
|
117
116
|
aggregateType: event.aggregateType,
|
|
118
117
|
tenantId: event.tenantId,
|
|
119
|
-
|
|
118
|
+
newVersion,
|
|
120
119
|
type: event.type,
|
|
121
120
|
eventVersion,
|
|
122
121
|
payload: event.payload,
|
|
123
122
|
metadata: event.metadata,
|
|
124
123
|
createdBy: event.metadata.userId,
|
|
125
|
-
|
|
124
|
+
createdAt: seedCreatedAt && instantToTimestamptzParam(seedCreatedAt),
|
|
126
125
|
});
|
|
127
126
|
if (!row)
|
|
128
|
-
throw
|
|
129
|
-
return
|
|
127
|
+
throw await versionConflictWithCurrentVersion(db, event);
|
|
128
|
+
return insertReturnOf(row);
|
|
130
129
|
}
|
|
131
130
|
// Subsequent event — predecessor must exist AND belong to the same tenant.
|
|
132
131
|
// INSERT … SELECT … WHERE EXISTS is awkward in the typed builder, so this
|
|
@@ -147,7 +146,10 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
|
|
|
147
146
|
createdAt: seedCreatedAt && instantToTimestamptzParam(seedCreatedAt),
|
|
148
147
|
});
|
|
149
148
|
if (!row)
|
|
150
|
-
throw
|
|
149
|
+
throw await versionConflictWithCurrentVersion(db, event);
|
|
150
|
+
return insertReturnOf(row);
|
|
151
|
+
}
|
|
152
|
+
function insertReturnOf(row) {
|
|
151
153
|
const createdAt = row.created_at instanceof Date
|
|
152
154
|
? Temporal.Instant.fromEpochMilliseconds(row.created_at.getTime())
|
|
153
155
|
: Temporal.Instant.from(row.created_at);
|
|
@@ -156,6 +158,12 @@ async function insertSubsequentEvent(db, event, newVersion, eventVersion, seedCr
|
|
|
156
158
|
createdAt,
|
|
157
159
|
};
|
|
158
160
|
}
|
|
161
|
+
// Runs in the still-healthy write transaction: ON CONFLICT DO NOTHING left it
|
|
162
|
+
// unaborted, so the stream version is readable here.
|
|
163
|
+
async function versionConflictWithCurrentVersion(db, event) {
|
|
164
|
+
const currentVersion = await selectStreamMaxVersion(db, event.aggregateId, event.tenantId);
|
|
165
|
+
return new VersionConflictError(event.aggregateId, event.expectedVersion, currentVersion);
|
|
166
|
+
}
|
|
159
167
|
function buildStoredEvent(event, newVersion, eventVersion, row) {
|
|
160
168
|
return {
|
|
161
169
|
id: String(row.id),
|