@cosmicdrift/kumiko-bundled-features 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/dist/billing-foundation/projection.js +23 -40
- package/dist/inbound-mail-foundation/projection.js +37 -45
- package/dist/user/handlers/update-roles.js +7 -18
- package/dist/workflow-runner/db/queries/due-runs.d.ts +4 -5
- package/dist/workflow-runner/db/queries/due-runs.js +5 -4
- package/dist/workflow-runner/feature.js +4 -6
- package/package.json +9 -9
- package/src/billing-foundation/changes.json +7 -0
- package/src/inbound-mail-foundation/changes.json +7 -0
- package/src/user/changes.json +13 -6
- package/src/workflow-runner/changes.json +7 -0
- package/dist/billing-foundation/db/queries/payment-projection.d.ts +0 -2
- package/dist/billing-foundation/db/queries/payment-projection.js +0 -13
- package/dist/billing-foundation/db/queries/subscription-projection.d.ts +0 -2
- package/dist/billing-foundation/db/queries/subscription-projection.js +0 -7
- package/dist/inbound-mail-foundation/db/queries/inbound-projections.d.ts +0 -7
- package/dist/inbound-mail-foundation/db/queries/inbound-projections.js +0 -24
|
@@ -13,11 +13,10 @@
|
|
|
13
13
|
// foundation production mounten müssen die Tabelle in ihre eigene
|
|
14
14
|
// `drizzle/generate.ts` ergänzen (= via subscriptionsProjectionTable-
|
|
15
15
|
// import). setupTestStack pusht sie automatisch via r.projection.table.
|
|
16
|
+
import { insertOnConflictDoNothing, requireEntityTableMeta, upsertOnConflict, } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
17
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
17
18
|
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
19
|
import { paymentRowId } from "./aggregate-id.js";
|
|
19
|
-
import { insertPaymentProjectionRow } from "./db/queries/payment-projection.js";
|
|
20
|
-
import { upsertSubscriptionProjectionRow } from "./db/queries/subscription-projection.js";
|
|
21
20
|
import { paymentEntity, subscriptionEntity } from "./entities.js";
|
|
22
21
|
// Drizzle-table-instance aus dem entity-shape. Wird sowohl von der
|
|
23
22
|
// projection-apply als auch von list-query / get-helper genutzt damit
|
|
@@ -27,6 +26,10 @@ export const subscriptionsProjectionTable = buildEntityTable("subscription", sub
|
|
|
27
26
|
// raw drizzle-pgTable, not an r.entity — apps mounting billing-foundation in
|
|
28
27
|
// production must add read_payments to their own drizzle/generate.ts too.
|
|
29
28
|
export const paymentsProjectionTable = buildEntityTable("payment", paymentEntity);
|
|
29
|
+
// Unbranded metas: the apply functions below ARE the executor of these
|
|
30
|
+
// projections, so they may write through the typed helpers.
|
|
31
|
+
const subscriptionsProjectionMeta = requireEntityTableMeta(subscriptionsProjectionTable, "subscription");
|
|
32
|
+
const paymentsProjectionMeta = requireEntityTableMeta(paymentsProjectionTable, "payment");
|
|
30
33
|
// =============================================================================
|
|
31
34
|
// Shared helpers
|
|
32
35
|
// =============================================================================
|
|
@@ -59,9 +62,7 @@ function cancelAtSetFromPayload(p) {
|
|
|
59
62
|
* on every apply including the initial create — a projection rebuild at a
|
|
60
63
|
* different wall-clock time must materialize the exact same row, and
|
|
61
64
|
* `getSubscriptionForTenant`'s `lastChangedAt` (staleness check for
|
|
62
|
-
* incomplete subscriptions) depends on that determinism.
|
|
63
|
-
* bypasses the typed query-builder's Temporal.Instant-to-string coercion,
|
|
64
|
-
* hence the explicit `.toString()`. */
|
|
65
|
+
* incomplete subscriptions) depends on that determinism. */
|
|
65
66
|
async function upsert(tx, event, set, fullPayload) {
|
|
66
67
|
// INSERT-fallback braucht ALL fields (NOT NULL constraints). Wenn
|
|
67
68
|
// jemand nur teil-felder updated (z.B. invoice-payment-failed nur
|
|
@@ -69,38 +70,21 @@ async function upsert(tx, event, set, fullPayload) {
|
|
|
69
70
|
// INSERT-Pfad und nur den teil-`set` für ON CONFLICT.
|
|
70
71
|
const insertCols = {
|
|
71
72
|
id: event.aggregateId,
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
tenantId: event.tenantId,
|
|
74
|
+
providerName: fullPayload.providerName,
|
|
75
|
+
providerCustomerId: fullPayload.providerCustomerId,
|
|
76
|
+
providerSubscriptionId: fullPayload.providerSubscriptionId,
|
|
76
77
|
status: fullPayload.status,
|
|
77
78
|
tier: fullPayload.tier,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
currentPeriodEnd: fullPayload.currentPeriodEndIso,
|
|
80
|
+
cancelAt: fullPayload.cancelAtIso ?? null,
|
|
81
|
+
modifiedAt: event.createdAt,
|
|
81
82
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
status: "status",
|
|
88
|
-
tier: "tier",
|
|
89
|
-
currentPeriodEnd: "current_period_end",
|
|
90
|
-
cancelAt: "cancel_at",
|
|
91
|
-
};
|
|
92
|
-
const insertParams = Object.values(insertCols);
|
|
93
|
-
const setEntries = Object.entries(set).filter(([, v]) => v !== undefined);
|
|
94
|
-
// modified_at is already bound as the last insertParams placeholder —
|
|
95
|
-
// reused (not re-pushed) for the ON CONFLICT SET clause below.
|
|
96
|
-
const setClauses = [`"modified_at" = $${insertParams.length}`];
|
|
97
|
-
const allParams = [...insertParams];
|
|
98
|
-
for (const [k, v] of setEntries) {
|
|
99
|
-
allParams.push(v);
|
|
100
|
-
setClauses.push(`"${setMap[k]}" = $${allParams.length}`);
|
|
101
|
-
}
|
|
102
|
-
const tableName = subscriptionsProjectionTable.tableName;
|
|
103
|
-
await upsertSubscriptionProjectionRow(tx, tableName, insertCols, setClauses, allParams);
|
|
83
|
+
const definedSet = Object.fromEntries(Object.entries(set).filter(([, v]) => v !== undefined));
|
|
84
|
+
await upsertOnConflict(tx, subscriptionsProjectionMeta, insertCols, {
|
|
85
|
+
conflictKeys: ["id"],
|
|
86
|
+
update: { modifiedAt: event.createdAt, ...definedSet },
|
|
87
|
+
});
|
|
104
88
|
}
|
|
105
89
|
// =============================================================================
|
|
106
90
|
// Apply-functions — eine pro event-typ
|
|
@@ -164,17 +148,16 @@ export const applyInvoicePaymentFailed = defineApply(async (event, tx) => {
|
|
|
164
148
|
* the second tenant's row. ON CONFLICT DO NOTHING also makes a projection
|
|
165
149
|
* rebuild idempotent without mutating an existing row. */
|
|
166
150
|
export const applyPaymentReceived = defineApply(async (event, tx) => {
|
|
167
|
-
const tableName = paymentsProjectionTable.tableName;
|
|
168
151
|
const providerEventId = event.metadata.headers?.["providerEventId"];
|
|
169
152
|
const providerName = event.metadata.headers?.["providerName"];
|
|
170
153
|
if (typeof providerEventId !== "string" || typeof providerName !== "string") {
|
|
171
154
|
throw new Error("applyPaymentReceived: event.metadata.headers is missing providerEventId/providerName — process-payment-event.write.ts always sets both");
|
|
172
155
|
}
|
|
173
|
-
await
|
|
156
|
+
await insertOnConflictDoNothing(tx, paymentsProjectionMeta, {
|
|
174
157
|
id: paymentRowId(event.tenantId, providerName, providerEventId),
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
158
|
+
tenantId: event.tenantId,
|
|
159
|
+
providerName: event.payload.providerName,
|
|
160
|
+
providerCustomerId: event.payload.providerCustomerId,
|
|
161
|
+
priceId: event.payload.priceId,
|
|
179
162
|
});
|
|
180
163
|
});
|
|
@@ -16,51 +16,45 @@
|
|
|
16
16
|
// kommen bereits als Ciphertext an — encrypted VOR dem append im
|
|
17
17
|
// write-handler. Die applies kopieren nur durch; decrypted wird erst
|
|
18
18
|
// in den list-queries.
|
|
19
|
+
import { insertOnConflictDoNothing, requireEntityTableMeta, upsertOnConflict, } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
19
20
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
20
21
|
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
-
import { insertIgnoreProjectionRow, upsertProjectionRow, } from "./db/queries/inbound-projections.js";
|
|
22
22
|
import { inboundMessageEntity, mailAccountEntity, mailThreadEntity } from "./entities.js";
|
|
23
23
|
// Drizzle-table-instances aus den entity-shapes — geteilt zwischen
|
|
24
24
|
// projection-applies und list-queries (ein column-namespace).
|
|
25
25
|
export const mailAccountsProjectionTable = buildEntityTable("mail-account", mailAccountEntity);
|
|
26
26
|
export const inboundMessagesProjectionTable = buildEntityTable("inbound-message", inboundMessageEntity);
|
|
27
27
|
export const mailThreadsProjectionTable = buildEntityTable("mail-thread", mailThreadEntity);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
// Unbranded metas: the apply functions below ARE the executor of these
|
|
29
|
+
// projections, so they may write through the typed helpers.
|
|
30
|
+
const mailAccountsProjectionMeta = requireEntityTableMeta(mailAccountsProjectionTable, "mail-account");
|
|
31
|
+
const inboundMessagesProjectionMeta = requireEntityTableMeta(inboundMessagesProjectionTable, "inbound-message");
|
|
32
|
+
const mailThreadsProjectionMeta = requireEntityTableMeta(mailThreadsProjectionTable, "mail-thread");
|
|
31
33
|
// =============================================================================
|
|
32
34
|
// mail-account — connected/updated/disconnected teilen den payload-shape,
|
|
33
35
|
// der event-type taggt was passiert ist. Alle drei UPSERT-full für
|
|
34
36
|
// defensive consistency (rebuild-aus-dem-Nichts, out-of-order).
|
|
35
37
|
// =============================================================================
|
|
36
|
-
const ACCOUNT_SET_CLAUSES = [
|
|
37
|
-
`"provider" = EXCLUDED."provider"`,
|
|
38
|
-
`"auth_method" = EXCLUDED."auth_method"`,
|
|
39
|
-
`"owner_user_id" = EXCLUDED."owner_user_id"`,
|
|
40
|
-
`"display_name" = EXCLUDED."display_name"`,
|
|
41
|
-
`"address" = EXCLUDED."address"`,
|
|
42
|
-
`"status" = EXCLUDED."status"`,
|
|
43
|
-
`"watch_state" = EXCLUDED."watch_state"`,
|
|
44
|
-
// connected_at bewusst NICHT im SET: der Erst-Connect-Zeitpunkt
|
|
45
|
-
// bleibt stehen, updated/disconnected überschreiben ihn nicht.
|
|
46
|
-
];
|
|
47
38
|
const applyMailAccountUpsert = defineApply(async (event, tx) => {
|
|
48
39
|
const p = event.payload;
|
|
49
|
-
const
|
|
50
|
-
id: event.aggregateId,
|
|
51
|
-
tenant_id: event.tenantId,
|
|
40
|
+
const mutable = {
|
|
52
41
|
provider: p.provider,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
42
|
+
authMethod: p.authMethod,
|
|
43
|
+
ownerUserId: p.ownerUserId,
|
|
44
|
+
displayName: p.displayName,
|
|
56
45
|
address: p.address,
|
|
57
46
|
status: p.status,
|
|
58
|
-
|
|
59
|
-
// Insert-Pfad = Erst-Connect (bzw. Rebuild: createdAt des ersten
|
|
60
|
-
// events des Streams — fachlich derselbe Zeitpunkt).
|
|
61
|
-
connected_at: event.createdAt.toString(),
|
|
47
|
+
watchState: p.watchState,
|
|
62
48
|
};
|
|
63
|
-
await
|
|
49
|
+
await upsertOnConflict(tx, mailAccountsProjectionMeta, {
|
|
50
|
+
id: event.aggregateId,
|
|
51
|
+
tenantId: event.tenantId,
|
|
52
|
+
...mutable,
|
|
53
|
+
// Only the insert path is the first connect (on rebuild: createdAt of the
|
|
54
|
+
// stream's first event, the same moment). Left out of `update` so the
|
|
55
|
+
// first-connect time survives updated/disconnected events.
|
|
56
|
+
connectedAt: event.createdAt,
|
|
57
|
+
}, { conflictKeys: ["id"], update: mutable });
|
|
64
58
|
});
|
|
65
59
|
/** mail-account-connected → UPSERT full. */
|
|
66
60
|
export const applyMailAccountConnected = applyMailAccountUpsert;
|
|
@@ -77,20 +71,20 @@ export const applyMailAccountDisconnected = applyMailAccountUpsert;
|
|
|
77
71
|
/** inbound-message-received → INSERT ... ON CONFLICT DO NOTHING. */
|
|
78
72
|
export const applyInboundMessageReceived = defineApply(async (event, tx) => {
|
|
79
73
|
const p = event.payload;
|
|
80
|
-
await
|
|
74
|
+
await insertOnConflictDoNothing(tx, inboundMessagesProjectionMeta, {
|
|
81
75
|
id: event.aggregateId,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
76
|
+
tenantId: event.tenantId,
|
|
77
|
+
accountId: p.accountId,
|
|
78
|
+
ownerUserId: p.ownerUserId,
|
|
79
|
+
messageIdHeader: p.messageIdHeader,
|
|
80
|
+
threadKey: p.threadKey,
|
|
87
81
|
from: p.from,
|
|
88
82
|
to: p.to,
|
|
89
83
|
cc: p.cc,
|
|
90
84
|
subject: p.subject,
|
|
91
85
|
snippet: p.snippet,
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
receivedAt: p.receivedAtIso,
|
|
87
|
+
bodyRef: p.bodyRef,
|
|
94
88
|
scope: p.scope,
|
|
95
89
|
});
|
|
96
90
|
});
|
|
@@ -103,17 +97,15 @@ export const applyInboundMessageReceived = defineApply(async (event, tx) => {
|
|
|
103
97
|
/** mail-thread-updated → UPSERT full mit dem Payload-Snapshot. */
|
|
104
98
|
export const applyMailThreadUpdated = defineApply(async (event, tx) => {
|
|
105
99
|
const p = event.payload;
|
|
106
|
-
const
|
|
107
|
-
id: event.aggregateId,
|
|
108
|
-
tenant_id: event.tenantId,
|
|
109
|
-
thread_key: p.threadKey,
|
|
100
|
+
const mutable = {
|
|
110
101
|
subject: p.subject,
|
|
111
|
-
|
|
112
|
-
|
|
102
|
+
lastMessageAt: p.lastMessageAtIso,
|
|
103
|
+
messageCount: p.messageCount,
|
|
113
104
|
};
|
|
114
|
-
await
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
105
|
+
await upsertOnConflict(tx, mailThreadsProjectionMeta, {
|
|
106
|
+
id: event.aggregateId,
|
|
107
|
+
tenantId: event.tenantId,
|
|
108
|
+
threadKey: p.threadKey,
|
|
109
|
+
...mutable,
|
|
110
|
+
}, { conflictKeys: ["id"], update: mutable });
|
|
119
111
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { acquireNamespacedAdvisoryLock, createEventStoreExecutor, } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import { createSystemUser, findForbiddenRoleAssignment, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import { AccessDeniedError, ConflictError, InternalError, NotFoundError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
|
|
@@ -10,9 +10,6 @@ const REVOKE_ALL_SESSIONS_QN = "sessions:write:user-session:revoke-all-for-user"
|
|
|
10
10
|
// Serializes last-SystemAdmin demotion checks globally inside the write TX
|
|
11
11
|
// (dispatcher batch wraps handlers in transaction — xact lock holds through update).
|
|
12
12
|
const LAST_SYSTEM_ADMIN_LOCK_NAMESPACE = 0x7361646d; // 'sadm'
|
|
13
|
-
function quoteIdent(name) {
|
|
14
|
-
return `"${name.replace(/"/g, '""')}"`;
|
|
15
|
-
}
|
|
16
13
|
function isActiveUserRow(row) {
|
|
17
14
|
if (row.isDeleted === true)
|
|
18
15
|
return false;
|
|
@@ -22,19 +19,11 @@ function isActiveSystemAdminRow(row) {
|
|
|
22
19
|
return isActiveUserRow(row) && parseRoles(row.roles).includes("SystemAdmin");
|
|
23
20
|
}
|
|
24
21
|
async function countOtherActiveSystemAdmins(db, excludeUserId) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const rows = (await asRawClient(db).unsafe(`SELECT id, roles, status, is_deleted AS "isDeleted"
|
|
31
|
-
FROM ${quoteIdent(tableName)}
|
|
32
|
-
WHERE is_deleted = false
|
|
33
|
-
AND (status = $1 OR status IS NULL)
|
|
34
|
-
AND roles @> $2::jsonb`,
|
|
35
|
-
// Pass a JS array — JSON.stringify + $n::jsonb double-encodes to a jsonb string
|
|
36
|
-
// (postgres.js), so `@>` never matches a roles array.
|
|
37
|
-
[USER_STATUS.Active, ["SystemAdmin"]]));
|
|
22
|
+
// `roles` is a jsonb multiSelect column: a scalar filter value means `@>` containment.
|
|
23
|
+
const rows = await selectMany(db, userTable, {
|
|
24
|
+
isDeleted: false,
|
|
25
|
+
roles: "SystemAdmin",
|
|
26
|
+
});
|
|
38
27
|
return rows.filter((u) => u.id !== excludeUserId && isActiveSystemAdminRow(u)).length;
|
|
39
28
|
}
|
|
40
29
|
export async function applyUserRolesUpdate(event, ctx, db) {
|
|
@@ -68,7 +57,7 @@ export async function applyUserRolesUpdate(event, ctx, db) {
|
|
|
68
57
|
// Lock before count+update so two concurrent demotions cannot both
|
|
69
58
|
// observe otherActiveSystemAdmins >= 1 and leave zero active SystemAdmins.
|
|
70
59
|
await acquireNamespacedAdvisoryLock(lockRunner, LAST_SYSTEM_ADMIN_LOCK_NAMESPACE, "global");
|
|
71
|
-
const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(ctx.systemDb.unsafeRaw("
|
|
60
|
+
const otherActiveSystemAdmins = await countOtherActiveSystemAdmins(ctx.systemDb.unsafeRaw("count remaining active SystemAdmins across the global users table"), event.payload.id);
|
|
72
61
|
if (otherActiveSystemAdmins === 0) {
|
|
73
62
|
return writeFailure(new ConflictError({
|
|
74
63
|
message: "cannot demote the last active SystemAdmin",
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
2
|
export type DueRow = {
|
|
4
|
-
readonly
|
|
5
|
-
readonly
|
|
3
|
+
readonly runId: string;
|
|
4
|
+
readonly stepIndex: number;
|
|
6
5
|
};
|
|
7
6
|
export declare const DUE_RUNS_BATCH_SIZE = 100;
|
|
8
|
-
export declare function selectDueWorkflowRunPending(db:
|
|
7
|
+
export declare function selectDueWorkflowRunPending(db: TenantDb): Promise<readonly DueRow[]>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { sql } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { workflowRunPendingTable } from "../../tables.js";
|
|
2
3
|
// Oldest first and capped, so a backlog after an outage drains over several
|
|
3
4
|
// ticks instead of holding one tick's `concurrency: "skip"` slot for minutes.
|
|
4
5
|
export const DUE_RUNS_BATCH_SIZE = 100;
|
|
@@ -6,7 +7,7 @@ export const DUE_RUNS_BATCH_SIZE = 100;
|
|
|
6
7
|
// (ctx.tryAppendEvent on WORKFLOW_RESUMED) — a second dispatch for the same
|
|
7
8
|
// row loses that race and no-ops. No row locks: in an autocommit statement
|
|
8
9
|
// they would be released right after the SELECT and guard nothing.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return
|
|
10
|
+
// `now()` stays the DB clock so job-host clock skew cannot mark rows due early.
|
|
11
|
+
export function selectDueWorkflowRunPending(db) {
|
|
12
|
+
return db.selectMany(workflowRunPendingTable, { wakeAt: { lt: sql `now()` } }, { orderBy: { col: "wakeAt", direction: "asc" }, limit: DUE_RUNS_BATCH_SIZE });
|
|
12
13
|
}
|
|
@@ -57,21 +57,19 @@ export const workflowRunnerFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
|
57
57
|
// skip: cron fired without a perTenant fan-out tenant — nothing scoped
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
-
ctx.systemDb.assertTenantMatch(tenantId);
|
|
61
|
-
const dueRows = await selectDueWorkflowRunPending(ctx.systemDb.unsafeRaw("due-row pickup needs FOR UPDATE SKIP LOCKED raw SQL; the query filters by this job's tenantId"), // @cast-boundary db-operator — DbRunner narrows to DbConnection, jobs never run inside a DbTx
|
|
62
|
-
tenantId);
|
|
60
|
+
const dueRows = await selectDueWorkflowRunPending(ctx.systemDb.assertTenantMatch(tenantId));
|
|
63
61
|
for (const row of dueRows) {
|
|
64
62
|
try {
|
|
65
63
|
await ctx.write("workflow-runner:write:resume-run", {
|
|
66
|
-
runId: row.
|
|
67
|
-
stepIndex: row.
|
|
64
|
+
runId: row.runId,
|
|
65
|
+
stepIndex: row.stepIndex,
|
|
68
66
|
});
|
|
69
67
|
}
|
|
70
68
|
catch (error) {
|
|
71
69
|
// skip: one row's dispatch failing (infra hiccup, not a business
|
|
72
70
|
// outcome — resume-run itself never throws for expected cases)
|
|
73
71
|
// must not stop the rest of this tenant's due rows from resuming.
|
|
74
|
-
ctx.log.warn(`[workflow-runner:resume-due-runs] runId=${row.
|
|
72
|
+
ctx.log.warn(`[workflow-runner:resume-due-runs] runId=${row.runId} stepIndex=${row.stepIndex} dispatch failed: ${String(error)}`);
|
|
75
73
|
}
|
|
76
74
|
}
|
|
77
75
|
});
|
package/package.json
CHANGED
|
@@ -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>",
|
|
@@ -499,12 +499,12 @@
|
|
|
499
499
|
}
|
|
500
500
|
},
|
|
501
501
|
"dependencies": {
|
|
502
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
503
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
504
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
505
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
506
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
507
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
502
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.340.0",
|
|
503
|
+
"@cosmicdrift/kumiko-framework": "0.340.0",
|
|
504
|
+
"@cosmicdrift/kumiko-headless": "0.340.0",
|
|
505
|
+
"@cosmicdrift/kumiko-renderer": "0.340.0",
|
|
506
|
+
"@cosmicdrift/kumiko-renderer-web": "0.340.0",
|
|
507
|
+
"@cosmicdrift/kumiko-types": "0.340.0",
|
|
508
508
|
"@mollie/api-client": "^4.5.0",
|
|
509
509
|
"@node-rs/argon2": "^2.0.2",
|
|
510
510
|
"@types/mailparser": "^3.4.6",
|
|
@@ -1017,8 +1017,8 @@
|
|
|
1017
1017
|
],
|
|
1018
1018
|
"devDependencies": {
|
|
1019
1019
|
"@testing-library/user-event": "^14.6.1",
|
|
1020
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1021
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1020
|
+
"@cosmicdrift/kumiko-locale-de": "0.340.0",
|
|
1021
|
+
"@cosmicdrift/kumiko-locale-es": "0.340.0",
|
|
1022
1022
|
"jsqr": "^1.4.0"
|
|
1023
1023
|
}
|
|
1024
1024
|
}
|
|
@@ -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",
|
|
@@ -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",
|
package/src/user/changes.json
CHANGED
|
@@ -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,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,13 +0,0 @@
|
|
|
1
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
// INSERT-once, not UPSERT: unlike subscriptionsProjectionTable (one row per
|
|
3
|
-
// tenant, state overwritten on every event), a payment row is an immutable
|
|
4
|
-
// fact keyed by paymentRowId(tenantId, providerName, providerEventId) — ON
|
|
5
|
-
// CONFLICT DO NOTHING makes a projection rebuild/replay idempotent without
|
|
6
|
-
// ever mutating an existing row.
|
|
7
|
-
export async function insertPaymentProjectionRow(tx, tableName, insertCols) {
|
|
8
|
-
const insertKeys = Object.keys(insertCols);
|
|
9
|
-
const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
|
|
10
|
-
const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO NOTHING`;
|
|
11
|
-
// 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
|
|
12
|
-
await asRawClient(tx).unsafe(sqlText, Object.values(insertCols));
|
|
13
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
export async function upsertSubscriptionProjectionRow(tx, tableName, insertCols, setClauses, params) {
|
|
3
|
-
const insertKeys = Object.keys(insertCols);
|
|
4
|
-
const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
|
|
5
|
-
const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO UPDATE SET ${setClauses.join(", ")}`;
|
|
6
|
-
await asRawClient(tx).unsafe(sqlText, params);
|
|
7
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
/** INSERT ... ON CONFLICT ("id") DO UPDATE SET <setClauses>. */
|
|
3
|
-
export declare function upsertProjectionRow(tx: DbRunner, tableName: string, insertCols: Record<string, unknown>, setClauses: readonly string[], params: readonly unknown[]): Promise<void>;
|
|
4
|
-
/** INSERT ... ON CONFLICT ("id") DO NOTHING — für append-only-Rows
|
|
5
|
-
* (inbound-message: genau EIN received-event pro Stream, Replays
|
|
6
|
-
* no-op'en auf der PK). */
|
|
7
|
-
export declare function insertIgnoreProjectionRow(tx: DbRunner, tableName: string, insertCols: Record<string, unknown>): Promise<void>;
|
|
@@ -1,24 +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
|
-
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
-
/** INSERT ... ON CONFLICT ("id") DO UPDATE SET <setClauses>. */
|
|
10
|
-
export async function upsertProjectionRow(tx, tableName, insertCols, setClauses, params) {
|
|
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
|
-
}
|
|
16
|
-
/** INSERT ... ON CONFLICT ("id") DO NOTHING — für append-only-Rows
|
|
17
|
-
* (inbound-message: genau EIN received-event pro Stream, Replays
|
|
18
|
-
* no-op'en auf der PK). */
|
|
19
|
-
export async function insertIgnoreProjectionRow(tx, tableName, insertCols) {
|
|
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 NOTHING`;
|
|
23
|
-
await asRawClient(tx).unsafe(sqlText, Object.values(insertCols));
|
|
24
|
-
}
|