@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
resetPiiSubjectKmsForTests,
|
|
12
12
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
13
13
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
14
15
|
import {
|
|
15
16
|
setupTestStack,
|
|
16
17
|
type TestStack,
|
|
@@ -41,6 +42,7 @@ import { SessionHandlers, SessionQueries } from "../constants";
|
|
|
41
42
|
import { createSessionsFeature } from "../feature";
|
|
42
43
|
import { userSessionEntity, userSessionTable } from "../schema/user-session";
|
|
43
44
|
import { createSessionCallbacks, type SessionCallbacks } from "../session-callbacks";
|
|
45
|
+
import { SESSION_REVOKED_EVENT_QN, type SessionRevokedPayload } from "../session-revoked-event";
|
|
44
46
|
import { sessionCallbacksFromLateBound } from "../testing";
|
|
45
47
|
import { makeSessionHelpers } from "./test-helpers";
|
|
46
48
|
|
|
@@ -84,6 +86,7 @@ beforeAll(async () => {
|
|
|
84
86
|
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
85
87
|
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
86
88
|
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
89
|
+
await createEventsTable(stack.db);
|
|
87
90
|
});
|
|
88
91
|
|
|
89
92
|
afterAll(async () => {
|
|
@@ -91,7 +94,12 @@ afterAll(async () => {
|
|
|
91
94
|
});
|
|
92
95
|
|
|
93
96
|
beforeEach(async () => {
|
|
94
|
-
await resetTestTables(stack.db, [
|
|
97
|
+
await resetTestTables(stack.db, [
|
|
98
|
+
userTable,
|
|
99
|
+
tenantMembershipsTable,
|
|
100
|
+
userSessionTable,
|
|
101
|
+
eventsTable,
|
|
102
|
+
]);
|
|
95
103
|
});
|
|
96
104
|
|
|
97
105
|
describe("sessions feature — login → check → revoke → rejected", () => {
|
|
@@ -191,6 +199,69 @@ describe("sessions feature — login → check → revoke → rejected", () => {
|
|
|
191
199
|
expect(preservedRevokedAt?.epochMilliseconds).toBe(originalRevokedAt?.epochMilliseconds);
|
|
192
200
|
});
|
|
193
201
|
|
|
202
|
+
// #1559 — store_user_sessions stays a direct-write store (no CRUD
|
|
203
|
+
// lifecycle events, see feature.ts), but a successful revoke must still
|
|
204
|
+
// append a lightweight session-revoked event so a cross-instance
|
|
205
|
+
// consumer (#1560) can react. Asserted directly against kumiko_events —
|
|
206
|
+
// the response body carries no trace of it.
|
|
207
|
+
test("revoke appends a session-revoked event carrying the userId + sid", async () => {
|
|
208
|
+
const { userId } = await h.seedUser("event1@example.com", "pw-long-enough");
|
|
209
|
+
const { token, sid } = await h.login("event1@example.com", "pw-long-enough");
|
|
210
|
+
|
|
211
|
+
const res = await h.authedPost("/api/write", token, {
|
|
212
|
+
type: SessionHandlers.revoke,
|
|
213
|
+
payload: { id: sid },
|
|
214
|
+
});
|
|
215
|
+
expect(res.status).toBe(200);
|
|
216
|
+
|
|
217
|
+
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
218
|
+
expect(events).toHaveLength(1);
|
|
219
|
+
const payload = events[0]?.["payload"] as SessionRevokedPayload;
|
|
220
|
+
expect(payload.userId).toBe(userId);
|
|
221
|
+
expect(payload.sessionIds).toEqual([sid]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// Negative case: an already-revoked sid must NOT emit a second event —
|
|
225
|
+
// a spurious invalidation is the failure mode #1559 guards against.
|
|
226
|
+
test("revoking an already-revoked sid emits no additional event", async () => {
|
|
227
|
+
await h.seedUser("event2@example.com", "pw-long-enough");
|
|
228
|
+
const first = await h.login("event2@example.com", "pw-long-enough");
|
|
229
|
+
|
|
230
|
+
await h.authedPost("/api/write", first.token, {
|
|
231
|
+
type: SessionHandlers.revoke,
|
|
232
|
+
payload: { id: first.sid },
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const second = await h.login("event2@example.com", "pw-long-enough");
|
|
236
|
+
const retry = await h.authedPost("/api/write", second.token, {
|
|
237
|
+
type: SessionHandlers.revoke,
|
|
238
|
+
payload: { id: first.sid },
|
|
239
|
+
});
|
|
240
|
+
expect(retry.status).toBe(422);
|
|
241
|
+
|
|
242
|
+
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
243
|
+
expect(events).toHaveLength(1); // only the first, successful revoke
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("revoke-all-others appends one event listing every revoked sid", async () => {
|
|
247
|
+
const { userId } = await h.seedUser("event3@example.com", "pw-long-enough");
|
|
248
|
+
const a = await h.login("event3@example.com", "pw-long-enough");
|
|
249
|
+
const b = await h.login("event3@example.com", "pw-long-enough");
|
|
250
|
+
const c = await h.login("event3@example.com", "pw-long-enough");
|
|
251
|
+
|
|
252
|
+
const res = await h.authedPost("/api/write", b.token, {
|
|
253
|
+
type: SessionHandlers.revokeAllOthers,
|
|
254
|
+
payload: {},
|
|
255
|
+
});
|
|
256
|
+
expect(res.status).toBe(200);
|
|
257
|
+
|
|
258
|
+
const events = await selectMany(stack.db, eventsTable, { type: SESSION_REVOKED_EVENT_QN });
|
|
259
|
+
expect(events).toHaveLength(1);
|
|
260
|
+
const payload = events[0]?.["payload"] as SessionRevokedPayload;
|
|
261
|
+
expect(payload.userId).toBe(userId);
|
|
262
|
+
expect(new Set(payload.sessionIds)).toEqual(new Set([a.sid, c.sid]));
|
|
263
|
+
});
|
|
264
|
+
|
|
194
265
|
test("session:mine only returns live sessions, marks the current one", async () => {
|
|
195
266
|
await h.seedUser("mine@example.com", "pw-long-enough");
|
|
196
267
|
// Three logins = three sessions (think: browser, mobile app, tablet)
|
package/src/sessions/feature.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type SessionAllOthersRevoker,
|
|
25
25
|
type SessionMassRevoker,
|
|
26
26
|
} from "./session-callbacks";
|
|
27
|
+
import { SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema } from "./session-revoked-event";
|
|
27
28
|
|
|
28
29
|
export type SessionsFeatureOptions = {
|
|
29
30
|
// A successful update on the `user` entity that changes the `passwordHash`
|
|
@@ -137,6 +138,17 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
|
|
|
137
138
|
},
|
|
138
139
|
} satisfies SessionStoreProvider);
|
|
139
140
|
|
|
141
|
+
// Custom domain-event for cross-instance access-invalidation (#1559).
|
|
142
|
+
// r.defineEvent registers the schema so ctx.unsafeAppendEvent (revoke,
|
|
143
|
+
// revoke-all-others) enforces it at append time. revoke-all-for-user
|
|
144
|
+
// appends via the low-level append() instead (needs to anchor on
|
|
145
|
+
// SYSTEM_TENANT_ID — see that handler for why) — append() does NOT
|
|
146
|
+
// consult the registry, so that callsite parses against
|
|
147
|
+
// sessionRevokedSchema explicitly, same guarantee via a different path.
|
|
148
|
+
// No projection: the payload IS the read, consumed directly off the
|
|
149
|
+
// event-store NOTIFY (#1560).
|
|
150
|
+
r.defineEvent(SESSION_REVOKED_EVENT_SHORT, sessionRevokedSchema);
|
|
151
|
+
|
|
140
152
|
const handlers = {
|
|
141
153
|
revoke: r.writeHandler(revokeWrite),
|
|
142
154
|
revokeAllOthers: r.writeHandler(revokeAllOthersWrite),
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { access, defineWriteHandler, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
3
5
|
import { Temporal } from "temporal-polyfill";
|
|
4
6
|
import { z } from "zod";
|
|
5
7
|
import { userSessionTable } from "../schema/user-session";
|
|
8
|
+
import {
|
|
9
|
+
SESSION_REVOKED_AGGREGATE_TYPE,
|
|
10
|
+
SESSION_REVOKED_EVENT_QN,
|
|
11
|
+
sessionRevokedSchema,
|
|
12
|
+
} from "../session-revoked-event";
|
|
6
13
|
|
|
7
14
|
// Mass-revoke ALL live sessions for a target user — privileged-only.
|
|
8
|
-
// Used by user-data-rights:restrict-account
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
15
|
+
// Used by user-data-rights:restrict-account for account-freeze (DSGVO
|
|
16
|
+
// Art. 18) and potentially other ops flows ("ban user", "compromised
|
|
17
|
+
// account"). Unlike revoke-all-others, the caller's own session (if any)
|
|
18
|
+
// is revoked too — the caller is System (cron/operator/cross-feature),
|
|
19
|
+
// not the end-user themselves.
|
|
13
20
|
//
|
|
14
|
-
// Tenant-scope:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
21
|
+
// Tenant-scope: the userSession schema persists tenantId per row (a user
|
|
22
|
+
// can have sessions in multiple tenants). We revoke cross-tenant because
|
|
23
|
+
// "account restriction" is a global user-level statement (the forget path
|
|
24
|
+
// is global too, see the User-Entity special doc). The UPDATE filters
|
|
25
|
+
// only on userId.
|
|
19
26
|
export const revokeAllForUserWrite = defineWriteHandler({
|
|
20
27
|
name: "user-session:revoke-all-for-user",
|
|
21
28
|
schema: z.object({
|
|
@@ -23,13 +30,45 @@ export const revokeAllForUserWrite = defineWriteHandler({
|
|
|
23
30
|
}),
|
|
24
31
|
access: { roles: access.privileged },
|
|
25
32
|
handler: async (event, ctx) => {
|
|
26
|
-
const updated = await updateMany(
|
|
33
|
+
const updated = await updateMany<{ id: string }>(
|
|
27
34
|
ctx.db.raw,
|
|
28
35
|
userSessionTable,
|
|
29
36
|
{ revokedAt: Temporal.Now.instant() },
|
|
30
37
|
{ userId: event.payload.userId, revokedAt: null },
|
|
31
38
|
);
|
|
32
39
|
|
|
40
|
+
// Lightweight append alongside the direct-write above (#1559) — see
|
|
41
|
+
// revoke.write.ts for why this isn't a lifecycle event on
|
|
42
|
+
// store_user_sessions. Uses the low-level append() (not
|
|
43
|
+
// ctx.unsafeAppendEvent) because this handler is deliberately
|
|
44
|
+
// cross-tenant: the privileged caller's own tenantId (what
|
|
45
|
+
// ctx.unsafeAppendEvent would bind to) has no relationship to the
|
|
46
|
+
// target user's session tenant(s). Anchored on SYSTEM_TENANT_ID
|
|
47
|
+
// instead, same as the jobs feature's cross-tenant run-lifecycle
|
|
48
|
+
// events (job-run-logger.ts) — a fresh aggregate per call, so no
|
|
49
|
+
// predecessor/version_conflict concerns.
|
|
50
|
+
//
|
|
51
|
+
// append() itself does NOT validate against the r.defineEvent-
|
|
52
|
+
// registered schema (that guarantee only comes from going through
|
|
53
|
+
// ctx.unsafeAppendEvent's dispatcher path). Mirrors job-run-logger.ts:
|
|
54
|
+
// parse explicitly before the raw append so a shape drift fails loudly
|
|
55
|
+
// here instead of landing unvalidated on the events table.
|
|
56
|
+
if (updated.length > 0) {
|
|
57
|
+
const payload = sessionRevokedSchema.parse({
|
|
58
|
+
userId: event.payload.userId,
|
|
59
|
+
sessionIds: updated.map((row) => row.id),
|
|
60
|
+
});
|
|
61
|
+
await append(ctx.db.raw, {
|
|
62
|
+
aggregateId: generateId(),
|
|
63
|
+
aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
|
|
64
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
65
|
+
expectedVersion: 0,
|
|
66
|
+
type: SESSION_REVOKED_EVENT_QN,
|
|
67
|
+
payload,
|
|
68
|
+
metadata: { userId: event.user.id },
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
33
72
|
return {
|
|
34
73
|
isSuccess: true as const,
|
|
35
74
|
data: { count: updated.length, userId: event.payload.userId },
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
5
|
import { Temporal } from "temporal-polyfill";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { SessionErrors } from "../constants";
|
|
7
8
|
import { userSessionTable } from "../schema/user-session";
|
|
9
|
+
import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN } from "../session-revoked-event";
|
|
8
10
|
|
|
9
11
|
// "Sign out everywhere else" — keep the caller's current session, kill all
|
|
10
12
|
// other live sessions for this user. Requires `user.sid` so "keep current"
|
|
@@ -25,13 +27,29 @@ export const revokeAllOthersWrite = defineWriteHandler({
|
|
|
25
27
|
);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
const updated = await updateMany(
|
|
30
|
+
const updated = await updateMany<{ id: string }>(
|
|
29
31
|
ctx.db,
|
|
30
32
|
userSessionTable,
|
|
31
33
|
{ revokedAt: Temporal.Now.instant() },
|
|
32
34
|
{ userId: event.user.id, revokedAt: null, id: { ne: keepSid } },
|
|
33
35
|
);
|
|
34
36
|
|
|
37
|
+
// Lightweight append alongside the direct-write above (#1559) — see
|
|
38
|
+
// revoke.write.ts for why this isn't a lifecycle event on
|
|
39
|
+
// store_user_sessions. Skipped when nothing was revoked (no other
|
|
40
|
+
// live sessions) — a spurious invalidation would be the failure mode.
|
|
41
|
+
if (updated.length > 0) {
|
|
42
|
+
await ctx.unsafeAppendEvent({
|
|
43
|
+
aggregateId: generateId(),
|
|
44
|
+
aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
|
|
45
|
+
type: SESSION_REVOKED_EVENT_QN,
|
|
46
|
+
payload: {
|
|
47
|
+
userId: event.user.id,
|
|
48
|
+
sessionIds: updated.map((row) => row.id),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
35
53
|
return { isSuccess: true, data: { count: updated.length } };
|
|
36
54
|
},
|
|
37
55
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { fetchOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
5
|
import { Temporal } from "temporal-polyfill";
|
|
5
6
|
import { z } from "zod";
|
|
6
7
|
import { SessionErrors } from "../constants";
|
|
7
8
|
import { userSessionTable } from "../schema/user-session";
|
|
9
|
+
import { SESSION_REVOKED_AGGREGATE_TYPE, SESSION_REVOKED_EVENT_QN } from "../session-revoked-event";
|
|
8
10
|
|
|
9
11
|
// Revoke a single session by id (= JWT jti). Three distinguishable outcomes:
|
|
10
12
|
//
|
|
@@ -36,6 +38,17 @@ export const revokeWrite = defineWriteHandler({
|
|
|
36
38
|
);
|
|
37
39
|
|
|
38
40
|
if (updated.length > 0) {
|
|
41
|
+
// Lightweight append alongside the direct-write above (#1559) — NOT a
|
|
42
|
+
// lifecycle event for store_user_sessions (that table stays an
|
|
43
|
+
// unmanaged direct-write store, see feature.ts). Own aggregate id per
|
|
44
|
+
// revoke, no predecessor to satisfy, no version_conflict against a
|
|
45
|
+
// concurrent revoke on a different sid.
|
|
46
|
+
await ctx.unsafeAppendEvent({
|
|
47
|
+
aggregateId: generateId(),
|
|
48
|
+
aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
|
|
49
|
+
type: SESSION_REVOKED_EVENT_QN,
|
|
50
|
+
payload: { userId: event.user.id, sessionIds: [event.payload.id] },
|
|
51
|
+
});
|
|
39
52
|
return { isSuccess: true, data: { id: event.payload.id } };
|
|
40
53
|
}
|
|
41
54
|
|
package/src/sessions/index.ts
CHANGED
|
@@ -15,3 +15,10 @@ export type {
|
|
|
15
15
|
SessionMassRevoker,
|
|
16
16
|
} from "./session-callbacks";
|
|
17
17
|
export { createSessionCallbacks } from "./session-callbacks";
|
|
18
|
+
export type { SessionRevokedPayload } from "./session-revoked-event";
|
|
19
|
+
export {
|
|
20
|
+
SESSION_REVOKED_AGGREGATE_TYPE,
|
|
21
|
+
SESSION_REVOKED_EVENT_QN,
|
|
22
|
+
SESSION_REVOKED_EVENT_SHORT,
|
|
23
|
+
sessionRevokedSchema,
|
|
24
|
+
} from "./session-revoked-event";
|
|
@@ -2,18 +2,29 @@ import type {
|
|
|
2
2
|
AuthSessionStatus,
|
|
3
3
|
SessionChecker,
|
|
4
4
|
SessionCreator,
|
|
5
|
+
SessionMassRevoker,
|
|
5
6
|
SessionMetadata,
|
|
6
7
|
SessionRevoker,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
9
|
+
|
|
10
|
+
export type { SessionMassRevoker } from "@cosmicdrift/kumiko-framework/api";
|
|
11
|
+
|
|
8
12
|
import { fetchOne, insertOne, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
13
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
10
14
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
17
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
12
18
|
import { Temporal } from "temporal-polyfill";
|
|
13
19
|
import { encryptForDirectWrite } from "../shared";
|
|
14
20
|
import { USER_STATUS, type UserStatus, userTable } from "../user";
|
|
15
21
|
import { DEFAULT_SESSION_EXPIRY_MS } from "./constants";
|
|
16
22
|
import { userSessionEntity, userSessionTable } from "./schema/user-session";
|
|
23
|
+
import {
|
|
24
|
+
SESSION_REVOKED_AGGREGATE_TYPE,
|
|
25
|
+
SESSION_REVOKED_EVENT_QN,
|
|
26
|
+
sessionRevokedSchema,
|
|
27
|
+
} from "./session-revoked-event";
|
|
17
28
|
|
|
18
29
|
// Locked accounts whose live sessions must be refused. deletionRequested is
|
|
19
30
|
// intentionally absent — it's a reversible grace period and the user needs
|
|
@@ -28,11 +39,6 @@ const BLOCKED_STATUSES: ReadonlySet<UserStatus> = new Set([
|
|
|
28
39
|
// login and every request. The (createdAt/revokedAt/ip/userAgent) columns
|
|
29
40
|
// already are the audit trail — a dispatcher roundtrip buys nothing.
|
|
30
41
|
|
|
31
|
-
// Mass-revoke for a single user. Used by the password-change hook and
|
|
32
|
-
// "sign out everywhere" flows. Returns the count of rows flipped so a
|
|
33
|
-
// caller can log "revoked N other sessions".
|
|
34
|
-
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
35
|
-
|
|
36
42
|
// "Sign out everywhere else, keep this one" — used both by the user-facing
|
|
37
43
|
// revoke-all-others handler and by other features (auth-mfa) that need the
|
|
38
44
|
// same effect from a raw callback instead of a dispatcher round-trip.
|
|
@@ -144,6 +150,31 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
144
150
|
{ revokedAt: Temporal.Now.instant() },
|
|
145
151
|
{ userId, revokedAt: null },
|
|
146
152
|
);
|
|
153
|
+
|
|
154
|
+
// Lightweight append alongside the direct-write above, same pattern
|
|
155
|
+
// as revoke.write.ts (#1559) — this callback is the password-change
|
|
156
|
+
// auto-revoke path (sessions/feature.ts postSave hook) and has no
|
|
157
|
+
// dispatcher ctx to call unsafeAppendEvent from, so it uses the raw
|
|
158
|
+
// append() like revoke-all-for-user.write.ts does. Without this, the
|
|
159
|
+
// access-invalidation consumer (#1560) never hears about a password
|
|
160
|
+
// change and an already-open SSE stream survives it — the exact
|
|
161
|
+
// "stale JWT mid-stream" gap this feature exists to close.
|
|
162
|
+
if (result.length > 0) {
|
|
163
|
+
const payload = sessionRevokedSchema.parse({
|
|
164
|
+
userId,
|
|
165
|
+
sessionIds: result.map((row: { id: string }) => row.id),
|
|
166
|
+
});
|
|
167
|
+
await append(db, {
|
|
168
|
+
aggregateId: generateId(),
|
|
169
|
+
aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
|
|
170
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
171
|
+
expectedVersion: 0,
|
|
172
|
+
type: SESSION_REVOKED_EVENT_QN,
|
|
173
|
+
payload,
|
|
174
|
+
metadata: { userId },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
147
178
|
return result.length;
|
|
148
179
|
},
|
|
149
180
|
|
|
@@ -156,6 +187,28 @@ export function createSessionCallbacks(opts: SessionCallbacksOptions): SessionCa
|
|
|
156
187
|
? { userId, revokedAt: null, id: { ne: currentSid } }
|
|
157
188
|
: { userId, revokedAt: null },
|
|
158
189
|
);
|
|
190
|
+
|
|
191
|
+
// Same reasoning as sessionMassRevoker above — this raw callback
|
|
192
|
+
// (used by auth-mfa and other internal callers, distinct from the
|
|
193
|
+
// user-facing revoke-all-others.write.ts handler which already
|
|
194
|
+
// appends this event) needs its own append so callers reached
|
|
195
|
+
// through here also cut open SSE streams.
|
|
196
|
+
if (result.length > 0) {
|
|
197
|
+
const payload = sessionRevokedSchema.parse({
|
|
198
|
+
userId,
|
|
199
|
+
sessionIds: result.map((row: { id: string }) => row.id),
|
|
200
|
+
});
|
|
201
|
+
await append(db, {
|
|
202
|
+
aggregateId: generateId(),
|
|
203
|
+
aggregateType: SESSION_REVOKED_AGGREGATE_TYPE,
|
|
204
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
205
|
+
expectedVersion: 0,
|
|
206
|
+
type: SESSION_REVOKED_EVENT_QN,
|
|
207
|
+
payload,
|
|
208
|
+
metadata: { userId },
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
159
212
|
return result.length;
|
|
160
213
|
},
|
|
161
214
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// Lightweight custom event appended alongside the direct-write
|
|
4
|
+
// updateMany() in revoke.write.ts / revoke-all-others.write.ts /
|
|
5
|
+
// revoke-all-for-user.write.ts (#1559). store_user_sessions itself stays
|
|
6
|
+
// an unmanaged direct-write store (see feature.ts) — the row columns are
|
|
7
|
+
// the audit trail, this event is NOT a CRUD/lifecycle event for that
|
|
8
|
+
// table and carries no projection. Its only job is to give cross-instance
|
|
9
|
+
// consumers (access-invalidation, #1560) something to LISTEN for via the
|
|
10
|
+
// existing event-store NOTIFY, keyed by the affected userId.
|
|
11
|
+
//
|
|
12
|
+
// _SHORT/_QN pair mirrors cap-counter's rolling-incremented event:
|
|
13
|
+
// _SHORT — r.defineEvent(short, schema) in feature.ts, framework prefixes
|
|
14
|
+
// it to the QN below.
|
|
15
|
+
// _QN — ctx.unsafeAppendEvent({ type }) / raw append()'s `type` field.
|
|
16
|
+
export const SESSION_REVOKED_EVENT_SHORT = "session-revoked" as const;
|
|
17
|
+
export const SESSION_REVOKED_EVENT_QN = "sessions:event:session-revoked" as const;
|
|
18
|
+
|
|
19
|
+
// Own aggregate type, decoupled from userSessionEntity's implicit
|
|
20
|
+
// "user-session" — each revoke action mints a fresh aggregate id (no
|
|
21
|
+
// predecessor to satisfy, no version_conflict against concurrent revokes).
|
|
22
|
+
export const SESSION_REVOKED_AGGREGATE_TYPE = "user-session-revocation" as const;
|
|
23
|
+
|
|
24
|
+
export const sessionRevokedSchema = z.object({
|
|
25
|
+
userId: z.string().min(1),
|
|
26
|
+
sessionIds: z.array(z.uuid()).min(1),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type SessionRevokedPayload = z.infer<typeof sessionRevokedSchema>;
|
|
@@ -98,10 +98,10 @@ describe("runChunkedMigration", () => {
|
|
|
98
98
|
deadlineAt: Number.POSITIVE_INFINITY,
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
// Batch 1 processes "a" and pushes failed to 1; the breaker is
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
expect(result.batchesProcessed).toBe(
|
|
101
|
+
// Batch 1 processes "a" and pushes failed to 1; the breaker is checked
|
|
102
|
+
// before batchesProcessed is incremented for the next fetched batch, so
|
|
103
|
+
// "b" is fetched but never touched and doesn't count as processed.
|
|
104
|
+
expect(result.batchesProcessed).toBe(1);
|
|
105
105
|
expect(result.stoppedReason).toBe("too_many_failures");
|
|
106
106
|
});
|
|
107
107
|
|
|
@@ -59,13 +59,13 @@ export async function runChunkedMigration<Row>(
|
|
|
59
59
|
const batch = await opts.nextBatch();
|
|
60
60
|
if (batch.length === 0) break;
|
|
61
61
|
|
|
62
|
-
batchesProcessed++;
|
|
63
|
-
|
|
64
62
|
if (failed >= opts.maxFailures) {
|
|
65
63
|
stoppedReason = "too_many_failures";
|
|
66
64
|
break;
|
|
67
65
|
}
|
|
68
66
|
|
|
67
|
+
batchesProcessed++;
|
|
68
|
+
|
|
69
69
|
for (const row of batch) {
|
|
70
70
|
if (failed >= opts.maxFailures) {
|
|
71
71
|
stoppedReason = "too_many_failures";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { withoutAmbientTemporal } from "@cosmicdrift/kumiko-framework/testing";
|
|
3
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
4
|
+
import { isWithinGracePeriod } from "./grace-period";
|
|
5
|
+
|
|
6
|
+
describe("isWithinGracePeriod — kumiko-framework#1525/#1550", () => {
|
|
7
|
+
test("null gracePeriodEnd → false without ambient Temporal", async () => {
|
|
8
|
+
await withoutAmbientTemporal(() => {
|
|
9
|
+
expect(isWithinGracePeriod(null)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("future vs past without ambient Temporal", async () => {
|
|
14
|
+
const future = TemporalPolyfill.Now.instant().add({
|
|
15
|
+
hours: 1,
|
|
16
|
+
}) as unknown as Temporal.Instant;
|
|
17
|
+
const past = TemporalPolyfill.Now.instant().subtract({
|
|
18
|
+
hours: 1,
|
|
19
|
+
}) as unknown as Temporal.Instant;
|
|
20
|
+
|
|
21
|
+
await withoutAmbientTemporal(() => {
|
|
22
|
+
expect(isWithinGracePeriod(future)).toBe(true);
|
|
23
|
+
expect(isWithinGracePeriod(past)).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Shared grace-period check for user-data-rights + tenant-lifecycle cancel flows. */
|
|
2
|
+
|
|
3
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* True when `gracePeriodEnd` is still in the future.
|
|
7
|
+
* Uses the polyfill Instant API so callers don't depend on `globalThis.Temporal`.
|
|
8
|
+
*/
|
|
9
|
+
export function isWithinGracePeriod(gracePeriodEnd: Temporal.Instant | null): boolean {
|
|
10
|
+
// @cast-boundary temporal-polyfill-vs-ambient: same TC39 Temporal.Instant
|
|
11
|
+
// at runtime — row types resolve against ambient Temporal; polyfill Instant
|
|
12
|
+
// is a separate nominal type across the two .d.ts sources.
|
|
13
|
+
return (
|
|
14
|
+
gracePeriodEnd != null &&
|
|
15
|
+
TemporalPolyfill.Instant.compare(
|
|
16
|
+
gracePeriodEnd as unknown as InstanceType<typeof TemporalPolyfill.Instant>,
|
|
17
|
+
TemporalPolyfill.Now.instant(),
|
|
18
|
+
) > 0
|
|
19
|
+
);
|
|
20
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -8,7 +8,9 @@ export {
|
|
|
8
8
|
export { decryptStoredPii } from "./decrypt-stored-pii";
|
|
9
9
|
export { encryptForDirectWrite } from "./encrypt-for-direct-write";
|
|
10
10
|
export { entitiesOf } from "./entities-of";
|
|
11
|
+
export { isWithinGracePeriod } from "./grace-period";
|
|
11
12
|
export { isIdentityV3Hash, verifyIdentityV3Hash } from "./identity-v3-hash";
|
|
13
|
+
export { mapWithConcurrency } from "./map-with-concurrency";
|
|
12
14
|
export { hashPassword, verifyDummyPassword, verifyPassword } from "./password-hashing";
|
|
13
15
|
export type { SystemQueryFn } from "./system-query";
|
|
14
16
|
export { type BurnResult, burnToken, unburnToken } from "./token-burn-store";
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mapWithConcurrency } from "./map-with-concurrency";
|
|
3
|
+
|
|
4
|
+
describe("mapWithConcurrency", () => {
|
|
5
|
+
test("preserves result order regardless of completion order", async () => {
|
|
6
|
+
const delays = [30, 10, 20, 0];
|
|
7
|
+
const results = await mapWithConcurrency(delays, 4, async (delay, index) => {
|
|
8
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
9
|
+
return index;
|
|
10
|
+
});
|
|
11
|
+
expect(results).toEqual([0, 1, 2, 3]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("never runs more than `limit` calls concurrently", async () => {
|
|
15
|
+
let active = 0;
|
|
16
|
+
let maxActive = 0;
|
|
17
|
+
const items = Array.from({ length: 12 }, (_, i) => i);
|
|
18
|
+
await mapWithConcurrency(items, 4, async (item) => {
|
|
19
|
+
active++;
|
|
20
|
+
maxActive = Math.max(maxActive, active);
|
|
21
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
22
|
+
active--;
|
|
23
|
+
return item;
|
|
24
|
+
});
|
|
25
|
+
expect(maxActive).toBeLessThanOrEqual(4);
|
|
26
|
+
expect(maxActive).toBeGreaterThan(1);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("propagates the first rejection", async () => {
|
|
30
|
+
const items = [1, 2, 3];
|
|
31
|
+
await expect(
|
|
32
|
+
mapWithConcurrency(items, 4, async (item) => {
|
|
33
|
+
if (item === 2) throw new Error("boom");
|
|
34
|
+
return item;
|
|
35
|
+
}),
|
|
36
|
+
).rejects.toThrow("boom");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Two workers rejecting at different times, under 4-way concurrency —
|
|
40
|
+
// regression guard for the #1398 hazard (an eagerly-fired, never-awaited
|
|
41
|
+
// Promise surfacing as an unattributed unhandled rejection). Every
|
|
42
|
+
// worker promise here is an element of the internal `Promise.all` array,
|
|
43
|
+
// so both rejections are always handled even though the aggregate
|
|
44
|
+
// `mapWithConcurrency` call already settled on the first one.
|
|
45
|
+
test("a second rejection after the first doesn't surface as an unhandled rejection", async () => {
|
|
46
|
+
const unhandled: unknown[] = [];
|
|
47
|
+
const onUnhandledRejection = (reason: unknown): void => {
|
|
48
|
+
unhandled.push(reason);
|
|
49
|
+
};
|
|
50
|
+
process.on("unhandledRejection", onUnhandledRejection);
|
|
51
|
+
try {
|
|
52
|
+
const items = [1, 2, 3, 4, 5, 6, 7, 8];
|
|
53
|
+
await expect(
|
|
54
|
+
mapWithConcurrency(items, 4, async (item) => {
|
|
55
|
+
if (item === 2) {
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
57
|
+
throw new Error("boom-early");
|
|
58
|
+
}
|
|
59
|
+
if (item === 5) {
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
61
|
+
throw new Error("boom-late");
|
|
62
|
+
}
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
64
|
+
return item;
|
|
65
|
+
}),
|
|
66
|
+
).rejects.toThrow("boom-early");
|
|
67
|
+
// Give the later-rejecting worker's promise a chance to settle and
|
|
68
|
+
// for a real unhandled rejection (if any) to fire.
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
70
|
+
} finally {
|
|
71
|
+
process.off("unhandledRejection", onUnhandledRejection);
|
|
72
|
+
}
|
|
73
|
+
expect(unhandled).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("does not claim new items after the first rejection", async () => {
|
|
77
|
+
const started: number[] = [];
|
|
78
|
+
const items = Array.from({ length: 20 }, (_, i) => i);
|
|
79
|
+
await expect(
|
|
80
|
+
mapWithConcurrency(items, 2, async (item) => {
|
|
81
|
+
started.push(item);
|
|
82
|
+
if (item === 0) throw new Error("boom");
|
|
83
|
+
// Slow enough that without fail-fast, more workers would drain the list.
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
85
|
+
return item;
|
|
86
|
+
}),
|
|
87
|
+
).rejects.toThrow("boom");
|
|
88
|
+
// At most `limit` in-flight when the first throw sets `failed`; without
|
|
89
|
+
// the flag the other worker would keep claiming the rest of the 20.
|
|
90
|
+
expect(started.length).toBeLessThan(items.length);
|
|
91
|
+
expect(started.length).toBeLessThanOrEqual(3);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("empty input resolves to an empty array without running the worker", async () => {
|
|
95
|
+
const results = await mapWithConcurrency<number, number>([], 4, async (item) => item);
|
|
96
|
+
expect(results).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("limit larger than item count doesn't over-spawn workers", async () => {
|
|
100
|
+
const items = [1, 2];
|
|
101
|
+
const results = await mapWithConcurrency(items, 10, async (item) => item * 2);
|
|
102
|
+
expect(results).toEqual([2, 4]);
|
|
103
|
+
});
|
|
104
|
+
});
|