@cosmicdrift/kumiko-samples 0.339.0 → 0.340.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/billing-foundation/changes.json +7 -0
- package/packages/bundled-features/src/billing-foundation/projection.ts +35 -41
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +57 -58
- package/packages/bundled-features/src/user/changes.json +13 -6
- package/packages/bundled-features/src/user/handlers/update-roles.ts +7 -23
- package/packages/bundled-features/src/workflow-runner/changes.json +7 -0
- package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +10 -13
- package/packages/bundled-features/src/workflow-runner/feature.ts +4 -11
- package/packages/bundled-features/src/billing-foundation/db/queries/payment-projection.ts +0 -19
- package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts +0 -15
- package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-samples",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.340.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.340.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,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.340.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Payment and subscription projections use typed bun-db helpers",
|
|
6
|
+
"detail": "`payment-received` inserts through `insertOnConflictDoNothing`, subscription events upsert through `upsertOnConflict`. The raw `db/queries/payment-projection.ts` and `subscription-projection.ts` modules are gone.",
|
|
7
|
+
"migration": "keine"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.339.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -14,11 +14,14 @@
|
|
|
14
14
|
// `drizzle/generate.ts` ergänzen (= via subscriptionsProjectionTable-
|
|
15
15
|
// import). setupTestStack pusht sie automatisch via r.projection.table.
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
insertOnConflictDoNothing,
|
|
19
|
+
requireEntityTableMeta,
|
|
20
|
+
upsertOnConflict,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
22
|
+
import { buildEntityTable, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
18
23
|
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
24
|
import { paymentRowId } from "./aggregate-id.js";
|
|
20
|
-
import { insertPaymentProjectionRow } from "./db/queries/payment-projection.js";
|
|
21
|
-
import { upsertSubscriptionProjectionRow } from "./db/queries/subscription-projection.js";
|
|
22
25
|
import { paymentEntity, subscriptionEntity } from "./entities.js";
|
|
23
26
|
import type { PaymentEventPayload, SubscriptionEventPayload } from "./events.js";
|
|
24
27
|
|
|
@@ -32,6 +35,17 @@ export const subscriptionsProjectionTable = buildEntityTable("subscription", sub
|
|
|
32
35
|
// production must add read_payments to their own drizzle/generate.ts too.
|
|
33
36
|
export const paymentsProjectionTable = buildEntityTable("payment", paymentEntity);
|
|
34
37
|
|
|
38
|
+
// Unbranded metas: the apply functions below ARE the executor of these
|
|
39
|
+
// projections, so they may write through the typed helpers.
|
|
40
|
+
const subscriptionsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
|
|
41
|
+
subscriptionsProjectionTable,
|
|
42
|
+
"subscription",
|
|
43
|
+
);
|
|
44
|
+
const paymentsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
|
|
45
|
+
paymentsProjectionTable,
|
|
46
|
+
"payment",
|
|
47
|
+
);
|
|
48
|
+
|
|
35
49
|
// =============================================================================
|
|
36
50
|
// Shared helpers
|
|
37
51
|
// =============================================================================
|
|
@@ -69,9 +83,7 @@ function cancelAtSetFromPayload(p: SubscriptionEventPayload): {
|
|
|
69
83
|
* on every apply including the initial create — a projection rebuild at a
|
|
70
84
|
* different wall-clock time must materialize the exact same row, and
|
|
71
85
|
* `getSubscriptionForTenant`'s `lastChangedAt` (staleness check for
|
|
72
|
-
* incomplete subscriptions) depends on that determinism.
|
|
73
|
-
* bypasses the typed query-builder's Temporal.Instant-to-string coercion,
|
|
74
|
-
* hence the explicit `.toString()`. */
|
|
86
|
+
* incomplete subscriptions) depends on that determinism. */
|
|
75
87
|
async function upsert(
|
|
76
88
|
tx: Parameters<Parameters<typeof defineApply<SubscriptionEventPayload>>[0]>[1],
|
|
77
89
|
event: { aggregateId: string; tenantId: string; createdAt: Temporal.Instant },
|
|
@@ -92,38 +104,21 @@ async function upsert(
|
|
|
92
104
|
// INSERT-Pfad und nur den teil-`set` für ON CONFLICT.
|
|
93
105
|
const insertCols = {
|
|
94
106
|
id: event.aggregateId,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
tenantId: event.tenantId,
|
|
108
|
+
providerName: fullPayload.providerName,
|
|
109
|
+
providerCustomerId: fullPayload.providerCustomerId,
|
|
110
|
+
providerSubscriptionId: fullPayload.providerSubscriptionId,
|
|
99
111
|
status: fullPayload.status,
|
|
100
112
|
tier: fullPayload.tier,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
113
|
+
currentPeriodEnd: fullPayload.currentPeriodEndIso,
|
|
114
|
+
cancelAt: fullPayload.cancelAtIso ?? null,
|
|
115
|
+
modifiedAt: event.createdAt,
|
|
104
116
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
status: "status",
|
|
111
|
-
tier: "tier",
|
|
112
|
-
currentPeriodEnd: "current_period_end",
|
|
113
|
-
cancelAt: "cancel_at",
|
|
114
|
-
};
|
|
115
|
-
const insertParams = Object.values(insertCols);
|
|
116
|
-
const setEntries = Object.entries(set).filter(([, v]) => v !== undefined);
|
|
117
|
-
// modified_at is already bound as the last insertParams placeholder —
|
|
118
|
-
// reused (not re-pushed) for the ON CONFLICT SET clause below.
|
|
119
|
-
const setClauses: string[] = [`"modified_at" = $${insertParams.length}`];
|
|
120
|
-
const allParams: unknown[] = [...insertParams];
|
|
121
|
-
for (const [k, v] of setEntries) {
|
|
122
|
-
allParams.push(v);
|
|
123
|
-
setClauses.push(`"${setMap[k as keyof typeof set]}" = $${allParams.length}`);
|
|
124
|
-
}
|
|
125
|
-
const tableName = (subscriptionsProjectionTable as { tableName: string }).tableName;
|
|
126
|
-
await upsertSubscriptionProjectionRow(tx, tableName, insertCols, setClauses, allParams);
|
|
117
|
+
const definedSet = Object.fromEntries(Object.entries(set).filter(([, v]) => v !== undefined));
|
|
118
|
+
await upsertOnConflict(tx, subscriptionsProjectionMeta, insertCols, {
|
|
119
|
+
conflictKeys: ["id"],
|
|
120
|
+
update: { modifiedAt: event.createdAt, ...definedSet },
|
|
121
|
+
});
|
|
127
122
|
}
|
|
128
123
|
|
|
129
124
|
// =============================================================================
|
|
@@ -209,7 +204,6 @@ export const applyInvoicePaymentFailed = defineApply<SubscriptionEventPayload>(
|
|
|
209
204
|
* the second tenant's row. ON CONFLICT DO NOTHING also makes a projection
|
|
210
205
|
* rebuild idempotent without mutating an existing row. */
|
|
211
206
|
export const applyPaymentReceived = defineApply<PaymentEventPayload>(async (event, tx) => {
|
|
212
|
-
const tableName = (paymentsProjectionTable as { tableName: string }).tableName;
|
|
213
207
|
const providerEventId = event.metadata.headers?.["providerEventId"];
|
|
214
208
|
const providerName = event.metadata.headers?.["providerName"];
|
|
215
209
|
if (typeof providerEventId !== "string" || typeof providerName !== "string") {
|
|
@@ -217,11 +211,11 @@ export const applyPaymentReceived = defineApply<PaymentEventPayload>(async (even
|
|
|
217
211
|
"applyPaymentReceived: event.metadata.headers is missing providerEventId/providerName — process-payment-event.write.ts always sets both",
|
|
218
212
|
);
|
|
219
213
|
}
|
|
220
|
-
await
|
|
214
|
+
await insertOnConflictDoNothing(tx, paymentsProjectionMeta, {
|
|
221
215
|
id: paymentRowId(event.tenantId, providerName, providerEventId),
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
216
|
+
tenantId: event.tenantId,
|
|
217
|
+
providerName: event.payload.providerName,
|
|
218
|
+
providerCustomerId: event.payload.providerCustomerId,
|
|
219
|
+
priceId: event.payload.priceId,
|
|
226
220
|
});
|
|
227
221
|
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.340.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Inbound mail projections use typed bun-db helpers",
|
|
6
|
+
"detail": "Mail-account and mail-thread events upsert through `upsertOnConflict` (`connectedAt` is only written on insert), inbound messages insert through `insertOnConflictDoNothing`. The raw `db/queries/inbound-projections.ts` module is gone.",
|
|
7
|
+
"migration": "keine"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.339.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -17,12 +17,13 @@
|
|
|
17
17
|
// write-handler. Die applies kopieren nur durch; decrypted wird erst
|
|
18
18
|
// in den list-queries.
|
|
19
19
|
|
|
20
|
-
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
-
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
22
20
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
insertOnConflictDoNothing,
|
|
22
|
+
requireEntityTableMeta,
|
|
23
|
+
upsertOnConflict,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
25
|
+
import { buildEntityTable, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
26
|
+
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
26
27
|
import { inboundMessageEntity, mailAccountEntity, mailThreadEntity } from "./entities.js";
|
|
27
28
|
import type {
|
|
28
29
|
InboundMessageEventPayload,
|
|
@@ -39,9 +40,20 @@ export const inboundMessagesProjectionTable = buildEntityTable(
|
|
|
39
40
|
);
|
|
40
41
|
export const mailThreadsProjectionTable = buildEntityTable("mail-thread", mailThreadEntity);
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
// Unbranded metas: the apply functions below ARE the executor of these
|
|
44
|
+
// projections, so they may write through the typed helpers.
|
|
45
|
+
const mailAccountsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
|
|
46
|
+
mailAccountsProjectionTable,
|
|
47
|
+
"mail-account",
|
|
48
|
+
);
|
|
49
|
+
const inboundMessagesProjectionMeta: EntityTableMeta = requireEntityTableMeta(
|
|
50
|
+
inboundMessagesProjectionTable,
|
|
51
|
+
"inbound-message",
|
|
52
|
+
);
|
|
53
|
+
const mailThreadsProjectionMeta: EntityTableMeta = requireEntityTableMeta(
|
|
54
|
+
mailThreadsProjectionTable,
|
|
55
|
+
"mail-thread",
|
|
56
|
+
);
|
|
45
57
|
|
|
46
58
|
// =============================================================================
|
|
47
59
|
// mail-account — connected/updated/disconnected teilen den payload-shape,
|
|
@@ -49,40 +61,30 @@ function tableNameOf(table: unknown): string {
|
|
|
49
61
|
// defensive consistency (rebuild-aus-dem-Nichts, out-of-order).
|
|
50
62
|
// =============================================================================
|
|
51
63
|
|
|
52
|
-
const ACCOUNT_SET_CLAUSES = [
|
|
53
|
-
`"provider" = EXCLUDED."provider"`,
|
|
54
|
-
`"auth_method" = EXCLUDED."auth_method"`,
|
|
55
|
-
`"owner_user_id" = EXCLUDED."owner_user_id"`,
|
|
56
|
-
`"display_name" = EXCLUDED."display_name"`,
|
|
57
|
-
`"address" = EXCLUDED."address"`,
|
|
58
|
-
`"status" = EXCLUDED."status"`,
|
|
59
|
-
`"watch_state" = EXCLUDED."watch_state"`,
|
|
60
|
-
// connected_at bewusst NICHT im SET: der Erst-Connect-Zeitpunkt
|
|
61
|
-
// bleibt stehen, updated/disconnected überschreiben ihn nicht.
|
|
62
|
-
];
|
|
63
|
-
|
|
64
64
|
const applyMailAccountUpsert = defineApply<MailAccountEventPayload>(async (event, tx) => {
|
|
65
65
|
const p = event.payload;
|
|
66
|
-
const
|
|
67
|
-
id: event.aggregateId,
|
|
68
|
-
tenant_id: event.tenantId,
|
|
66
|
+
const mutable = {
|
|
69
67
|
provider: p.provider,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
authMethod: p.authMethod,
|
|
69
|
+
ownerUserId: p.ownerUserId,
|
|
70
|
+
displayName: p.displayName,
|
|
73
71
|
address: p.address,
|
|
74
72
|
status: p.status,
|
|
75
|
-
|
|
76
|
-
// Insert-Pfad = Erst-Connect (bzw. Rebuild: createdAt des ersten
|
|
77
|
-
// events des Streams — fachlich derselbe Zeitpunkt).
|
|
78
|
-
connected_at: event.createdAt.toString(),
|
|
73
|
+
watchState: p.watchState,
|
|
79
74
|
};
|
|
80
|
-
await
|
|
75
|
+
await upsertOnConflict(
|
|
81
76
|
tx,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
77
|
+
mailAccountsProjectionMeta,
|
|
78
|
+
{
|
|
79
|
+
id: event.aggregateId,
|
|
80
|
+
tenantId: event.tenantId,
|
|
81
|
+
...mutable,
|
|
82
|
+
// Only the insert path is the first connect (on rebuild: createdAt of the
|
|
83
|
+
// stream's first event, the same moment). Left out of `update` so the
|
|
84
|
+
// first-connect time survives updated/disconnected events.
|
|
85
|
+
connectedAt: event.createdAt,
|
|
86
|
+
},
|
|
87
|
+
{ conflictKeys: ["id"], update: mutable },
|
|
86
88
|
);
|
|
87
89
|
});
|
|
88
90
|
|
|
@@ -104,20 +106,20 @@ export const applyMailAccountDisconnected = applyMailAccountUpsert;
|
|
|
104
106
|
export const applyInboundMessageReceived = defineApply<InboundMessageEventPayload>(
|
|
105
107
|
async (event, tx) => {
|
|
106
108
|
const p = event.payload;
|
|
107
|
-
await
|
|
109
|
+
await insertOnConflictDoNothing(tx, inboundMessagesProjectionMeta, {
|
|
108
110
|
id: event.aggregateId,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
tenantId: event.tenantId,
|
|
112
|
+
accountId: p.accountId,
|
|
113
|
+
ownerUserId: p.ownerUserId,
|
|
114
|
+
messageIdHeader: p.messageIdHeader,
|
|
115
|
+
threadKey: p.threadKey,
|
|
114
116
|
from: p.from,
|
|
115
117
|
to: p.to,
|
|
116
118
|
cc: p.cc,
|
|
117
119
|
subject: p.subject,
|
|
118
120
|
snippet: p.snippet,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
receivedAt: p.receivedAtIso,
|
|
122
|
+
bodyRef: p.bodyRef,
|
|
121
123
|
scope: p.scope,
|
|
122
124
|
});
|
|
123
125
|
},
|
|
@@ -133,23 +135,20 @@ export const applyInboundMessageReceived = defineApply<InboundMessageEventPayloa
|
|
|
133
135
|
/** mail-thread-updated → UPSERT full mit dem Payload-Snapshot. */
|
|
134
136
|
export const applyMailThreadUpdated = defineApply<MailThreadEventPayload>(async (event, tx) => {
|
|
135
137
|
const p = event.payload;
|
|
136
|
-
const
|
|
137
|
-
id: event.aggregateId,
|
|
138
|
-
tenant_id: event.tenantId,
|
|
139
|
-
thread_key: p.threadKey,
|
|
138
|
+
const mutable = {
|
|
140
139
|
subject: p.subject,
|
|
141
|
-
|
|
142
|
-
|
|
140
|
+
lastMessageAt: p.lastMessageAtIso,
|
|
141
|
+
messageCount: p.messageCount,
|
|
143
142
|
};
|
|
144
|
-
await
|
|
143
|
+
await upsertOnConflict(
|
|
145
144
|
tx,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
145
|
+
mailThreadsProjectionMeta,
|
|
146
|
+
{
|
|
147
|
+
id: event.aggregateId,
|
|
148
|
+
tenantId: event.tenantId,
|
|
149
|
+
threadKey: p.threadKey,
|
|
150
|
+
...mutable,
|
|
151
|
+
},
|
|
152
|
+
{ conflictKeys: ["id"], update: mutable },
|
|
154
153
|
);
|
|
155
154
|
});
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.340.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The last-SystemAdmin check uses jsonb containment through selectMany",
|
|
6
|
+
"detail": "Removing the SystemAdmin role still refuses to drop the last active SystemAdmin; the count now comes from `selectMany` with a roles containment filter instead of raw SQL.",
|
|
7
|
+
"migration": "keine"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.271.0",
|
|
4
11
|
"type": "improvement",
|
|
@@ -8,7 +15,7 @@
|
|
|
8
15
|
{
|
|
9
16
|
"version": "0.218.1",
|
|
10
17
|
"type": "improvement",
|
|
11
|
-
"title": "user.email searchable
|
|
18
|
+
"title": "user.email searchable — reindex required after upgrade",
|
|
12
19
|
"detail": "email gained searchable for roster lookup. buildSearchDocument only runs on write; existing rows need reindexEntity(\"user\") (or the reindex job) plus Meili configure for the new attribute.",
|
|
13
20
|
"migration": "After deploying, run reindexEntity(\"user\") (or the existing reindex job) so pre-upgrade user rows appear in roster search."
|
|
14
21
|
},
|
|
@@ -16,28 +23,28 @@
|
|
|
16
23
|
"version": "0.218.0",
|
|
17
24
|
"type": "fix",
|
|
18
25
|
"title": "user-list: system-scope search; membership roles on tenants; emailVerified sort",
|
|
19
|
-
"detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant
|
|
26
|
+
"detail": "systemScope entityList search uses SYSTEM_TENANT_ID (was session tenant → Meili miss/500). Tenants column shows membership roles (Offlot Demo (TenantAdmin)). emailVerified sortable; email also searchable for roster lookup. Global roles stay SystemAdmin-only — TenantAdmin/Admin live on membership (Team → Members)."
|
|
20
27
|
},
|
|
21
28
|
{
|
|
22
29
|
"version": "0.217.0",
|
|
23
30
|
"type": "improvement",
|
|
24
31
|
"title": "user-list: roles multiSelect + tenants column; roles editable as SystemAdmin (fw#2384 follow-up)",
|
|
25
|
-
"detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box
|
|
26
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
32
|
+
"detail": "user.roles is now createMultiSelectField (jsonb string[], options=[SystemAdmin]) so entityEdit shows a real multi-select instead of a raw JSON text box — SystemAdmins can promote another SystemAdmin from the UI. user-list columns gain roles + tenants; tenants is a derived field filled by the list handler from memberships (platform roster was unusable without tenant context). switch-tenant + auth-client parseRoles accept jsonb arrays (string-only deserialize dropped SystemAdmin on tenant switch). Soft-deleted users stay hidden by default.",
|
|
33
|
+
"migration": "Run `kumiko-schema generate` and apply — migrates read_users.roles from text to jsonb USING roles::jsonb. Existing JSON array strings (`[]`, `[\"SystemAdmin\"]`) convert cleanly. Consumers that wrote roles as a JSON-encoded string via user:create/update should pass string[] instead (parseRoles still accepts both on read)."
|
|
27
34
|
},
|
|
28
35
|
{
|
|
29
36
|
"version": "0.167.1",
|
|
30
37
|
"type": "breaking",
|
|
31
38
|
"title": "user.locale no longer defaults to \"de\" (fw#1637).",
|
|
32
39
|
"detail": "The entity-level default contradicted tenant-settings' own \"en\" default and silently overrode any app or tenant locale configuration for every new user. locale now stays unset until the client or a resolution chain provides one. Consumers that already fall back with `user.locale ?? \"en\"` are unaffected in shape but now see null instead of \"de\".",
|
|
33
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
40
|
+
"migration": "Run `kumiko-schema generate` and apply — the migration emits `ALTER TABLE read_users ALTER COLUMN locale DROP DEFAULT`. Note that DROP DEFAULT only affects future inserts: existing rows keep the \"de\" the old default wrote, which no user ever chose. Your app therefore splits into pre-bump users on \"de\" and post-bump users on null, and each group resolves to a different language. Decide deliberately: either keep the old values (and state that pre-bump users stay German), or run `UPDATE read_users SET locale = NULL WHERE locale = 'de'` once so every user follows the same chain — the latter only if no UI ever let users edit the field, otherwise it erases real choices. If your app relied on the implicit German default, set an explicit fallback instead (fw#1653)."
|
|
34
41
|
},
|
|
35
42
|
{
|
|
36
43
|
"version": "0.167.1",
|
|
37
44
|
"type": "improvement",
|
|
38
45
|
"title": "user.timezone field, wired into ctx.tz (fw#1636).",
|
|
39
46
|
"detail": "The user entity gains a `timezone` field (createTzField, no default). buildHandlerContext passes it to ctx.tz.user; the fallback chain stays tenant.timezone, then \"UTC\". Before this, ctx.tz.tenant and ctx.tz.user both hardcoded \"UTC\" while the plumbing already accepted per-request values.",
|
|
40
|
-
"migration": "Run `kumiko-schema generate` and apply
|
|
47
|
+
"migration": "Run `kumiko-schema generate` and apply — the migration adds `read_users.timezone`. No backfill: an unset timezone falls through to the tenant's, exactly as before the field existed."
|
|
41
48
|
},
|
|
42
49
|
{
|
|
43
50
|
"version": "0.165.3",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import {
|
|
3
3
|
acquireNamespacedAdvisoryLock,
|
|
4
4
|
createEventStoreExecutor,
|
|
@@ -40,10 +40,6 @@ type UserRolesRow = {
|
|
|
40
40
|
isDeleted?: boolean;
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
function quoteIdent(name: string): string {
|
|
44
|
-
return `"${name.replace(/"/g, '""')}"`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
43
|
function isActiveUserRow(row: UserRolesRow): boolean {
|
|
48
44
|
if (row.isDeleted === true) return false;
|
|
49
45
|
return row.status === USER_STATUS.Active || !row.status;
|
|
@@ -54,21 +50,11 @@ function isActiveSystemAdminRow(row: UserRolesRow): boolean {
|
|
|
54
50
|
}
|
|
55
51
|
|
|
56
52
|
async function countOtherActiveSystemAdmins(db: DbRunner, excludeUserId: string): Promise<number> {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const rows = (await asRawClient(db).unsafe(
|
|
63
|
-
`SELECT id, roles, status, is_deleted AS "isDeleted"
|
|
64
|
-
FROM ${quoteIdent(tableName)}
|
|
65
|
-
WHERE is_deleted = false
|
|
66
|
-
AND (status = $1 OR status IS NULL)
|
|
67
|
-
AND roles @> $2::jsonb`,
|
|
68
|
-
// Pass a JS array — JSON.stringify + $n::jsonb double-encodes to a jsonb string
|
|
69
|
-
// (postgres.js), so `@>` never matches a roles array.
|
|
70
|
-
[USER_STATUS.Active, ["SystemAdmin"]],
|
|
71
|
-
)) as UserRolesRow[];
|
|
53
|
+
// `roles` is a jsonb multiSelect column: a scalar filter value means `@>` containment.
|
|
54
|
+
const rows = await selectMany<UserRolesRow>(db, userTable, {
|
|
55
|
+
isDeleted: false,
|
|
56
|
+
roles: "SystemAdmin",
|
|
57
|
+
});
|
|
72
58
|
return rows.filter((u) => u.id !== excludeUserId && isActiveSystemAdminRow(u)).length;
|
|
73
59
|
}
|
|
74
60
|
|
|
@@ -138,9 +124,7 @@ export async function applyUserRolesUpdate(
|
|
|
138
124
|
// observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
|
|
139
125
|
await acquireNamespacedAdvisoryLock(lockRunner, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
|
|
140
126
|
const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(
|
|
141
|
-
ctx.systemDb.unsafeRaw(
|
|
142
|
-
"jsonb @> prefilter over the global users table to count remaining active SystemAdmins",
|
|
143
|
-
),
|
|
127
|
+
ctx.systemDb.unsafeRaw("count remaining active SystemAdmins across the global users table"),
|
|
144
128
|
event.payload.id,
|
|
145
129
|
);
|
|
146
130
|
if (otherActiveSystemAdmins === 0) {
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.340.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "The resume job reads due runs through the job's TenantDb",
|
|
6
|
+
"detail": "`selectDueWorkflowRunPending` takes a `TenantDb` and returns `{ runId, stepIndex }` rows, so the tenant filter comes from the TenantDb instead of a hand-written `WHERE tenant_id`.",
|
|
7
|
+
"migration": "keine"
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.339.0",
|
|
4
11
|
"type": "breaking",
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { sql, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { workflowRunPendingTable } from "../../tables.js";
|
|
4
3
|
|
|
5
|
-
export type DueRow = { readonly
|
|
4
|
+
export type DueRow = { readonly runId: string; readonly stepIndex: number };
|
|
6
5
|
|
|
7
6
|
// Oldest first and capped, so a backlog after an outage drains over several
|
|
8
7
|
// ticks instead of holding one tick's `concurrency: "skip"` slot for minutes.
|
|
@@ -12,13 +11,11 @@ export const DUE_RUNS_BATCH_SIZE = 100;
|
|
|
12
11
|
// (ctx.tryAppendEvent on WORKFLOW_RESUMED) — a second dispatch for the same
|
|
13
12
|
// row loses that race and no-ops. No row locks: in an autocommit statement
|
|
14
13
|
// they would be released right after the SELECT and guard nothing.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
[tenantId],
|
|
23
|
-
)) as readonly DueRow[];
|
|
14
|
+
// `now()` stays the DB clock so job-host clock skew cannot mark rows due early.
|
|
15
|
+
export function selectDueWorkflowRunPending(db: TenantDb): Promise<readonly DueRow[]> {
|
|
16
|
+
return db.selectMany<DueRow>(
|
|
17
|
+
workflowRunPendingTable,
|
|
18
|
+
{ wakeAt: { lt: sql`now()` } },
|
|
19
|
+
{ orderBy: { col: "wakeAt", direction: "asc" }, limit: DUE_RUNS_BATCH_SIZE },
|
|
20
|
+
);
|
|
24
21
|
}
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
//
|
|
25
25
|
// wait/retry/waitForEvent suspensions all resume automatically now.
|
|
26
26
|
|
|
27
|
-
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
28
27
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
29
28
|
import { selectDueWorkflowRunPending } from "./db/queries/due-runs.js";
|
|
30
29
|
import { resumeRunHandler } from "./handlers/resume-run.write.js";
|
|
@@ -68,26 +67,20 @@ export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
68
67
|
return;
|
|
69
68
|
}
|
|
70
69
|
|
|
71
|
-
ctx.systemDb.assertTenantMatch(tenantId);
|
|
72
|
-
const dueRows = await selectDueWorkflowRunPending(
|
|
73
|
-
ctx.systemDb.unsafeRaw(
|
|
74
|
-
"due-row pickup needs FOR UPDATE SKIP LOCKED raw SQL; the query filters by this job's tenantId",
|
|
75
|
-
) as DbConnection, // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
|
|
76
|
-
tenantId,
|
|
77
|
-
);
|
|
70
|
+
const dueRows = await selectDueWorkflowRunPending(ctx.systemDb.assertTenantMatch(tenantId));
|
|
78
71
|
|
|
79
72
|
for (const row of dueRows) {
|
|
80
73
|
try {
|
|
81
74
|
await ctx.write("workflow-runner:write:resume-run", {
|
|
82
|
-
runId: row.
|
|
83
|
-
stepIndex: row.
|
|
75
|
+
runId: row.runId,
|
|
76
|
+
stepIndex: row.stepIndex,
|
|
84
77
|
});
|
|
85
78
|
} catch (error) {
|
|
86
79
|
// skip: one row's dispatch failing (infra hiccup, not a business
|
|
87
80
|
// outcome — resume-run itself never throws for expected cases)
|
|
88
81
|
// must not stop the rest of this tenant's due rows from resuming.
|
|
89
82
|
ctx.log.warn(
|
|
90
|
-
`[workflow-runner:resume-due-runs] runId=${row.
|
|
83
|
+
`[workflow-runner:resume-due-runs] runId=${row.runId} stepIndex=${row.stepIndex} dispatch failed: ${String(error)}`,
|
|
91
84
|
);
|
|
92
85
|
}
|
|
93
86
|
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
|
|
4
|
-
// INSERT-once, not UPSERT: unlike subscriptionsProjectionTable (one row per
|
|
5
|
-
// tenant, state overwritten on every event), a payment row is an immutable
|
|
6
|
-
// fact keyed by paymentRowId(tenantId, providerName, providerEventId) — ON
|
|
7
|
-
// CONFLICT DO NOTHING makes a projection rebuild/replay idempotent without
|
|
8
|
-
// ever mutating an existing row.
|
|
9
|
-
export async function insertPaymentProjectionRow(
|
|
10
|
-
tx: DbRunner,
|
|
11
|
-
tableName: string,
|
|
12
|
-
insertCols: Record<string, unknown>,
|
|
13
|
-
): Promise<void> {
|
|
14
|
-
const insertKeys = Object.keys(insertCols);
|
|
15
|
-
const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
|
|
16
|
-
const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO NOTHING`;
|
|
17
|
-
// kumiko-lint-ignore raw-sql tableName + column keys are code-controlled, not user input: tableName is buildEntityTable()'s static table name and insertKeys come from applyPaymentReceived's hardcoded object-literal columns in projection.ts — only values flow through $1..$n
|
|
18
|
-
await asRawClient(tx).unsafe(sqlText, Object.values(insertCols));
|
|
19
|
-
}
|
package/packages/bundled-features/src/billing-foundation/db/queries/subscription-projection.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
-
|
|
4
|
-
export async function upsertSubscriptionProjectionRow(
|
|
5
|
-
tx: DbRunner,
|
|
6
|
-
tableName: string,
|
|
7
|
-
insertCols: Record<string, unknown>,
|
|
8
|
-
setClauses: readonly string[],
|
|
9
|
-
params: readonly unknown[],
|
|
10
|
-
): Promise<void> {
|
|
11
|
-
const insertKeys = Object.keys(insertCols);
|
|
12
|
-
const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
|
|
13
|
-
const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO UPDATE SET ${setClauses.join(", ")}`;
|
|
14
|
-
await asRawClient(tx).unsafe(sqlText, params);
|
|
15
|
-
}
|
package/packages/bundled-features/src/inbound-mail-foundation/db/queries/inbound-projections.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
// Raw-SQL-Helper für die inline projections — Muster
|
|
2
|
-
// billing-foundation/db/queries/subscription-projection.ts.
|
|
3
|
-
//
|
|
4
|
-
// Warum raw statt drizzle-insert: die apply-fns laufen in der event-TX
|
|
5
|
-
// mit einem DbRunner, und die ON-CONFLICT-Semantik (UPSERT vs.
|
|
6
|
-
// DO NOTHING) ist der fachliche Kern — explizit hingeschrieben statt
|
|
7
|
-
// hinter einem ORM-Builder versteckt.
|
|
8
|
-
|
|
9
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
-
|
|
12
|
-
/** INSERT ... ON CONFLICT ("id") DO UPDATE SET <setClauses>. */
|
|
13
|
-
export async function upsertProjectionRow(
|
|
14
|
-
tx: DbRunner,
|
|
15
|
-
tableName: string,
|
|
16
|
-
insertCols: Record<string, unknown>,
|
|
17
|
-
setClauses: readonly string[],
|
|
18
|
-
params: readonly unknown[],
|
|
19
|
-
): Promise<void> {
|
|
20
|
-
const insertKeys = Object.keys(insertCols);
|
|
21
|
-
const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
|
|
22
|
-
const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO UPDATE SET ${setClauses.join(", ")}`;
|
|
23
|
-
await asRawClient(tx).unsafe(sqlText, params);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** INSERT ... ON CONFLICT ("id") DO NOTHING — für append-only-Rows
|
|
27
|
-
* (inbound-message: genau EIN received-event pro Stream, Replays
|
|
28
|
-
* no-op'en auf der PK). */
|
|
29
|
-
export async function insertIgnoreProjectionRow(
|
|
30
|
-
tx: DbRunner,
|
|
31
|
-
tableName: string,
|
|
32
|
-
insertCols: Record<string, unknown>,
|
|
33
|
-
): Promise<void> {
|
|
34
|
-
const insertKeys = Object.keys(insertCols);
|
|
35
|
-
const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
|
|
36
|
-
const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO NOTHING`;
|
|
37
|
-
await asRawClient(tx).unsafe(sqlText, Object.values(insertCols));
|
|
38
|
-
}
|