@cosmicdrift/kumiko-samples 0.381.0 → 0.383.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +73 -27
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +37 -9
- package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +9 -7
- package/packages/bundled-features/src/delivery/attempt-log.ts +22 -1
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +12 -3
- package/packages/bundled-features/src/delivery/feature.ts +12 -9
- package/packages/bundled-features/src/delivery/mask-recipient-address.ts +14 -0
- package/packages/bundled-features/src/jobs/changes.json +7 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +34 -1
- package/packages/bundled-features/src/sessions/changes.json +6 -0
- package/packages/bundled-features/src/sessions/session-callbacks.ts +34 -20
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +25 -0
- package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +12 -8
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +7 -1
- package/samples/recipes/api-worker-split/README.md +8 -0
- package/samples/recipes/api-worker-split/src/feature.ts +10 -0
- package/samples/recipes/record-detail-layout/README.md +2 -2
- package/samples/recipes/record-detail-layout/src/feature.ts +8 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.383.0",
|
|
4
4
|
"description": "Source trees of the Kumiko sample recipes, sample apps and bundled features in repo layout, for tooling such as few-shot corpus builds.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.383.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Escape-hatch retention loads only needed columns, in keyset pages",
|
|
6
|
+
"detail": "The retention run groups tenants only over events older than the shortest cutoff, selects id, aggregateId, createdAt and payload, and prunes per 2000-row keyset page instead of loading every row with its full payload."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.379.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -76,8 +76,10 @@ async function resolveExplicitRetentionDays(args: {
|
|
|
76
76
|
const COMPLIANCE_PROFILE_ENTITY = "tenant-compliance-profile";
|
|
77
77
|
const PRUNE_CHUNK_SIZE = 1000;
|
|
78
78
|
const PRUNE_BATCH_SIZE = 5000;
|
|
79
|
+
const SCAN_PAGE_SIZE = 2000;
|
|
79
80
|
|
|
80
81
|
type AuditEventRow = {
|
|
82
|
+
readonly id: bigint;
|
|
81
83
|
readonly aggregateId: string;
|
|
82
84
|
readonly tenantId: TenantId;
|
|
83
85
|
readonly createdAt: Temporal.Instant;
|
|
@@ -109,12 +111,25 @@ function targetTenantOf(payload: unknown): TenantId | null {
|
|
|
109
111
|
return parsed.success ? parseTenantId(parsed.data.targetTenantId) : null;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
// No tenant's cutoff can be later than the shortest built-in retention: overrides only lengthen it.
|
|
115
|
+
function latestPossibleCutoff(now: Temporal.Instant): Temporal.Instant {
|
|
116
|
+
const cutoffs = Object.values(COMPLIANCE_PROFILES).map((profile) =>
|
|
117
|
+
subtractRetentionSpec(now, profile.auditLog.retention),
|
|
118
|
+
);
|
|
119
|
+
return cutoffs.reduce((latest, cutoff) =>
|
|
120
|
+
Temporal.Instant.compare(cutoff, latest) > 0 ? cutoff : latest,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function storingTenantIds(
|
|
125
|
+
db: DbConnection,
|
|
126
|
+
expirableBefore: Temporal.Instant,
|
|
127
|
+
): Promise<readonly TenantId[]> {
|
|
113
128
|
const groups = await aggregateWhere(
|
|
114
129
|
db,
|
|
115
130
|
eventsTable,
|
|
116
131
|
{ measure: { fn: "count" }, groupBy: [{ field: "tenantId" }] },
|
|
117
|
-
{ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE },
|
|
132
|
+
{ aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE, createdAt: { lt: expirableBefore } },
|
|
118
133
|
);
|
|
119
134
|
return groups.flatMap((group) => {
|
|
120
135
|
const tenantId = parseTenantId(group.keys[0]);
|
|
@@ -122,9 +137,24 @@ async function storingTenantIds(db: DbConnection): Promise<readonly TenantId[]>
|
|
|
122
137
|
});
|
|
123
138
|
}
|
|
124
139
|
|
|
140
|
+
async function pruneAggregates(db: DbConnection, aggregateIds: readonly string[]): Promise<number> {
|
|
141
|
+
const now = getTemporal().Now.instant();
|
|
142
|
+
let deletedCount = 0;
|
|
143
|
+
for (let i = 0; i < aggregateIds.length; i += PRUNE_CHUNK_SIZE) {
|
|
144
|
+
const result = await pruneEvents(db, {
|
|
145
|
+
aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
|
|
146
|
+
aggregateIds: aggregateIds.slice(i, i + PRUNE_CHUNK_SIZE),
|
|
147
|
+
olderThan: now,
|
|
148
|
+
});
|
|
149
|
+
deletedCount += result.deletedCount;
|
|
150
|
+
}
|
|
151
|
+
return deletedCount;
|
|
152
|
+
}
|
|
153
|
+
|
|
125
154
|
async function pruneByTenantProfiles(
|
|
126
155
|
db: DbConnection,
|
|
127
156
|
operatorRetentionDays: number | undefined,
|
|
157
|
+
scanPageSize: number,
|
|
128
158
|
): Promise<number> {
|
|
129
159
|
const now = getTemporal().Now.instant();
|
|
130
160
|
const operatorFloor =
|
|
@@ -142,34 +172,43 @@ async function pruneByTenantProfiles(
|
|
|
142
172
|
|
|
143
173
|
// The effective cutoff is never later than the storing tenant's, so only rows older than
|
|
144
174
|
// that one can expire; everything younger stays in the database.
|
|
145
|
-
|
|
146
|
-
for (const storingTenantId of await storingTenantIds(db)) {
|
|
175
|
+
let deletedCount = 0;
|
|
176
|
+
for (const storingTenantId of await storingTenantIds(db, latestPossibleCutoff(now))) {
|
|
147
177
|
const storeCutoff = await cutoffOf(storingTenantId);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
178
|
+
// Keyset on id: pruning a page only removes rows at or below the cursor.
|
|
179
|
+
let afterId: bigint | undefined;
|
|
180
|
+
for (;;) {
|
|
181
|
+
const page = await selectMany<AuditEventRow>(
|
|
182
|
+
db,
|
|
183
|
+
eventsTable,
|
|
184
|
+
{
|
|
185
|
+
aggregateType: ESCAPE_HATCH_USE_AGGREGATE_TYPE,
|
|
186
|
+
tenantId: storingTenantId,
|
|
187
|
+
createdAt: { lt: storeCutoff },
|
|
188
|
+
...(afterId !== undefined && { id: { gt: afterId } }),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
fields: ["id", "aggregateId", "createdAt", "payload"],
|
|
192
|
+
orderBy: { col: "id", direction: "asc" },
|
|
193
|
+
limit: scanPageSize,
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
const expiredAggregateIds: string[] = [];
|
|
197
|
+
for (const row of page) {
|
|
198
|
+
const targetTenantId = targetTenantOf(row.payload);
|
|
199
|
+
const cutoff = targetTenantId
|
|
200
|
+
? earlierInstant(storeCutoff, await cutoffOf(targetTenantId))
|
|
201
|
+
: storeCutoff;
|
|
202
|
+
if (Temporal.Instant.compare(row.createdAt, cutoff) < 0) {
|
|
203
|
+
expiredAggregateIds.push(row.aggregateId);
|
|
204
|
+
}
|
|
160
205
|
}
|
|
206
|
+
deletedCount += await pruneAggregates(db, expiredAggregateIds);
|
|
207
|
+
const lastRow = page[page.length - 1];
|
|
208
|
+
if (lastRow === undefined || page.length < scanPageSize) break;
|
|
209
|
+
afterId = lastRow.id;
|
|
161
210
|
}
|
|
162
211
|
}
|
|
163
|
-
|
|
164
|
-
let deletedCount = 0;
|
|
165
|
-
for (let i = 0; i < expiredAggregateIds.length; i += PRUNE_CHUNK_SIZE) {
|
|
166
|
-
const result = await pruneEvents(db, {
|
|
167
|
-
aggregateTypes: [ESCAPE_HATCH_USE_AGGREGATE_TYPE],
|
|
168
|
-
aggregateIds: expiredAggregateIds.slice(i, i + PRUNE_CHUNK_SIZE),
|
|
169
|
-
olderThan: now,
|
|
170
|
-
});
|
|
171
|
-
deletedCount += result.deletedCount;
|
|
172
|
-
}
|
|
173
212
|
return deletedCount;
|
|
174
213
|
}
|
|
175
214
|
|
|
@@ -199,6 +238,7 @@ export async function runEscapeHatchRetention(args: {
|
|
|
199
238
|
readonly configResolver: ConfigResolver | undefined;
|
|
200
239
|
readonly userId: string;
|
|
201
240
|
readonly batchSize?: number;
|
|
241
|
+
readonly scanPageSize?: number;
|
|
202
242
|
}): Promise<{ readonly deletedCount: number }> {
|
|
203
243
|
const { db } = args;
|
|
204
244
|
if (!isDbConnection(db)) {
|
|
@@ -211,5 +251,11 @@ export async function runEscapeHatchRetention(args: {
|
|
|
211
251
|
}
|
|
212
252
|
|
|
213
253
|
const operatorRetentionDays = await resolveExplicitRetentionDays({ ...args, db });
|
|
214
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
deletedCount: await pruneByTenantProfiles(
|
|
256
|
+
db,
|
|
257
|
+
operatorRetentionDays,
|
|
258
|
+
args.scanPageSize ?? SCAN_PAGE_SIZE,
|
|
259
|
+
),
|
|
260
|
+
};
|
|
215
261
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Warning when the login limiter falls back to memory in production",
|
|
6
|
+
"detail": "The in-memory login and MFA rate limiter logs one warning per process in production, because its limits are per replica."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.379.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Tenant caps list decrypts only the visible page",
|
|
6
|
+
"detail": "Without a search and with a sort other than name, the list loads light tenant rows, pages them, and fetches and decrypts names for the current page only. The default name sort and any search still decrypt every tenant, because tenant names are stored encrypted and cannot be sorted or filtered in SQL. Order, total and rows are unchanged."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.374.0",
|
|
4
10
|
"type": "breaking",
|
|
@@ -16,7 +16,7 @@ import type { CapSpec, CapUsage } from "../types.ts";
|
|
|
16
16
|
import { capFieldName } from "../web/constants.ts";
|
|
17
17
|
import { computeFraction } from "../web/usage-math.ts";
|
|
18
18
|
|
|
19
|
-
type TenantRow = { readonly id: string; readonly name
|
|
19
|
+
type TenantRow = { readonly id: string; readonly name?: string };
|
|
20
20
|
type TierAssignmentRow = {
|
|
21
21
|
readonly tenantId: string;
|
|
22
22
|
readonly tier: string;
|
|
@@ -54,6 +54,24 @@ const FILTER_OP = z.enum(["eq", "ne", "lt", "gt", "in"]);
|
|
|
54
54
|
type FilterOp = z.infer<typeof FILTER_OP>;
|
|
55
55
|
|
|
56
56
|
const SORTABLE_FIELDS = ["name", "tier", "billing"] as const;
|
|
57
|
+
const DEFAULT_SORT_FIELD = "name";
|
|
58
|
+
|
|
59
|
+
async function withDecryptedPageNames<TRow extends { readonly tenantId: string }>(
|
|
60
|
+
db: Parameters<typeof selectMany>[0],
|
|
61
|
+
pageRows: readonly TRow[],
|
|
62
|
+
): Promise<(TRow & { readonly name: string })[]> {
|
|
63
|
+
const decrypted = await decryptTenantNames(
|
|
64
|
+
await selectMany<TenantRow>(
|
|
65
|
+
db,
|
|
66
|
+
tenantTable,
|
|
67
|
+
{ id: { in: pageRows.map((row) => row.tenantId) } },
|
|
68
|
+
{ fields: ["id", "name"] },
|
|
69
|
+
),
|
|
70
|
+
"cap-overview:tenant-caps:list",
|
|
71
|
+
);
|
|
72
|
+
const nameById = new Map(decrypted.map((tenant) => [tenant.id, tenant.name ?? ""]));
|
|
73
|
+
return pageRows.map((row) => ({ ...row, name: nameById.get(row.tenantId) ?? "" }));
|
|
74
|
+
}
|
|
57
75
|
|
|
58
76
|
function tenantCapKey(tenantId: string, capId: string): string {
|
|
59
77
|
return JSON.stringify([tenantId, capId]);
|
|
@@ -163,11 +181,18 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
163
181
|
"cap-overview:tenant-caps:list — SystemAdmin platform-wide tenant overview",
|
|
164
182
|
);
|
|
165
183
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
"
|
|
170
|
-
|
|
184
|
+
// Names are ciphertext at rest: only a name sort or search has to decrypt every tenant;
|
|
185
|
+
// otherwise rows stay name-less and just the page is decrypted afterwards.
|
|
186
|
+
const needsAllNames =
|
|
187
|
+
(query.payload.sort ?? DEFAULT_SORT_FIELD) === "name" ||
|
|
188
|
+
(query.payload.search?.trim() ?? "") !== "";
|
|
189
|
+
const tenants = needsAllNames
|
|
190
|
+
? await decryptTenantNames(
|
|
191
|
+
// Destroyed tenants decrypt to the erased sentinel, which a picker or list would show verbatim.
|
|
192
|
+
await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }),
|
|
193
|
+
"cap-overview:tenant-caps:list",
|
|
194
|
+
)
|
|
195
|
+
: await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }, { fields: ["id"] });
|
|
171
196
|
const assignments = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {});
|
|
172
197
|
const subscriptions = await selectMany<SubscriptionRow>(db, subscriptionsProjectionTable, {});
|
|
173
198
|
|
|
@@ -182,7 +207,7 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
182
207
|
return {
|
|
183
208
|
id: tenant.id,
|
|
184
209
|
tenantId: tenant.id,
|
|
185
|
-
name: tenant.name,
|
|
210
|
+
name: tenant.name ?? "",
|
|
186
211
|
tier: assignment?.tier ?? "",
|
|
187
212
|
billing: billingLabel(subscriptionByTenant.get(tenant.id), assignment?.source ?? null),
|
|
188
213
|
};
|
|
@@ -194,10 +219,13 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
|
|
|
194
219
|
// tenant/team-list.query.ts. Holds up to tier-engine's documented
|
|
195
220
|
// "single-pass scan" scale (a few thousand tenants); past that the fix
|
|
196
221
|
// is a real combined read-projection.
|
|
197
|
-
const { rows:
|
|
222
|
+
const { rows: pageWithoutNames, total } = pageRows(merged, query.payload, {
|
|
198
223
|
searchFields: ["name"],
|
|
199
|
-
defaultSort:
|
|
224
|
+
defaultSort: DEFAULT_SORT_FIELD,
|
|
200
225
|
});
|
|
226
|
+
const page = needsAllNames
|
|
227
|
+
? pageWithoutNames
|
|
228
|
+
: await withDecryptedPageNames(db, pageWithoutNames);
|
|
201
229
|
|
|
202
230
|
// Usage is computed only for this page's tenant ids, never for the
|
|
203
231
|
// full tenant set; caps without usageBatch still cost one query per
|
|
@@ -34,20 +34,22 @@ export const tenantsMissingProfileQuery = defineQueryHandler({
|
|
|
34
34
|
"platform operator scans every tenant for a missing compliance-profile selection",
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
);
|
|
37
|
+
const tenantRows = await db.selectMany<{ id: TenantId; name: string }>(tenantTable, {
|
|
38
|
+
isEnabled: true,
|
|
39
|
+
});
|
|
41
40
|
const profileRows = await db.selectMany<{ tenantId: TenantId }>(
|
|
42
41
|
tenantComplianceProfileTable,
|
|
43
42
|
{},
|
|
44
43
|
);
|
|
45
44
|
const tenantIdsWithProfile = new Set(profileRows.map((row) => row.tenantId));
|
|
45
|
+
// Only tenants that stay in the answer need their name decrypted.
|
|
46
|
+
const tenants = await decryptTenantNames(
|
|
47
|
+
tenantRows.filter((tenant) => !tenantIdsWithProfile.has(tenant.id)),
|
|
48
|
+
"compliance-profiles-ops:tenants-missing-profile",
|
|
49
|
+
);
|
|
46
50
|
|
|
47
51
|
return {
|
|
48
|
-
tenants: tenants
|
|
49
|
-
.filter((tenant) => !tenantIdsWithProfile.has(tenant.id))
|
|
50
|
-
.map((tenant) => ({ id: tenant.id, name: tenant.name })),
|
|
52
|
+
tenants: tenants.map((tenant) => ({ id: tenant.id, name: tenant.name })),
|
|
51
53
|
};
|
|
52
54
|
},
|
|
53
55
|
});
|
|
@@ -10,7 +10,7 @@ import { runProjectionsForEvent } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
|
10
10
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
11
11
|
import { DELIVERY_ATTEMPT_EVENT, DELIVERY_CHANNEL_EXTENSION } from "./constants.ts";
|
|
12
12
|
import { deliveryAttemptSchema } from "./events.ts";
|
|
13
|
-
import { maskRecipientAddress } from "./mask-recipient-address.ts";
|
|
13
|
+
import { maskRecipientAddress, maskRecipientAddressOnce } from "./mask-recipient-address.ts";
|
|
14
14
|
import { type DeliveryLogEntry, isDeliveryChannelPlugin } from "./types.ts";
|
|
15
15
|
|
|
16
16
|
// Unknown channels count as personal: masking is the fail-closed default.
|
|
@@ -31,6 +31,27 @@ function loggedAddress(registry: Registry, entry: DeliveryLogEntry): string | nu
|
|
|
31
31
|
: maskRecipientAddress(entry.recipientAddress);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Projection-side counterpart of loggedAddress: legacy events were appended with the full address.
|
|
35
|
+
export function rememberConnectionNameChannel(
|
|
36
|
+
connectionNameChannels: Set<string>,
|
|
37
|
+
channelName: string,
|
|
38
|
+
channelOptions: unknown,
|
|
39
|
+
): void {
|
|
40
|
+
if (isDeliveryChannelPlugin(channelOptions) && channelOptions.addressKind === "connection-name") {
|
|
41
|
+
connectionNameChannels.add(channelName);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function projectedAttemptAddress(
|
|
46
|
+
connectionNameChannels: ReadonlySet<string>,
|
|
47
|
+
channel: string,
|
|
48
|
+
recipientAddress: string | null,
|
|
49
|
+
): string | null {
|
|
50
|
+
return connectionNameChannels.has(channel)
|
|
51
|
+
? recipientAddress
|
|
52
|
+
: maskRecipientAddressOnce(recipientAddress);
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
// Shared append + inline-projection write (low-level append() does not
|
|
35
56
|
// auto-fire projections — only the dispatcher/executor paths do).
|
|
36
57
|
async function writeAttemptEvent(
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "delivery-log projection masks the recipient address",
|
|
6
|
+
"detail": "The inline projection now masks the address with the same helper as the write path, also on rebuild, so legacy events with a full address no longer reach the attempt log. Already masked addresses and connection-name channels stay unchanged. Swallowed errors in the snapshot save, queue-depth poll and channel pre-resolve now log a scrubbed warning."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.379.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -19,7 +19,11 @@ import {
|
|
|
19
19
|
createSystemUser,
|
|
20
20
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
21
|
import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
createFallbackLogger,
|
|
24
|
+
type Logger,
|
|
25
|
+
scrubLogText,
|
|
26
|
+
} from "@cosmicdrift/kumiko-framework/logging";
|
|
23
27
|
import {
|
|
24
28
|
assertNotifyTargetsOwnTenant,
|
|
25
29
|
createEscapeHatchReporter,
|
|
@@ -652,8 +656,13 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
|
|
|
652
656
|
gates.set(key, gate);
|
|
653
657
|
if (gate !== "open") continue;
|
|
654
658
|
addresses.set(key, await channel.resolve(userId, channelCtx));
|
|
655
|
-
} catch {
|
|
656
|
-
//
|
|
659
|
+
} catch (err) {
|
|
660
|
+
// The per-recipient delivery path resolves again and decides the outcome; this only surfaces the early failure.
|
|
661
|
+
logError.warn("channel pre-resolve failed", {
|
|
662
|
+
channel: channel.name,
|
|
663
|
+
notificationType,
|
|
664
|
+
error: scrubLogText(err instanceof Error ? err.message : String(err)),
|
|
665
|
+
});
|
|
657
666
|
}
|
|
658
667
|
}
|
|
659
668
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import { ConsumerLagError } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
10
10
|
import type * as z from "zod";
|
|
11
|
+
import { projectedAttemptAddress, rememberConnectionNameChannel } from "./attempt-log.ts";
|
|
11
12
|
import { resolveAttemptLogRetentionDays, runAttemptLogRetention } from "./attempt-log-retention.ts";
|
|
12
13
|
import {
|
|
13
14
|
DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT,
|
|
@@ -120,6 +121,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
120
121
|
// drain needed in tests. Chosen over a MultiStreamProjection because
|
|
121
122
|
// delivery-log is a hot read-path for admin/audit UIs that expect
|
|
122
123
|
// read-your-own-write semantics.
|
|
124
|
+
const connectionNameChannels = new Set<string>();
|
|
123
125
|
r.projection({
|
|
124
126
|
name: "delivery-log",
|
|
125
127
|
source: "deliveryAttempt",
|
|
@@ -127,6 +129,11 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
127
129
|
apply: {
|
|
128
130
|
[DELIVERY_ATTEMPT_EVENT]: async (event, tx) => {
|
|
129
131
|
const p = event.payload as z.infer<typeof deliveryAttemptSchema>; // @cast-boundary engine-payload
|
|
132
|
+
const projectedAddress = projectedAttemptAddress(
|
|
133
|
+
connectionNameChannels,
|
|
134
|
+
p.channel,
|
|
135
|
+
p.recipientAddress,
|
|
136
|
+
);
|
|
130
137
|
// PK = aggregateId. An async attempt accrues multiple events on one
|
|
131
138
|
// stream (queued → sent/failed): the first INSERTs, later events
|
|
132
139
|
// UPDATE the same row. Events arrive in version order, so the last
|
|
@@ -140,7 +147,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
140
147
|
notificationType: p.notificationType,
|
|
141
148
|
channel: p.channel,
|
|
142
149
|
recipientId: p.recipientId,
|
|
143
|
-
recipientAddress:
|
|
150
|
+
recipientAddress: projectedAddress,
|
|
144
151
|
status: p.status,
|
|
145
152
|
error: p.error,
|
|
146
153
|
priority: p.priority,
|
|
@@ -149,7 +156,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
149
156
|
{
|
|
150
157
|
status: p.status,
|
|
151
158
|
error: p.error,
|
|
152
|
-
recipientAddress:
|
|
159
|
+
recipientAddress: projectedAddress,
|
|
153
160
|
confirmed: p.confirmed ?? null,
|
|
154
161
|
},
|
|
155
162
|
);
|
|
@@ -165,14 +172,10 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
|
|
|
165
172
|
},
|
|
166
173
|
});
|
|
167
174
|
|
|
168
|
-
//
|
|
169
|
-
// Extension-Point lebt jetzt im `renderer-foundation`-Bundle als
|
|
170
|
-
// `renderer` (Multi-Kind-Plugin-Contract). delivery hostet keinen
|
|
171
|
-
// eigenen mehr — channel-email nimmt renderer als direkte
|
|
172
|
-
// Konstruktor-Option (siehe email-channel.ts), nicht via Extension-
|
|
173
|
-
// Usage. Migration 2026-05-19.
|
|
175
|
+
// Connection-name channels address a configured connection, not a person, so they stay unmasked.
|
|
174
176
|
r.extendsRegistrar(DELIVERY_CHANNEL_EXTENSION, {
|
|
175
|
-
onRegister: () =>
|
|
177
|
+
onRegister: (channelName, channelOptions) =>
|
|
178
|
+
rememberConnectionNameChannel(connectionNameChannels, channelName, channelOptions),
|
|
176
179
|
});
|
|
177
180
|
|
|
178
181
|
// Async delivery pipeline for queued-mode channels. render decouples the
|
|
@@ -38,3 +38,17 @@ function maskStructuredOrTail(address: string): string {
|
|
|
38
38
|
if (address.length < MIN_LENGTH_FOR_VISIBLE_TAIL) return MASK;
|
|
39
39
|
return `${MASK}${address.slice(-VISIBLE_TAIL_LENGTH)}`;
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
// Masked output is itself a valid input; the projection sees both fresh (already masked) and legacy (full) events.
|
|
43
|
+
function isMaskedRecipientAddress(address: string): boolean {
|
|
44
|
+
if (address.startsWith(MASK) && address.length <= MASK.length + VISIBLE_TAIL_LENGTH) return true;
|
|
45
|
+
if (address.endsWith(`/${MASK}`)) return true;
|
|
46
|
+
return /^[^@]\*\*\*@[^@]+$/.test(address);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Ciphertext passes through: it is decrypted at read time and a key erase removes it.
|
|
50
|
+
export function maskRecipientAddressOnce(address: string | null): string | null {
|
|
51
|
+
if (address === null || isPiiCiphertext(address)) return address;
|
|
52
|
+
if (isMaskedRecipientAddress(address)) return address;
|
|
53
|
+
return maskPlainAddress(address);
|
|
54
|
+
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Job handlers can return ctx.defer(ms)",
|
|
6
|
+
"detail": "A handler that returns ctx.defer(ms) moves the queue job to delayed and runs again after ms, like the maxPerTenant defer. It consumes no retry attempt and records no failure, failure log or failed run; the run row of the deferred pass is removed and a tenantVisibleRun record goes back to queued. maxDefers on the job (default 100, shared counter with maxPerTenant defers) bounds the loop; exceeding it fails the run for good (no retry) with a clear message. Inline runs (boot gates) throw. New run-logger hook onJobDeferred, passed through the entrypoint jobs options. JobHandlerFn may now return a JobDeferral. JobContext has a required defer member.",
|
|
7
|
+
"migration": "add defer to every hand-built JobContext object (tests, seeds), e.g. defer: (delayMs) => ({ kind: \"job-deferral\", delayMs })"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.365.0",
|
|
4
11
|
"type": "fix",
|
|
@@ -59,7 +59,7 @@ export type JobRunLoggerOptions = {
|
|
|
59
59
|
|
|
60
60
|
export type JobRunLoggerCallbacks = Pick<
|
|
61
61
|
JobRunnerOptions,
|
|
62
|
-
"onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped"
|
|
62
|
+
"onJobStart" | "onJobComplete" | "onJobFailed" | "onJobDeferred" | "onJobQueued" | "onJobDropped"
|
|
63
63
|
>;
|
|
64
64
|
|
|
65
65
|
// Default cap on the bullJobId → runId cache. A worker that starts jobs
|
|
@@ -362,6 +362,25 @@ async function recordTenantRunOutcome(
|
|
|
362
362
|
});
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
+
async function requeueTenantRun(
|
|
366
|
+
db: DbConnection,
|
|
367
|
+
jobName: string,
|
|
368
|
+
bullJobId: string,
|
|
369
|
+
meta: JobMeta,
|
|
370
|
+
): Promise<void> {
|
|
371
|
+
// skip: job did not opt in to tenantVisibleRun
|
|
372
|
+
if (!meta.tenantVisibleRun) return;
|
|
373
|
+
const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
|
|
374
|
+
// skip: system-tenant run, no tenant-scoped query reads it
|
|
375
|
+
if (!key) return;
|
|
376
|
+
await updateMany(
|
|
377
|
+
db,
|
|
378
|
+
tenantJobRunsTable,
|
|
379
|
+
{ status: "queued", startedAt: null, finishedAt: null, updatedAt: Temporal.Now.instant() },
|
|
380
|
+
{ tenantId: key.tenantId, jobName, bullJobId },
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
365
384
|
async function dropTenantRunQueued(
|
|
366
385
|
db: DbConnection,
|
|
367
386
|
jobName: string,
|
|
@@ -501,6 +520,20 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
501
520
|
);
|
|
502
521
|
},
|
|
503
522
|
|
|
523
|
+
// A deferred run is neither success nor failure: the same job id starts a fresh run later.
|
|
524
|
+
onJobDeferred: async (jobName: string, bullJobId: string, meta: JobMeta) => {
|
|
525
|
+
await isolateTenantRunWrite(
|
|
526
|
+
() => requeueTenantRun(db, jobName, bullJobId, meta),
|
|
527
|
+
jobName,
|
|
528
|
+
bullJobId,
|
|
529
|
+
);
|
|
530
|
+
const resolved = await resolveRun(bullJobId);
|
|
531
|
+
// skip: run row already gone (cache evicted and row unreachable)
|
|
532
|
+
if (!resolved) return;
|
|
533
|
+
await deleteMany(db, jobRunsTable, { id: resolved.runId });
|
|
534
|
+
runIdByBullJobId.delete(bullJobId);
|
|
535
|
+
},
|
|
536
|
+
|
|
504
537
|
onJobStart: async (jobName: string, bullJobId: string, meta: JobMeta) => {
|
|
505
538
|
await isolateTenantRunWrite(
|
|
506
539
|
() => recordTenantRunStarted(db, jobName, bullJobId, meta),
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "sessionChecker looks up the user and the membership concurrently",
|
|
6
|
+
"detail": "The session check starts the user lookup together with the session read and the membership lookup together with the last-seen refresh. Results and the fail-open behavior are unchanged; a failed lookup that is not needed no longer raises an unhandled rejection."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.336.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -39,6 +39,15 @@ import {
|
|
|
39
39
|
// Re-exported so existing `../sessions` importers keep working unchanged.
|
|
40
40
|
export { isPrincipalBlocked };
|
|
41
41
|
|
|
42
|
+
type SettledLookup<T> = { readonly ok: true; readonly value: T } | { readonly ok: false };
|
|
43
|
+
|
|
44
|
+
function settleLookup<T>(lookup: Promise<T>): Promise<SettledLookup<T>> {
|
|
45
|
+
return lookup.then(
|
|
46
|
+
(value): SettledLookup<T> => ({ ok: true, value }),
|
|
47
|
+
(): SettledLookup<T> => ({ ok: false }),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
// Why the callbacks live at the raw-DB level rather than going through the
|
|
43
52
|
// dispatcher: session-create/revoke/check run on the hot path of every
|
|
44
53
|
// login and every request. The (createdAt/revokedAt/ip/userAgent) columns
|
|
@@ -145,6 +154,14 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
145
154
|
|
|
146
155
|
// kumiko-lint-ignore complexity-budget session check + lastSeen refresh on hot path
|
|
147
156
|
async sessionChecker(sid: string, expectedUserId: string): Promise<AuthSessionCheckResult> {
|
|
157
|
+
// The user lookup is keyed by the JWT's userId, not the session row, so it
|
|
158
|
+
// overlaps the session read. Settled eagerly: a throwing session read must
|
|
159
|
+
// not leave this promise as an unhandled rejection.
|
|
160
|
+
const userLookup = settleLookup(
|
|
161
|
+
fetchOne<{ status: UserStatus; isDeleted: boolean; roles: string | null }>(db, userTable, {
|
|
162
|
+
id: expectedUserId,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
148
165
|
const row = await fetchOne<{
|
|
149
166
|
userId: string;
|
|
150
167
|
tenantId: TenantId;
|
|
@@ -165,6 +182,15 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
165
182
|
return "expired";
|
|
166
183
|
}
|
|
167
184
|
|
|
185
|
+
// Membership is keyed by the session's tenantId, so it can only start now;
|
|
186
|
+
// it overlaps the user lookup and the lastSeen refresh.
|
|
187
|
+
const membershipLookup = settleLookup(
|
|
188
|
+
fetchOne<{ roles: string | null }>(db, tenantMembershipsTable, {
|
|
189
|
+
userId: expectedUserId,
|
|
190
|
+
tenantId: row.tenantId,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
168
194
|
// Hourly, not per-request: this check runs on every authenticated
|
|
169
195
|
// request, so a write here has to stay off the hot path.
|
|
170
196
|
const nowMs = Temporal.Now.instant().epochMilliseconds;
|
|
@@ -195,16 +221,9 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
195
221
|
// missing row is a legitimate "no tenant roles" outcome. Both branches
|
|
196
222
|
// return the bare "live" string (no re-derived roles) — the middleware
|
|
197
223
|
// falls back to the JWT's frozen roles claim.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
db,
|
|
202
|
-
userTable,
|
|
203
|
-
{ id: expectedUserId },
|
|
204
|
-
);
|
|
205
|
-
} catch {
|
|
206
|
-
return "live";
|
|
207
|
-
}
|
|
224
|
+
const userResult = await userLookup;
|
|
225
|
+
if (!userResult.ok) return "live";
|
|
226
|
+
const user = userResult.value;
|
|
208
227
|
if (!user) return "live";
|
|
209
228
|
if (isPrincipalRowBlocked(user)) return "blocked";
|
|
210
229
|
|
|
@@ -212,16 +231,11 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
212
231
|
// DB error on the membership read must not lock the user out. A
|
|
213
232
|
// missing row (not a throw) is a valid outcome: the user genuinely has
|
|
214
233
|
// no tenant-scoped roles for row.tenantId, so membershipRoles is [].
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
});
|
|
221
|
-
membershipRoles = membership ? parseRoles(membership.roles) : [];
|
|
222
|
-
} catch {
|
|
223
|
-
return "live";
|
|
224
|
-
}
|
|
234
|
+
const membershipResult = await membershipLookup;
|
|
235
|
+
if (!membershipResult.ok) return "live";
|
|
236
|
+
const membershipRoles = membershipResult.value
|
|
237
|
+
? parseRoles(membershipResult.value.roles)
|
|
238
|
+
: [];
|
|
225
239
|
|
|
226
240
|
const globalRoles = parseRoles(user.roles);
|
|
227
241
|
const roles = buildSessionRoles(globalRoles, membershipRoles);
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.383.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Tenant directory search stops decrypting once the limit is reached",
|
|
6
|
+
"detail": "Tenant names are encrypted and not in the search index, so the directory search decrypts in small batches and stops after limit matches; the 1000-row scan cap stays. With more matches than the limit, the first matches in key order are returned (sorted by label), not the best alphabetical ones. The missing-profile list drops tenants that already have a profile before decrypting."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.365.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -15,3 +15,28 @@ export async function decryptTenantNames<T extends { readonly name?: unknown }>(
|
|
|
15
15
|
: row,
|
|
16
16
|
);
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
// Tenant names are not in the search index (find: "none"), so a name search has to decrypt;
|
|
20
|
+
// stopping at `limit` matches keeps a typed prefix from decrypting the whole scan window.
|
|
21
|
+
export async function takeTenantsMatchingDecryptedName<
|
|
22
|
+
T extends { readonly id: unknown; readonly name?: unknown },
|
|
23
|
+
>(
|
|
24
|
+
rows: readonly T[],
|
|
25
|
+
labelMatches: (label: string) => boolean,
|
|
26
|
+
limit: number,
|
|
27
|
+
fallbackRequestId: string,
|
|
28
|
+
): Promise<T[]> {
|
|
29
|
+
const batchSize = Math.max(limit, KMS_POOL_CONCURRENCY);
|
|
30
|
+
const matches: T[] = [];
|
|
31
|
+
for (let start = 0; start < rows.length && matches.length < limit; start += batchSize) {
|
|
32
|
+
const decrypted = await decryptTenantNames(
|
|
33
|
+
rows.slice(start, start + batchSize),
|
|
34
|
+
fallbackRequestId,
|
|
35
|
+
);
|
|
36
|
+
for (const row of decrypted) {
|
|
37
|
+
const label = typeof row.name === "string" ? row.name : String(row.id);
|
|
38
|
+
if (labelMatches(label)) matches.push(row);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return matches.slice(0, limit);
|
|
42
|
+
}
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
7
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import * as z from "zod";
|
|
9
|
-
import { decryptTenantNames } from "../decrypt-tenant-names.ts";
|
|
9
|
+
import { decryptTenantNames, takeTenantsMatchingDecryptedName } from "../decrypt-tenant-names.ts";
|
|
10
10
|
import { tenantTable } from "../schema/tenant.ts";
|
|
11
11
|
import { isSystemAdmin } from "./is-system-admin.ts";
|
|
12
12
|
|
|
@@ -60,18 +60,22 @@ export const tenantDirectoryQuery = definePagedQueryHandler({
|
|
|
60
60
|
}
|
|
61
61
|
// name is ciphertext at rest, so the SQL page is keyed by the plaintext
|
|
62
62
|
// `key`; label order and search happen in memory after decrypting.
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
const needle = search?.toLowerCase();
|
|
64
|
+
const named =
|
|
65
|
+
needle === undefined
|
|
66
|
+
? await decryptTenantNames(tenants, "tenant:tenant-directory")
|
|
67
|
+
: await takeTenantsMatchingDecryptedName(
|
|
68
|
+
tenants,
|
|
69
|
+
(label) => label.toLowerCase().includes(needle),
|
|
70
|
+
limit,
|
|
71
|
+
"tenant:tenant-directory",
|
|
72
|
+
);
|
|
73
|
+
const rows = named
|
|
65
74
|
.map((tenant) => {
|
|
66
75
|
const id = String(tenant.id);
|
|
67
76
|
return { id, label: typeof tenant.name === "string" ? tenant.name : id };
|
|
68
77
|
})
|
|
69
78
|
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
|
|
70
|
-
const needle = search?.toLowerCase();
|
|
71
|
-
const rows =
|
|
72
|
-
needle === undefined
|
|
73
|
-
? labeled
|
|
74
|
-
: labeled.filter((row) => row.label.toLowerCase().includes(needle)).slice(0, limit);
|
|
75
79
|
return { rows, nextCursor: null };
|
|
76
80
|
},
|
|
77
81
|
});
|
|
@@ -171,7 +171,13 @@ export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
|
|
|
171
171
|
statusToneField: "status",
|
|
172
172
|
statusTones: { active: "ok", terminated: "bad" },
|
|
173
173
|
},
|
|
174
|
-
metrics: [
|
|
174
|
+
metrics: [
|
|
175
|
+
{ field: "grundmiete", label: "rental:entity:lease:field:grundmiete" },
|
|
176
|
+
{ field: "beginn", label: "rental:entity:lease:field:beginn" },
|
|
177
|
+
{ field: "kuendigungsfrist", label: "rental:entity:lease:field:kuendigungsfrist" },
|
|
178
|
+
{ field: "zahltag", label: "rental:entity:lease:field:zahltag" },
|
|
179
|
+
{ field: "mieter", label: "rental:entity:lease:field:mieter" },
|
|
180
|
+
],
|
|
175
181
|
fieldLabels: {
|
|
176
182
|
grundmiete: "rental:entity:lease:field:grundmiete",
|
|
177
183
|
beginn: "rental:entity:lease:field:beginn",
|
|
@@ -54,6 +54,14 @@ With only the API running, `read_orders` gains the row but
|
|
|
54
54
|
`read_order_activity` stays empty. Start the worker and both the activity
|
|
55
55
|
row and the `read_fulfillments` row appear.
|
|
56
56
|
|
|
57
|
+
## Deferring a job
|
|
58
|
+
|
|
59
|
+
`process-order` returns `context.defer(ms)` while the (simulated) carrier is busy. The job
|
|
60
|
+
is re-run after `ms` on the same queue job: no retry attempt is used, no failure is logged
|
|
61
|
+
or recorded, and the run log shows only the runs that actually executed. `maxDefers`
|
|
62
|
+
(default 100) bounds it; beyond that the run fails. It needs a queued run: an inline boot
|
|
63
|
+
gate throws.
|
|
64
|
+
|
|
57
65
|
## Test
|
|
58
66
|
|
|
59
67
|
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
@@ -84,6 +84,13 @@ export function setOrderFulfillWrite(fn: FulfillWrite): void {
|
|
|
84
84
|
fulfillWrite = fn;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
let carrierRetryAfterMs: () => number = () => 0;
|
|
88
|
+
|
|
89
|
+
/** Stand-in for a rate-limited carrier API: ms until it accepts work again, 0 when free. */
|
|
90
|
+
export function setCarrierRetryAfter(fn: () => number): void {
|
|
91
|
+
carrierRetryAfterMs = fn;
|
|
92
|
+
}
|
|
93
|
+
|
|
87
94
|
// Job payloads arrive as `Record<string, unknown>` (JobHandlerFn) — an
|
|
88
95
|
// unchecked `as string` cast here would let a missing/non-string field
|
|
89
96
|
// through silently, writing `orderKey: undefined` / `label: "label-
|
|
@@ -123,6 +130,9 @@ export function createApiWorkerSplitFeature() {
|
|
|
123
130
|
);
|
|
124
131
|
}
|
|
125
132
|
const { customerName } = processOrderPayloadSchema.parse(payload);
|
|
133
|
+
// Busy is not a failure: ctx.defer re-runs the job later without burning a retry.
|
|
134
|
+
const retryAfterMs = carrierRetryAfterMs();
|
|
135
|
+
if (retryAfterMs > 0) return context.defer(retryAfterMs);
|
|
126
136
|
const result = await fulfillWrite({
|
|
127
137
|
handlerQn: "orders:write:fulfillment:create",
|
|
128
138
|
payload: {
|
|
@@ -5,12 +5,12 @@ header, a metrics band, and a tabbed layout instead of one long stacked form.
|
|
|
5
5
|
|
|
6
6
|
## What it shows
|
|
7
7
|
|
|
8
|
-
- `header` (title/subtitle/status) and `metrics` (
|
|
8
|
+
- `header` (title/subtitle/status) and `metrics` (each with its own i18n `label`)
|
|
9
9
|
rendered from the columns of the screen's own query row
|
|
10
10
|
- `layout.mode: "tabs"` with two `relatedList` tabs (order items, payments)
|
|
11
11
|
and one `fields` tab (master data) — each `relatedList` runs its own query
|
|
12
12
|
- boot-validator catching two common author mistakes: a `metrics` entry
|
|
13
|
-
|
|
13
|
+
with an empty `label`, and a `tabs` layout with fewer than two
|
|
14
14
|
sections
|
|
15
15
|
|
|
16
16
|
## Source
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// 1. `header` renders the query row's customer/order/status columns above
|
|
4
4
|
// the layout — the identity a support agent scans first.
|
|
5
5
|
// 2. `metrics` renders a labeled band of key numbers from the same row —
|
|
6
|
-
//
|
|
7
|
-
// column name.
|
|
6
|
+
// each metric carries its own i18n `label`, there is no fallback to the
|
|
7
|
+
// raw column name.
|
|
8
8
|
// 3. `layout.mode: "tabs"` splits the record into four tabs: two
|
|
9
9
|
// `relatedList` sections (own query, own pagination, mounts only when
|
|
10
10
|
// active), one `fields` section (master data from the detail row), and
|
|
@@ -76,13 +76,12 @@ export function createOrderDeskFeature(): FeatureDefinition {
|
|
|
76
76
|
type: "projectionDetail",
|
|
77
77
|
query: "order-desk:query:order:detail",
|
|
78
78
|
header: { title: "customerName", subtitle: "orderNumber", status: "status" },
|
|
79
|
-
metrics: [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
},
|
|
79
|
+
metrics: [
|
|
80
|
+
{ field: "totalAmount", label: "order-desk.metric.totalAmount" },
|
|
81
|
+
{ field: "outstandingAmount", label: "order-desk.metric.outstandingAmount" },
|
|
82
|
+
{ field: "itemCount", label: "order-desk.metric.itemCount" },
|
|
83
|
+
{ field: "placedAt", label: "order-desk.metric.placedAt" },
|
|
84
|
+
],
|
|
86
85
|
layout: {
|
|
87
86
|
mode: "tabs",
|
|
88
87
|
sections: [
|