@cosmicdrift/kumiko-samples 0.353.0 → 0.354.1
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 +18 -2
- package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
- package/packages/bundled-features/src/agent-tools/agent-manifest.ts +11 -5
- package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
- package/packages/bundled-features/src/agent-tools/index.ts +6 -1
- package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
- package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
- package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
- package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +5 -0
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
- package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
- package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
- package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
- package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
- package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
- package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
- package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
- package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
- package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
- package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
- package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +4 -4
- package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
- package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
- package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
- package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
- package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
- package/packages/bundled-features/src/delivery/changes.json +6 -0
- package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
- package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
- package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
- package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
- package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
- package/packages/bundled-features/src/files/changes.json +24 -0
- package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
- package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +72 -23
- package/packages/bundled-features/src/folders/changes.json +6 -0
- package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
- package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
- package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
- package/packages/bundled-features/src/jobs/changes.json +30 -0
- package/packages/bundled-features/src/jobs/constants.ts +1 -0
- package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
- package/packages/bundled-features/src/jobs/feature.ts +7 -1
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
- package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
- package/packages/bundled-features/src/jobs/index.ts +2 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
- package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
- package/packages/bundled-features/src/ledger/changes.json +23 -0
- package/packages/bundled-features/src/ledger/entity.ts +13 -5
- package/packages/bundled-features/src/ledger/feature.ts +3 -0
- package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
- package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
- package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
- package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
- package/packages/bundled-features/src/notes-history/changes.json +13 -0
- package/packages/bundled-features/src/notes-history/entity.ts +4 -0
- package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
- package/packages/bundled-features/src/sessions/feature.ts +21 -8
- package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
- package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
- package/packages/bundled-features/src/sessions/screens.ts +10 -8
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
- package/packages/bundled-features/src/shared/index.ts +2 -1
- package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
- package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
- package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
- package/packages/bundled-features/src/template-resolver/api.ts +3 -3
- package/packages/bundled-features/src/template-resolver/changes.json +5 -0
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
- package/packages/bundled-features/src/tenant/changes.json +12 -0
- package/packages/bundled-features/src/tenant/feature.ts +8 -1
- package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
- package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
- package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
- package/packages/bundled-features/src/tenant-handover/changes.json +6 -0
- package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
- package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
- package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
- package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/feature.ts +13 -1
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +100 -29
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +41 -9
- package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
- package/packages/bundled-features/src/tier-engine/changes.json +5 -0
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
- package/packages/bundled-features/src/user/changes.json +15 -0
- package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
- package/packages/bundled-features/src/user/handlers/list.query.ts +3 -0
- package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
- package/packages/bundled-features/src/user/seeding.ts +34 -0
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
- package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
- package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +72 -14
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +6 -1
- package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
- package/samples/apps/showcase/src/features/items/feature.ts +2 -1
- package/samples/apps/styleguide/src/features/widgets/feature.ts +83 -0
- package/samples/apps/styleguide/src/features/widgets/i18n.ts +12 -0
- package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
- package/samples/apps/ui-walkthrough/package.json +3 -0
- package/samples/apps/use-all-bundled/README.md +1 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
- package/samples/apps/user-data-rights-demo/README.md +1 -3
- package/samples/recipes/legal-pages/README.md +3 -4
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/seed-migration/README.md +0 -2
|
@@ -2,8 +2,11 @@ import {
|
|
|
2
2
|
deleteMany,
|
|
3
3
|
fetchOne,
|
|
4
4
|
insertMany,
|
|
5
|
+
insertOnConflictDoNothing,
|
|
5
6
|
insertOne,
|
|
7
|
+
transaction,
|
|
6
8
|
updateMany,
|
|
9
|
+
upsertOnConflict,
|
|
7
10
|
} from "@cosmicdrift/kumiko-framework/bun-db";
|
|
8
11
|
import {
|
|
9
12
|
configuredPiiSubjectKms,
|
|
@@ -12,12 +15,13 @@ import {
|
|
|
12
15
|
type LocalKeyKmsAdapter,
|
|
13
16
|
PII_ERASED_SENTINEL,
|
|
14
17
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
15
|
-
import type
|
|
18
|
+
import { acquireNamespacedAdvisoryLock, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
16
19
|
import { type Registry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
20
|
import type {
|
|
18
21
|
JobLogEntry,
|
|
19
22
|
JobMeta,
|
|
20
23
|
JobOutcomeMeta,
|
|
24
|
+
JobQueuedMeta,
|
|
21
25
|
JobRunnerOptions,
|
|
22
26
|
} from "@cosmicdrift/kumiko-framework/jobs";
|
|
23
27
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
@@ -27,6 +31,7 @@ import { runCompletedSchema, runFailedSchema, runStartedSchema } from "./events.
|
|
|
27
31
|
import { parseJobInstant } from "./job-instant.js";
|
|
28
32
|
import { jobRunLogsTable, jobRunsTable } from "./job-run-table.js";
|
|
29
33
|
import { tenantJobFailuresTable } from "./tenant-job-failure-table.js";
|
|
34
|
+
import { tenantJobRunsTable } from "./tenant-job-run-table.js";
|
|
30
35
|
|
|
31
36
|
// Matches PgKmsAdapter's default pool size (see tenant/handlers/*.query.ts) —
|
|
32
37
|
// bounds concurrent getOrCreateDek calls so a large log batch doesn't claim
|
|
@@ -51,7 +56,7 @@ export type JobRunLoggerOptions = {
|
|
|
51
56
|
|
|
52
57
|
export type JobRunLoggerCallbacks = Pick<
|
|
53
58
|
JobRunnerOptions,
|
|
54
|
-
"onJobStart" | "onJobComplete" | "onJobFailed"
|
|
59
|
+
"onJobStart" | "onJobComplete" | "onJobFailed" | "onJobQueued" | "onJobDropped"
|
|
55
60
|
>;
|
|
56
61
|
|
|
57
62
|
// Default cap on the bullJobId → runId cache. A worker that starts jobs
|
|
@@ -151,6 +156,10 @@ async function encryptStartedPayload(
|
|
|
151
156
|
return encryptOrSentinel(kms, triggeredById, payload, "payload");
|
|
152
157
|
}
|
|
153
158
|
|
|
159
|
+
// pg_advisory_xact_lock namespace (int4): 'tjfl' as ASCII, disjoint from the
|
|
160
|
+
// framework's other fixed advisory-lock keys.
|
|
161
|
+
const TENANT_JOB_FAILURE_LOCK_NAMESPACE = 0x746a666c;
|
|
162
|
+
|
|
154
163
|
// fw#3079 — which row the tenant-visible failure record lives in: one per
|
|
155
164
|
// (tenant, job, subject), so the next outcome of the same work replaces or
|
|
156
165
|
// clears it. `subject` is null for a job that declares no subjectFields.
|
|
@@ -177,14 +186,22 @@ async function recordTenantJobFailure(
|
|
|
177
186
|
// non-final failure must not show the tenant a failure the next attempt
|
|
178
187
|
// may still resolve.
|
|
179
188
|
if (!where || !messageKey || outcome?.finalAttempt !== true) return;
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
await
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
189
|
+
// Delete-then-insert under a per-key lock instead of an upsert: the two
|
|
190
|
+
// partial unique indexes (subject NULL vs. set) are no ON CONFLICT target.
|
|
191
|
+
// The lock makes the later of two racing final failures replace the earlier
|
|
192
|
+
// one; the indexes are the backstop against any writer that skips the lock.
|
|
193
|
+
await transaction(db, async (tx) => {
|
|
194
|
+
await acquireNamespacedAdvisoryLock(
|
|
195
|
+
tx,
|
|
196
|
+
TENANT_JOB_FAILURE_LOCK_NAMESPACE,
|
|
197
|
+
JSON.stringify([where["tenantId"], where["jobName"], where["subject"]]),
|
|
198
|
+
);
|
|
199
|
+
await deleteMany(tx, tenantJobFailuresTable, where);
|
|
200
|
+
await insertOne(tx, tenantJobFailuresTable, {
|
|
201
|
+
...where,
|
|
202
|
+
messageKey,
|
|
203
|
+
failedAt: Temporal.Now.instant(),
|
|
204
|
+
});
|
|
188
205
|
});
|
|
189
206
|
}
|
|
190
207
|
|
|
@@ -199,8 +216,131 @@ async function clearTenantJobFailure(
|
|
|
199
216
|
await deleteMany(db, tenantJobFailuresTable, where);
|
|
200
217
|
}
|
|
201
218
|
|
|
219
|
+
// fw#3616 — tenant-visible run state. One row per BullMQ job in
|
|
220
|
+
// store_tenant_job_runs; the writes below only ever touch rows of the same
|
|
221
|
+
// bullJobId, except pruning older finished rows of the same key.
|
|
222
|
+
type TenantRunKey = {
|
|
223
|
+
readonly tenantId: string;
|
|
224
|
+
readonly jobName: string;
|
|
225
|
+
readonly subject: string | null;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
function tenantRunKey(
|
|
229
|
+
jobName: string,
|
|
230
|
+
tenantId: string,
|
|
231
|
+
subject: string | null,
|
|
232
|
+
): TenantRunKey | null {
|
|
233
|
+
// skip: tenant-less run (cron resolves to SYSTEM_TENANT_ID) — no tenant-scoped query could read it
|
|
234
|
+
if (tenantId === SYSTEM_TENANT_ID) return null;
|
|
235
|
+
return { tenantId, jobName, subject };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function recordTenantRunQueued(
|
|
239
|
+
db: DbConnection,
|
|
240
|
+
jobName: string,
|
|
241
|
+
bullJobId: string,
|
|
242
|
+
meta: JobQueuedMeta,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
const key = tenantRunKey(jobName, meta.tenantId, meta.subject);
|
|
245
|
+
// skip: system-tenant run, no tenant-scoped query reads it
|
|
246
|
+
if (!key) return;
|
|
247
|
+
const now = Temporal.Now.instant();
|
|
248
|
+
// DO NOTHING: a fast worker can report the start before this enqueue hook
|
|
249
|
+
// returns, and a repeated jobId (debounce, fan-out dedup) is the same job.
|
|
250
|
+
await insertOnConflictDoNothing(
|
|
251
|
+
db,
|
|
252
|
+
tenantJobRunsTable,
|
|
253
|
+
{ ...key, bullJobId, status: "queued", queuedAt: now, updatedAt: now },
|
|
254
|
+
{ conflictKeys: ["bullJobId"] },
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function recordTenantRunStarted(
|
|
259
|
+
db: DbConnection,
|
|
260
|
+
jobName: string,
|
|
261
|
+
bullJobId: string,
|
|
262
|
+
meta: JobMeta,
|
|
263
|
+
): Promise<void> {
|
|
264
|
+
// skip: job did not opt in to tenantVisibleRun
|
|
265
|
+
if (!meta.tenantVisibleRun) return;
|
|
266
|
+
const key = tenantRunKey(jobName, meta.tenantVisibleRun.tenantId, meta.tenantVisibleRun.subject);
|
|
267
|
+
// skip: system-tenant run, no tenant-scoped query reads it
|
|
268
|
+
if (!key) return;
|
|
269
|
+
const now = Temporal.Now.instant();
|
|
270
|
+
await upsertOnConflict(
|
|
271
|
+
db,
|
|
272
|
+
tenantJobRunsTable,
|
|
273
|
+
{ ...key, bullJobId, status: "running", startedAt: now, updatedAt: now },
|
|
274
|
+
{
|
|
275
|
+
conflictKeys: ["bullJobId"],
|
|
276
|
+
update: { status: "running", startedAt: now, finishedAt: null, updatedAt: now },
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function recordTenantRunOutcome(
|
|
282
|
+
db: DbConnection,
|
|
283
|
+
jobName: string,
|
|
284
|
+
bullJobId: string,
|
|
285
|
+
outcome: JobOutcomeMeta | undefined,
|
|
286
|
+
result: "completed" | "failed",
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
// skip: job did not opt in to tenantVisibleRun
|
|
289
|
+
if (!outcome?.tenantVisibleRun) return;
|
|
290
|
+
const key = tenantRunKey(jobName, outcome.tenantId, outcome.tenantVisibleRun.subject);
|
|
291
|
+
// skip: system-tenant run, no tenant-scoped query reads it
|
|
292
|
+
if (!key) return;
|
|
293
|
+
const now = Temporal.Now.instant();
|
|
294
|
+
// A failed attempt BullMQ will retry goes back to waiting: the tenant keeps
|
|
295
|
+
// seeing pending work, not a failure the next attempt may still resolve.
|
|
296
|
+
if (result === "failed" && !outcome.finalAttempt) {
|
|
297
|
+
await updateMany(
|
|
298
|
+
db,
|
|
299
|
+
tenantJobRunsTable,
|
|
300
|
+
{ status: "queued", startedAt: null, finishedAt: null, updatedAt: now },
|
|
301
|
+
{ bullJobId },
|
|
302
|
+
);
|
|
303
|
+
// skip: retry pending, the row stays active until the final attempt
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
await upsertOnConflict(
|
|
307
|
+
db,
|
|
308
|
+
tenantJobRunsTable,
|
|
309
|
+
{ ...key, bullJobId, status: result, finishedAt: now, updatedAt: now },
|
|
310
|
+
{ conflictKeys: ["bullJobId"], update: { status: result, finishedAt: now, updatedAt: now } },
|
|
311
|
+
);
|
|
312
|
+
await deleteMany(db, tenantJobRunsTable, {
|
|
313
|
+
...key,
|
|
314
|
+
status: { in: ["completed", "failed"] },
|
|
315
|
+
bullJobId: { ne: bullJobId },
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function dropTenantRunQueued(db: DbConnection, bullJobId: string): Promise<void> {
|
|
320
|
+
await deleteMany(db, tenantJobRunsTable, { bullJobId, status: "queued" });
|
|
321
|
+
}
|
|
322
|
+
|
|
202
323
|
const log = createFallbackLogger("job-run-logger");
|
|
203
324
|
|
|
325
|
+
// The tenant run state is an optional side write: a throw here must not
|
|
326
|
+
// fail the enqueue, skip the run-row write or turn a completed run into a
|
|
327
|
+
// BullMQ retry.
|
|
328
|
+
async function isolateTenantRunWrite(
|
|
329
|
+
write: () => Promise<void>,
|
|
330
|
+
jobName: string,
|
|
331
|
+
bullJobId: string,
|
|
332
|
+
): Promise<void> {
|
|
333
|
+
try {
|
|
334
|
+
await write();
|
|
335
|
+
} catch (e) {
|
|
336
|
+
log.error("tenant job run state write failed", {
|
|
337
|
+
jobName,
|
|
338
|
+
bullJobId,
|
|
339
|
+
error: e instanceof Error ? e.message : String(e),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
204
344
|
// The tenant failure record is an optional side write: a throw here must not
|
|
205
345
|
// skip the run-row update or turn a completed run into a BullMQ retry.
|
|
206
346
|
async function isolateTenantFailureWrite(
|
|
@@ -289,7 +429,24 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
289
429
|
}
|
|
290
430
|
|
|
291
431
|
return {
|
|
432
|
+
onJobQueued: async (jobName: string, bullJobId: string, meta: JobQueuedMeta) => {
|
|
433
|
+
await isolateTenantRunWrite(
|
|
434
|
+
() => recordTenantRunQueued(db, jobName, bullJobId, meta),
|
|
435
|
+
jobName,
|
|
436
|
+
bullJobId,
|
|
437
|
+
);
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
onJobDropped: async (jobName: string, bullJobId: string) => {
|
|
441
|
+
await isolateTenantRunWrite(() => dropTenantRunQueued(db, bullJobId), jobName, bullJobId);
|
|
442
|
+
},
|
|
443
|
+
|
|
292
444
|
onJobStart: async (jobName: string, bullJobId: string, meta: JobMeta) => {
|
|
445
|
+
await isolateTenantRunWrite(
|
|
446
|
+
() => recordTenantRunStarted(db, jobName, bullJobId, meta),
|
|
447
|
+
jobName,
|
|
448
|
+
bullJobId,
|
|
449
|
+
);
|
|
293
450
|
const runId = generateId();
|
|
294
451
|
const triggeredById = meta.triggeredById ?? null;
|
|
295
452
|
cachePut(bullJobId, runId, triggeredById);
|
|
@@ -336,6 +493,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
336
493
|
jobName,
|
|
337
494
|
bullJobId,
|
|
338
495
|
);
|
|
496
|
+
await isolateTenantRunWrite(
|
|
497
|
+
() => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "completed"),
|
|
498
|
+
jobName,
|
|
499
|
+
bullJobId,
|
|
500
|
+
);
|
|
339
501
|
const resolved = await resolveRun(bullJobId);
|
|
340
502
|
// skip: state loss between start + complete (worker restart, cache
|
|
341
503
|
// evicted AND DB has no matching bull_job_id). Rare edge case; we
|
|
@@ -400,6 +562,11 @@ export function createJobRunLogger(opts: JobRunLoggerOptions): JobRunLoggerCallb
|
|
|
400
562
|
jobName,
|
|
401
563
|
bullJobId,
|
|
402
564
|
);
|
|
565
|
+
await isolateTenantRunWrite(
|
|
566
|
+
() => recordTenantRunOutcome(db, jobName, bullJobId, outcome, "failed"),
|
|
567
|
+
jobName,
|
|
568
|
+
bullJobId,
|
|
569
|
+
);
|
|
403
570
|
const resolved = await resolveRun(bullJobId);
|
|
404
571
|
// skip: same rare state-loss case as in onJobComplete — drop the
|
|
405
572
|
// failure write rather than forge a run row from scratch.
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import {
|
|
3
3
|
type EntityTableMeta,
|
|
4
|
+
index,
|
|
4
5
|
instant,
|
|
5
6
|
table as pgTable,
|
|
6
7
|
serial,
|
|
7
8
|
sql,
|
|
8
9
|
text,
|
|
10
|
+
uniqueIndex,
|
|
9
11
|
uuid,
|
|
10
12
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
11
13
|
|
|
@@ -23,14 +25,31 @@ import {
|
|
|
23
25
|
// canonical JSON of the job's declared subjectFields, or NULL for a job that
|
|
24
26
|
// declares none — stored in clear, so a job must not declare a PII field
|
|
25
27
|
// as its subject.
|
|
26
|
-
export const tenantJobFailuresTable = pgTable(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
28
|
+
export const tenantJobFailuresTable = pgTable(
|
|
29
|
+
"store_tenant_job_failures",
|
|
30
|
+
{
|
|
31
|
+
id: serial("id").primaryKey(),
|
|
32
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
33
|
+
jobName: text("job_name").notNull(),
|
|
34
|
+
subject: text("subject"),
|
|
35
|
+
messageKey: text("message_key").notNull(),
|
|
36
|
+
failedAt: instant("failed_at").default(sql`now()`).notNull(),
|
|
37
|
+
},
|
|
38
|
+
(t) => [
|
|
39
|
+
// One row per key, also the lookup for the delete before every write;
|
|
40
|
+
// subject NULL needs its own partial index because NULLs
|
|
41
|
+
// are distinct in a plain unique index.
|
|
42
|
+
uniqueIndex("store_tenant_job_failures_tenant_job_subject_uidx")
|
|
43
|
+
.on(t.tenantId, t.jobName, t.subject)
|
|
44
|
+
.where(sql`subject IS NOT NULL`),
|
|
45
|
+
uniqueIndex("store_tenant_job_failures_tenant_job_null_subject_uidx")
|
|
46
|
+
.on(t.tenantId, t.jobName)
|
|
47
|
+
.where(sql`subject IS NULL`),
|
|
48
|
+
// The tenant list sorts by failed_at; retention filters on it alone.
|
|
49
|
+
index("store_tenant_job_failures_tenant_failed_at_idx").on(t.tenantId, t.failedAt),
|
|
50
|
+
index("store_tenant_job_failures_failed_at_idx").on(t.failedAt),
|
|
51
|
+
],
|
|
52
|
+
);
|
|
34
53
|
|
|
35
54
|
export const tenantJobFailuresTableMeta: EntityTableMeta = requireEntityTableMeta(
|
|
36
55
|
tenantJobFailuresTable,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { requireEntityTableMeta } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import {
|
|
3
|
+
type EntityTableMeta,
|
|
4
|
+
index,
|
|
5
|
+
instant,
|
|
6
|
+
table as pgTable,
|
|
7
|
+
serial,
|
|
8
|
+
sql,
|
|
9
|
+
text,
|
|
10
|
+
uniqueIndex,
|
|
11
|
+
uuid,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
|
|
14
|
+
export type TenantJobRunStatus = "queued" | "running" | "completed" | "failed";
|
|
15
|
+
|
|
16
|
+
// The tenant's own view of a job's run state (fw#3616). Direct-write store
|
|
17
|
+
// like store_job_runs, written by job-run-logger.ts from the runner callbacks.
|
|
18
|
+
//
|
|
19
|
+
// store_job_runs cannot serve the tenant: it anchors every run on the system
|
|
20
|
+
// tenant, has no subject, and gets its row only when a run starts, so a queued
|
|
21
|
+
// job is invisible there. This table holds no payload, error text or logs by
|
|
22
|
+
// design — the tenant learns only that work is waiting, running or finished.
|
|
23
|
+
//
|
|
24
|
+
// One row per BullMQ job. Active rows (queued/running) are kept as they are;
|
|
25
|
+
// a finished row replaces earlier finished rows of the same (tenant, job,
|
|
26
|
+
// subject), so only the latest outcome stays. `subject` is the canonical JSON
|
|
27
|
+
// of the job's declared subjectFields, or NULL for none — stored in clear, so
|
|
28
|
+
// a job must not declare a PII field as its subject.
|
|
29
|
+
export const tenantJobRunsTable = pgTable(
|
|
30
|
+
"store_tenant_job_runs",
|
|
31
|
+
{
|
|
32
|
+
id: serial("id").primaryKey(),
|
|
33
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
34
|
+
jobName: text("job_name").notNull(),
|
|
35
|
+
subject: text("subject"),
|
|
36
|
+
bullJobId: text("bull_job_id").notNull(),
|
|
37
|
+
status: text("status").notNull(),
|
|
38
|
+
queuedAt: instant("queued_at"),
|
|
39
|
+
startedAt: instant("started_at"),
|
|
40
|
+
finishedAt: instant("finished_at"),
|
|
41
|
+
updatedAt: instant("updated_at").default(sql`now()`).notNull(),
|
|
42
|
+
},
|
|
43
|
+
(t) => [
|
|
44
|
+
uniqueIndex("store_tenant_job_runs_bull_job_id_unique").on(t.bullJobId),
|
|
45
|
+
// Finishing a run prunes older outcomes by this key; the tenant query reads by it.
|
|
46
|
+
index("store_tenant_job_runs_tenant_job_subject_idx").on(t.tenantId, t.jobName, t.subject),
|
|
47
|
+
// Stale sweep and retention filter on age alone.
|
|
48
|
+
index("store_tenant_job_runs_updated_at_idx").on(t.updatedAt),
|
|
49
|
+
],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
export const tenantJobRunsTableMeta: EntityTableMeta = requireEntityTableMeta(
|
|
53
|
+
tenantJobRunsTable,
|
|
54
|
+
"tenantJobRunsTable",
|
|
55
|
+
);
|
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "confirm-schedule-period serializes concurrent confirms of the same schedule period",
|
|
6
|
+
"detail": "ledger: `confirm-schedule-period` serializes concurrent confirms of the same (tenant, schedule period) with an advisory lock, so direct calls can no longer double-book."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.354.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "Schedule sourceRef is unique per tenant (partial unique index read_ledger_schedules_tenant_id_source_ref_uidx)",
|
|
12
|
+
"migration": "sourceRef is now unique per tenant: a second createSchedule (or updateSchedule) with a sourceRef already used in the same tenant fails with unique_violation (HTTP 409). Schedules without a sourceRef and the same sourceRef in different tenants stay allowed. Before applying, check for duplicates: SELECT tenant_id, source_ref, count(*) FROM read_ledger_schedules WHERE source_ref IS NOT NULL GROUP BY tenant_id, source_ref HAVING count(*) > 1; and resolve them through real writes (updateSchedule with another sourceRef). kumiko schema generate treats the new unique index on a managed projection as destructive (DROP TABLE + CREATE TABLE + .rebuild.json); a rebuild would also hit the unique index on historical duplicates. Hand-edit the generated migration: replace the generated DROP/CREATE with exactly DROP INDEX IF EXISTS \"read_ledger_schedules_tenant_id_source_ref_idx\"; CREATE UNIQUE INDEX IF NOT EXISTS \"read_ledger_schedules_tenant_id_source_ref_uidx\" ON \"read_ledger_schedules\" (\"tenant_id\", \"source_ref\") WHERE \"source_ref\" IS NOT NULL; and discard the generated .rebuild.json (delete the file, do not commit it). Code that does find-then-create by sourceRef should expect unique_violation on create and re-read the schedule that won the race."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.354.0",
|
|
16
|
+
"type": "fix",
|
|
17
|
+
"title": "ledger create-transaction and reverse-transaction resolve agent risk high"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"version": "0.354.0",
|
|
21
|
+
"type": "breaking",
|
|
22
|
+
"title": "A ledger schedule needs subjectType and subjectId together or neither",
|
|
23
|
+
"migration": "Create and update schedule calls that set only one of the two fields now fail validation. Send both fields or neither."
|
|
24
|
+
},
|
|
2
25
|
{
|
|
3
26
|
"version": "0.331.0",
|
|
4
27
|
"type": "improvement",
|
|
@@ -108,6 +108,8 @@ export const transactionEntity = createEntity({
|
|
|
108
108
|
filterable: true,
|
|
109
109
|
}),
|
|
110
110
|
},
|
|
111
|
+
// Subject ids are only unique within their type, so lookups filter on both
|
|
112
|
+
// columns; a subjectId-only filter would not be served by this index.
|
|
111
113
|
indexes: [{ columns: ["tenantId", "subjectType", "subjectId"] }],
|
|
112
114
|
});
|
|
113
115
|
|
|
@@ -163,8 +165,8 @@ export const scheduleEntity = createEntity({
|
|
|
163
165
|
filterable: true,
|
|
164
166
|
}),
|
|
165
167
|
// Caller-assigned stable key (e.g. one id per lease position) to find the
|
|
166
|
-
// schedule again
|
|
167
|
-
//
|
|
168
|
+
// schedule again; unique per tenant when set. `description` stays pure
|
|
169
|
+
// display text instead of a lookup key.
|
|
168
170
|
sourceRef: createTextField({
|
|
169
171
|
maxLength: 128,
|
|
170
172
|
personal: false,
|
|
@@ -172,10 +174,16 @@ export const scheduleEntity = createEntity({
|
|
|
172
174
|
filterable: true,
|
|
173
175
|
}),
|
|
174
176
|
},
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
+
// Find-then-create by sourceRef races across tabs/devices; only the DB closes
|
|
178
|
+
// that gap. Partial because sourceRef is optional. A new name (not the old
|
|
179
|
+
// non-unique default) keeps the migration an in-place CREATE UNIQUE INDEX.
|
|
177
180
|
indexes: [
|
|
178
181
|
{ columns: ["tenantId", "subjectType", "subjectId"] },
|
|
179
|
-
{
|
|
182
|
+
{
|
|
183
|
+
columns: ["tenantId", "sourceRef"],
|
|
184
|
+
unique: true,
|
|
185
|
+
where: sql`"source_ref" IS NOT NULL`,
|
|
186
|
+
name: "read_ledger_schedules_tenant_id_source_ref_uidx",
|
|
187
|
+
},
|
|
180
188
|
],
|
|
181
189
|
});
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
createIncomeStatementHandler,
|
|
32
32
|
} from "./handlers/reports.query.js";
|
|
33
33
|
import { createReverseTransactionHandler } from "./handlers/reverse-transaction.write.js";
|
|
34
|
+
import { requireScheduleSubjectPair } from "./schedule-subject-pair.js";
|
|
34
35
|
|
|
35
36
|
// Opt-in tier-gating (mirrors folders): when set, the feature declares itself
|
|
36
37
|
// r.toggleable so the dispatcher gate + tier-engine can switch the WHOLE ledger
|
|
@@ -125,6 +126,8 @@ function registerLedger(
|
|
|
125
126
|
"Changes a recurring booking template's description, accounts, amount, interval or date window; use it when a standing order's terms change, already confirmed periods stay booked as they were.",
|
|
126
127
|
}),
|
|
127
128
|
);
|
|
129
|
+
r.hook("preSave", "schedule:create", requireScheduleSubjectPair);
|
|
130
|
+
r.hook("preSave", "schedule:update", requireScheduleSubjectPair);
|
|
128
131
|
r.writeHandler(createConfirmSchedulePeriodHandler(access));
|
|
129
132
|
r.queryHandler(
|
|
130
133
|
defineEntityListHandler("schedule", scheduleEntity, {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { acquireNamespacedAdvisoryLock } from "@cosmicdrift/kumiko-framework/db";
|
|
1
2
|
import type {
|
|
2
3
|
AccessRule,
|
|
3
4
|
WriteHandlerDef,
|
|
@@ -19,6 +20,12 @@ import {
|
|
|
19
20
|
} from "../schemas.js";
|
|
20
21
|
import { readSubjectReference } from "../subject-reference.js";
|
|
21
22
|
|
|
23
|
+
// pg_advisory_xact_lock namespace (int4): 'lcsp' as ASCII, disjoint from the
|
|
24
|
+
// framework's other fixed advisory-lock keys.
|
|
25
|
+
const CONFIRM_PERIOD_LOCK_NAMESPACE = 0x6c637370;
|
|
26
|
+
const CONFIRM_PERIOD_LOCK_REASON =
|
|
27
|
+
"takes the per-(tenant, reference) advisory lock so parallel confirms cannot both pass the already-booked check; TenantDb has no lock API";
|
|
28
|
+
|
|
22
29
|
// confirm-schedule-period — turn ONE projected period of a schedule into a posted,
|
|
23
30
|
// balanced transaction (debit +amount / credit −amount), tagged with
|
|
24
31
|
// scheduleReference(scheduleId, period) so the host can merge Soll vs. Ist.
|
|
@@ -35,6 +42,7 @@ export function createConfirmSchedulePeriodHandler(
|
|
|
35
42
|
name: "confirm-schedule-period",
|
|
36
43
|
schema: confirmSchedulePeriodPayloadSchema,
|
|
37
44
|
access,
|
|
45
|
+
escapeHatch: { grants: ["unsafeRaw"], reason: CONFIRM_PERIOD_LOCK_REASON },
|
|
38
46
|
description:
|
|
39
47
|
"Turns one month of a recurring schedule into a posted balanced entry between its debit and credit account, returning the existing booking instead when that period is already booked and not reversed; use it to move a forecast period into the actual books.",
|
|
40
48
|
handler: async (event, ctx) => {
|
|
@@ -68,9 +76,13 @@ export function createConfirmSchedulePeriodHandler(
|
|
|
68
76
|
// normally 0 or 1 row, never O(tenant's-full-history); (2) any Storno
|
|
69
77
|
// that mirrors one of those candidates (a tx is reversed when ANOTHER
|
|
70
78
|
// tx's `reference` names its id).
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
79
|
+
// No unique index on (tenantId, reference): a Storno makes re-confirming the
|
|
80
|
+
// same reference legitimate, so the check-then-write is serialized by a lock.
|
|
81
|
+
await acquireNamespacedAdvisoryLock(
|
|
82
|
+
ctx.db.unsafeRaw(),
|
|
83
|
+
CONFIRM_PERIOD_LOCK_NAMESPACE,
|
|
84
|
+
`${event.user.tenantId}:${reference}`,
|
|
85
|
+
);
|
|
74
86
|
const candidates = await ctx.db.selectMany(transactionTable, {
|
|
75
87
|
tenantId: event.user.tenantId,
|
|
76
88
|
reference,
|
|
@@ -19,6 +19,7 @@ export function createCreateTransactionHandler(
|
|
|
19
19
|
access,
|
|
20
20
|
description:
|
|
21
21
|
"Books one balanced journal entry from a date, description and at least two posting lines summing to zero, after verifying every named account exists; use it to record any financial movement in the tenant's books.",
|
|
22
|
+
agent: { risk: "high" },
|
|
22
23
|
handler: async (event, ctx) => {
|
|
23
24
|
const payload = event.payload as CreateTransactionPayload; // @cast-boundary engine-payload
|
|
24
25
|
|
|
@@ -26,6 +26,7 @@ export function createReverseTransactionHandler(
|
|
|
26
26
|
access,
|
|
27
27
|
description:
|
|
28
28
|
"Books the mirror image of a posted journal entry as a new entry referencing the original, refusing entries that are not posted or already reversed; use it as the only way to correct a booking, since entries themselves are immutable.",
|
|
29
|
+
agent: { risk: "high" },
|
|
29
30
|
handler: async (event, ctx) => {
|
|
30
31
|
const payload = event.payload as ReverseTransactionPayload; // @cast-boundary engine-payload
|
|
31
32
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PreSaveHookFn } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { ValidationError } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
|
+
|
|
4
|
+
function isSet(value: unknown): boolean {
|
|
5
|
+
return value !== undefined && value !== null && value !== "";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// subjectId is only unique within its subjectType, so a schedule with one of
|
|
9
|
+
// the two would be found by a lone-subjectId filter among other object types.
|
|
10
|
+
// The effective pair is checked (changes over the stored row) because an
|
|
11
|
+
// update may touch only one of the two fields.
|
|
12
|
+
export const requireScheduleSubjectPair: PreSaveHookFn = async (changes, ctx) => {
|
|
13
|
+
const effective = (key: "subjectType" | "subjectId"): unknown =>
|
|
14
|
+
key in changes ? changes[key] : ctx.previous[key];
|
|
15
|
+
const hasType = isSet(effective("subjectType"));
|
|
16
|
+
const hasId = isSet(effective("subjectId"));
|
|
17
|
+
if (hasType === hasId) return changes;
|
|
18
|
+
throw new ValidationError({
|
|
19
|
+
fields: [
|
|
20
|
+
{
|
|
21
|
+
path: hasType ? "subjectId" : "subjectType",
|
|
22
|
+
code: "required",
|
|
23
|
+
i18nKey: "errors.validation.required",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
};
|
|
@@ -33,8 +33,8 @@ import {
|
|
|
33
33
|
import {
|
|
34
34
|
BlockedHostError,
|
|
35
35
|
HostResolutionError,
|
|
36
|
-
MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
37
36
|
type MailConnectTarget,
|
|
37
|
+
mailAllowedPrivateHostsEnvSchema,
|
|
38
38
|
readAllowedPrivateMailHostsFromEnv,
|
|
39
39
|
requireDefined,
|
|
40
40
|
requireNonEmpty,
|
|
@@ -50,7 +50,6 @@ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secr
|
|
|
50
50
|
import { access, createTenantConfig, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
51
51
|
import { UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
52
52
|
import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
53
|
-
import * as z from "zod";
|
|
54
53
|
|
|
55
54
|
const FEATURE_NAME = "mail-transport-smtp";
|
|
56
55
|
|
|
@@ -59,19 +58,9 @@ const log = createFallbackLogger(FEATURE_NAME);
|
|
|
59
58
|
// Operator escape hatch for an internal relay or a dev/test SMTP server
|
|
60
59
|
// (mailpit, MailHog): KUMIKO_MAIL_ALLOWED_PRIVATE_HOSTS, an operator env
|
|
61
60
|
// var never a tenant-config value, so a tenant can never grant themselves
|
|
62
|
-
// the private-host bypass.
|
|
63
|
-
// inbound-provider-imap
|
|
64
|
-
|
|
65
|
-
// see foundation-shared/mail-host-policy.ts for the shared reader + the
|
|
66
|
-
// guard this feeds.
|
|
67
|
-
export const mailTransportSmtpEnvSchema = z.object({
|
|
68
|
-
[MAIL_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
|
|
69
|
-
.string()
|
|
70
|
-
.optional()
|
|
71
|
-
.describe(
|
|
72
|
-
"Comma-separated operator allowlist of private/internal hosts (e.g. a local mailpit/greenmail dev server) that bypass the public-address check for SMTP and IMAP host config. Shared with inbound-provider-imap — never a tenant-config value.",
|
|
73
|
-
),
|
|
74
|
-
});
|
|
61
|
+
// the private-host bypass. The schema instance is shared with
|
|
62
|
+
// inbound-provider-imap (see foundation-shared/mail-host-policy.ts).
|
|
63
|
+
export const mailTransportSmtpEnvSchema = mailAllowedPrivateHostsEnvSchema;
|
|
75
64
|
|
|
76
65
|
// Test-only DNS seam — production never calls this, resolveMailConnectTarget
|
|
77
66
|
// defaults to the real resolver. Lets tests pin deterministic, network-free
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.354.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "notes-history adds a composite index on (tenantId, entityType, entityId) for note entries, so the schema-drift check fails until the app regenerates",
|
|
6
|
+
"detail": "Review batch H2: framework parts of consumer-app findings.\n- `file-derivatives`: the `isPublic` predicate args carry `fileRefId`, so a predicate can reject a client-spoofed FileRef that claims another entity's `entityId`/`fieldName`.\n- `sessions`: `createSessionsFeature({ adminAccess: \"systemAdmin\" })` narrows the admin list/detail queries and both admin screens together (default `\"admin\"`, unchanged).\n- `user-profile`: `change-email` is open to every signed-in user (`openToAll`), so a tenant member whose only role is `TenantAdmin` can change their own email; the handler still re-checks the password.\n- `notes-history`: composite index on `(tenantId, entityType, entityId)` for note entries. Apps run `kumiko-schema generate` after the bump.\n- guards: `// kumiko-lint-ignore a,b reason` suppresses several guards on one line (`lineHasIgnoreTag`); `primitives-discipline` and `no-custom-primitives` honour it.\n- dev-server: `kumiko-build --check` verifies `.kumiko/` is current without writing (exit 1 on drift); `runCodegen` takes `checkOnly`.",
|
|
7
|
+
"migration": "Run `kumiko-schema generate` in apps that mount notes-history and commit the generated migration."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.354.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "read_note_mentions gets a tenant, subject index",
|
|
13
|
+
"migration": "Run `kumiko schema generate`; it emits an in-place CREATE INDEX with no DROP and no rebuild."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.338.0",
|
|
4
17
|
"type": "improvement",
|
|
@@ -91,6 +91,8 @@ export function createNoteEntryEntity(
|
|
|
91
91
|
find: "none",
|
|
92
92
|
}),
|
|
93
93
|
},
|
|
94
|
+
// Every host detail screen reads its notes by tenant + host entity.
|
|
95
|
+
indexes: [{ columns: ["tenantId", "entityType", "entityId"] }],
|
|
94
96
|
});
|
|
95
97
|
}
|
|
96
98
|
|
|
@@ -122,6 +124,8 @@ export function createNoteMentionEntity() {
|
|
|
122
124
|
}),
|
|
123
125
|
subjectId: createTextField({ required: true, maxLength: 64, personal: "ref" }),
|
|
124
126
|
},
|
|
127
|
+
// The forget cascade looks mentions up by subject within one tenant.
|
|
128
|
+
indexes: [{ columns: ["tenantId", "subjectId"] }],
|
|
125
129
|
});
|
|
126
130
|
}
|
|
127
131
|
|
|
@@ -66,6 +66,7 @@ export function createPatCreateHandler(opts: CreatePatOptions = {}) {
|
|
|
66
66
|
},
|
|
67
67
|
},
|
|
68
68
|
escapeHatch: {
|
|
69
|
+
grants: ["systemIdentity"],
|
|
69
70
|
reason:
|
|
70
71
|
"Reads the caller's own passwordHash (privileged-only field) via ctx.queryAs(SYSTEM, " +
|
|
71
72
|
"user:findForAuth) to re-verify the current password before minting the token. Also " +
|