@digimuza/saas-core 0.0.2
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/CLAUDE.md +1165 -0
- package/LICENSE +21 -0
- package/README.md +234 -0
- package/dist/api-keys/index.d.ts +9 -0
- package/dist/api-keys/index.d.ts.map +1 -0
- package/dist/api-keys/index.js +37 -0
- package/dist/api-keys/index.js.map +1 -0
- package/dist/api-keys/keys.d.ts +55 -0
- package/dist/api-keys/keys.d.ts.map +1 -0
- package/dist/api-keys/keys.js +92 -0
- package/dist/api-keys/keys.js.map +1 -0
- package/dist/api-keys/service.d.ts +111 -0
- package/dist/api-keys/service.d.ts.map +1 -0
- package/dist/api-keys/service.js +210 -0
- package/dist/api-keys/service.js.map +1 -0
- package/dist/audit/index.d.ts +5 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +44 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/audit/service.d.ts +88 -0
- package/dist/audit/service.d.ts.map +1 -0
- package/dist/audit/service.js +128 -0
- package/dist/audit/service.js.map +1 -0
- package/dist/auth/client.d.ts +5 -0
- package/dist/auth/client.d.ts.map +1 -0
- package/dist/auth/client.js +26 -0
- package/dist/auth/client.js.map +1 -0
- package/dist/auth/cookies.d.ts +51 -0
- package/dist/auth/cookies.d.ts.map +1 -0
- package/dist/auth/cookies.js +58 -0
- package/dist/auth/cookies.js.map +1 -0
- package/dist/auth/index.d.ts +14 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +46 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/service.d.ts +67 -0
- package/dist/auth/service.d.ts.map +1 -0
- package/dist/auth/service.js +275 -0
- package/dist/auth/service.js.map +1 -0
- package/dist/auth/types.d.ts +56 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +2 -0
- package/dist/auth/types.js.map +1 -0
- package/dist/authorization/index.d.ts +4 -0
- package/dist/authorization/index.d.ts.map +1 -0
- package/dist/authorization/index.js +24 -0
- package/dist/authorization/index.js.map +1 -0
- package/dist/authorization/service.d.ts +92 -0
- package/dist/authorization/service.d.ts.map +1 -0
- package/dist/authorization/service.js +254 -0
- package/dist/authorization/service.js.map +1 -0
- package/dist/billing/index.d.ts +16 -0
- package/dist/billing/index.d.ts.map +1 -0
- package/dist/billing/index.js +47 -0
- package/dist/billing/index.js.map +1 -0
- package/dist/billing/provider.d.ts +114 -0
- package/dist/billing/provider.d.ts.map +1 -0
- package/dist/billing/provider.js +2 -0
- package/dist/billing/provider.js.map +1 -0
- package/dist/billing/revolut.d.ts +68 -0
- package/dist/billing/revolut.d.ts.map +1 -0
- package/dist/billing/revolut.js +181 -0
- package/dist/billing/revolut.js.map +1 -0
- package/dist/billing/service.d.ts +124 -0
- package/dist/billing/service.d.ts.map +1 -0
- package/dist/billing/service.js +470 -0
- package/dist/billing/service.js.map +1 -0
- package/dist/cli/commands/dev.d.ts +43 -0
- package/dist/cli/commands/dev.d.ts.map +1 -0
- package/dist/cli/commands/dev.js +212 -0
- package/dist/cli/commands/dev.js.map +1 -0
- package/dist/cli/commands/project.d.ts +12 -0
- package/dist/cli/commands/project.d.ts.map +1 -0
- package/dist/cli/commands/project.js +49 -0
- package/dist/cli/commands/project.js.map +1 -0
- package/dist/cli/docker.d.ts +50 -0
- package/dist/cli/docker.d.ts.map +1 -0
- package/dist/cli/docker.js +84 -0
- package/dist/cli/docker.js.map +1 -0
- package/dist/cli/index.d.ts +6 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +198 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/output.d.ts +30 -0
- package/dist/cli/output.d.ts.map +1 -0
- package/dist/cli/output.js +66 -0
- package/dist/cli/output.js.map +1 -0
- package/dist/cli/templates.d.ts +52 -0
- package/dist/cli/templates.d.ts.map +1 -0
- package/dist/cli/templates.js +225 -0
- package/dist/cli/templates.js.map +1 -0
- package/dist/config/client.d.ts +60 -0
- package/dist/config/client.d.ts.map +1 -0
- package/dist/config/client.js +94 -0
- package/dist/config/client.js.map +1 -0
- package/dist/config/config-loader.d.ts +2 -0
- package/dist/config/config-loader.d.ts.map +1 -0
- package/dist/config/config-loader.js +2 -0
- package/dist/config/config-loader.js.map +1 -0
- package/dist/config/env.d.ts +24 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +40 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/errors.d.ts +17 -0
- package/dist/config/errors.d.ts.map +1 -0
- package/dist/config/errors.js +26 -0
- package/dist/config/errors.js.map +1 -0
- package/dist/config/index.d.ts +72 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +106 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/runtime.d.ts +15 -0
- package/dist/config/runtime.d.ts.map +1 -0
- package/dist/config/runtime.js +22 -0
- package/dist/config/runtime.js.map +1 -0
- package/dist/config/sections.d.ts +226 -0
- package/dist/config/sections.d.ts.map +1 -0
- package/dist/config/sections.js +141 -0
- package/dist/config/sections.js.map +1 -0
- package/dist/db/client.d.ts +43 -0
- package/dist/db/client.d.ts.map +1 -0
- package/dist/db/client.js +77 -0
- package/dist/db/client.js.map +1 -0
- package/dist/db/errors.d.ts +39 -0
- package/dist/db/errors.d.ts.map +1 -0
- package/dist/db/errors.js +67 -0
- package/dist/db/errors.js.map +1 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +10 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +21 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +28 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema/_shared.d.ts +30 -0
- package/dist/db/schema/_shared.d.ts.map +1 -0
- package/dist/db/schema/_shared.js +36 -0
- package/dist/db/schema/_shared.js.map +1 -0
- package/dist/db/schema/apikeys.d.ts +263 -0
- package/dist/db/schema/apikeys.d.ts.map +1 -0
- package/dist/db/schema/apikeys.js +70 -0
- package/dist/db/schema/apikeys.js.map +1 -0
- package/dist/db/schema/authorization.d.ts +465 -0
- package/dist/db/schema/authorization.d.ts.map +1 -0
- package/dist/db/schema/authorization.js +134 -0
- package/dist/db/schema/authorization.js.map +1 -0
- package/dist/db/schema/billing.d.ts +1100 -0
- package/dist/db/schema/billing.d.ts.map +1 -0
- package/dist/db/schema/billing.js +223 -0
- package/dist/db/schema/billing.js.map +1 -0
- package/dist/db/schema/events.d.ts +161 -0
- package/dist/db/schema/events.d.ts.map +1 -0
- package/dist/db/schema/events.js +66 -0
- package/dist/db/schema/events.js.map +1 -0
- package/dist/db/schema/features.d.ts +243 -0
- package/dist/db/schema/features.d.ts.map +1 -0
- package/dist/db/schema/features.js +87 -0
- package/dist/db/schema/features.js.map +1 -0
- package/dist/db/schema/files.d.ts +253 -0
- package/dist/db/schema/files.d.ts.map +1 -0
- package/dist/db/schema/files.js +74 -0
- package/dist/db/schema/files.js.map +1 -0
- package/dist/db/schema/index.d.ts +38 -0
- package/dist/db/schema/index.d.ts.map +1 -0
- package/dist/db/schema/index.js +38 -0
- package/dist/db/schema/index.js.map +1 -0
- package/dist/db/schema/invitations.d.ts +214 -0
- package/dist/db/schema/invitations.d.ts.map +1 -0
- package/dist/db/schema/invitations.js +80 -0
- package/dist/db/schema/invitations.js.map +1 -0
- package/dist/db/schema/messaging.d.ts +442 -0
- package/dist/db/schema/messaging.d.ts.map +1 -0
- package/dist/db/schema/messaging.js +120 -0
- package/dist/db/schema/messaging.js.map +1 -0
- package/dist/db/schema/users.d.ts +246 -0
- package/dist/db/schema/users.d.ts.map +1 -0
- package/dist/db/schema/users.js +60 -0
- package/dist/db/schema/users.js.map +1 -0
- package/dist/db/schema/webhooks.d.ts +693 -0
- package/dist/db/schema/webhooks.d.ts.map +1 -0
- package/dist/db/schema/webhooks.js +147 -0
- package/dist/db/schema/webhooks.js.map +1 -0
- package/dist/db/schema/workspaces.d.ts +196 -0
- package/dist/db/schema/workspaces.d.ts.map +1 -0
- package/dist/db/schema/workspaces.js +57 -0
- package/dist/db/schema/workspaces.js.map +1 -0
- package/dist/db/types.d.ts +18 -0
- package/dist/db/types.d.ts.map +1 -0
- package/dist/db/types.js +2 -0
- package/dist/db/types.js.map +1 -0
- package/dist/email/adapters.d.ts +29 -0
- package/dist/email/adapters.d.ts.map +1 -0
- package/dist/email/adapters.js +60 -0
- package/dist/email/adapters.js.map +1 -0
- package/dist/email/index.d.ts +20 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +79 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/provider.d.ts +47 -0
- package/dist/email/provider.d.ts.map +1 -0
- package/dist/email/provider.js +7 -0
- package/dist/email/provider.js.map +1 -0
- package/dist/email/resend.d.ts +22 -0
- package/dist/email/resend.d.ts.map +1 -0
- package/dist/email/resend.js +51 -0
- package/dist/email/resend.js.map +1 -0
- package/dist/email/service.d.ts +55 -0
- package/dist/email/service.d.ts.map +1 -0
- package/dist/email/service.js +87 -0
- package/dist/email/service.js.map +1 -0
- package/dist/email/templates.d.ts +81 -0
- package/dist/email/templates.d.ts.map +1 -0
- package/dist/email/templates.js +84 -0
- package/dist/email/templates.js.map +1 -0
- package/dist/entitlements/index.d.ts +10 -0
- package/dist/entitlements/index.d.ts.map +1 -0
- package/dist/entitlements/index.js +28 -0
- package/dist/entitlements/index.js.map +1 -0
- package/dist/entitlements/service.d.ts +83 -0
- package/dist/entitlements/service.d.ts.map +1 -0
- package/dist/entitlements/service.js +158 -0
- package/dist/entitlements/service.js.map +1 -0
- package/dist/errors.d.ts +85 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +95 -0
- package/dist/errors.js.map +1 -0
- package/dist/events/core-events.d.ts +120 -0
- package/dist/events/core-events.d.ts.map +1 -0
- package/dist/events/core-events.js +109 -0
- package/dist/events/core-events.js.map +1 -0
- package/dist/events/index.d.ts +15 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +58 -0
- package/dist/events/index.js.map +1 -0
- package/dist/events/publisher.d.ts +46 -0
- package/dist/events/publisher.d.ts.map +1 -0
- package/dist/events/publisher.js +54 -0
- package/dist/events/publisher.js.map +1 -0
- package/dist/events/registry.d.ts +87 -0
- package/dist/events/registry.d.ts.map +1 -0
- package/dist/events/registry.js +34 -0
- package/dist/events/registry.js.map +1 -0
- package/dist/events/service.d.ts +78 -0
- package/dist/events/service.d.ts.map +1 -0
- package/dist/events/service.js +182 -0
- package/dist/events/service.js.map +1 -0
- package/dist/features/bucket.d.ts +22 -0
- package/dist/features/bucket.d.ts.map +1 -0
- package/dist/features/bucket.js +42 -0
- package/dist/features/bucket.js.map +1 -0
- package/dist/features/index.d.ts +15 -0
- package/dist/features/index.d.ts.map +1 -0
- package/dist/features/index.js +78 -0
- package/dist/features/index.js.map +1 -0
- package/dist/features/registry.d.ts +51 -0
- package/dist/features/registry.d.ts.map +1 -0
- package/dist/features/registry.js +52 -0
- package/dist/features/registry.js.map +1 -0
- package/dist/features/service.d.ts +78 -0
- package/dist/features/service.d.ts.map +1 -0
- package/dist/features/service.js +207 -0
- package/dist/features/service.js.map +1 -0
- package/dist/files/index.d.ts +6 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +64 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/service.d.ts +92 -0
- package/dist/files/service.d.ts.map +1 -0
- package/dist/files/service.js +303 -0
- package/dist/files/service.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs/backend.d.ts +23 -0
- package/dist/jobs/backend.d.ts.map +1 -0
- package/dist/jobs/backend.js +23 -0
- package/dist/jobs/backend.js.map +1 -0
- package/dist/jobs/index.d.ts +29 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +114 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/jobs/service.d.ts +67 -0
- package/dist/jobs/service.d.ts.map +1 -0
- package/dist/jobs/service.js +139 -0
- package/dist/jobs/service.js.map +1 -0
- package/dist/jobs/types.d.ts +78 -0
- package/dist/jobs/types.d.ts.map +1 -0
- package/dist/jobs/types.js +4 -0
- package/dist/jobs/types.js.map +1 -0
- package/dist/logger/default.d.ts +7 -0
- package/dist/logger/default.d.ts.map +1 -0
- package/dist/logger/default.js +28 -0
- package/dist/logger/default.js.map +1 -0
- package/dist/logger/index.d.ts +24 -0
- package/dist/logger/index.d.ts.map +1 -0
- package/dist/logger/index.js +90 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/logger/redact.d.ts +40 -0
- package/dist/logger/redact.d.ts.map +1 -0
- package/dist/logger/redact.js +178 -0
- package/dist/logger/redact.js.map +1 -0
- package/dist/logger/service.d.ts +14 -0
- package/dist/logger/service.d.ts.map +1 -0
- package/dist/logger/service.js +56 -0
- package/dist/logger/service.js.map +1 -0
- package/dist/logger/sinks.d.ts +37 -0
- package/dist/logger/sinks.d.ts.map +1 -0
- package/dist/logger/sinks.js +135 -0
- package/dist/logger/sinks.js.map +1 -0
- package/dist/logger/types.d.ts +47 -0
- package/dist/logger/types.d.ts.map +1 -0
- package/dist/logger/types.js +8 -0
- package/dist/logger/types.js.map +1 -0
- package/dist/notifications/index.d.ts +8 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +72 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/notifications/service.d.ts +79 -0
- package/dist/notifications/service.d.ts.map +1 -0
- package/dist/notifications/service.js +164 -0
- package/dist/notifications/service.js.map +1 -0
- package/dist/realtime/channels.d.ts +37 -0
- package/dist/realtime/channels.d.ts.map +1 -0
- package/dist/realtime/channels.js +58 -0
- package/dist/realtime/channels.js.map +1 -0
- package/dist/realtime/index.d.ts +19 -0
- package/dist/realtime/index.d.ts.map +1 -0
- package/dist/realtime/index.js +118 -0
- package/dist/realtime/index.js.map +1 -0
- package/dist/realtime/postgres.d.ts +48 -0
- package/dist/realtime/postgres.d.ts.map +1 -0
- package/dist/realtime/postgres.js +151 -0
- package/dist/realtime/postgres.js.map +1 -0
- package/dist/realtime/service.d.ts +75 -0
- package/dist/realtime/service.d.ts.map +1 -0
- package/dist/realtime/service.js +103 -0
- package/dist/realtime/service.js.map +1 -0
- package/dist/realtime/sse.d.ts +55 -0
- package/dist/realtime/sse.d.ts.map +1 -0
- package/dist/realtime/sse.js +123 -0
- package/dist/realtime/sse.js.map +1 -0
- package/dist/realtime/transport.d.ts +52 -0
- package/dist/realtime/transport.d.ts.map +1 -0
- package/dist/realtime/transport.js +2 -0
- package/dist/realtime/transport.js.map +1 -0
- package/dist/storage/index.d.ts +18 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +89 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/keys.d.ts +50 -0
- package/dist/storage/keys.d.ts.map +1 -0
- package/dist/storage/keys.js +120 -0
- package/dist/storage/keys.js.map +1 -0
- package/dist/storage/memory.d.ts +32 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +84 -0
- package/dist/storage/memory.js.map +1 -0
- package/dist/storage/provider.d.ts +93 -0
- package/dist/storage/provider.d.ts.map +1 -0
- package/dist/storage/provider.js +2 -0
- package/dist/storage/provider.js.map +1 -0
- package/dist/storage/s3.d.ts +34 -0
- package/dist/storage/s3.d.ts.map +1 -0
- package/dist/storage/s3.js +175 -0
- package/dist/storage/s3.js.map +1 -0
- package/dist/storage/service.d.ts +86 -0
- package/dist/storage/service.d.ts.map +1 -0
- package/dist/storage/service.js +107 -0
- package/dist/storage/service.js.map +1 -0
- package/dist/storage/sigv4.d.ts +58 -0
- package/dist/storage/sigv4.d.ts.map +1 -0
- package/dist/storage/sigv4.js +114 -0
- package/dist/storage/sigv4.js.map +1 -0
- package/dist/streams/client.d.ts +91 -0
- package/dist/streams/client.d.ts.map +1 -0
- package/dist/streams/client.js +128 -0
- package/dist/streams/client.js.map +1 -0
- package/dist/streams/events.d.ts +68 -0
- package/dist/streams/events.d.ts.map +1 -0
- package/dist/streams/events.js +81 -0
- package/dist/streams/events.js.map +1 -0
- package/dist/streams/index.d.ts +47 -0
- package/dist/streams/index.d.ts.map +1 -0
- package/dist/streams/index.js +47 -0
- package/dist/streams/index.js.map +1 -0
- package/dist/streams/realtime.d.ts +27 -0
- package/dist/streams/realtime.d.ts.map +1 -0
- package/dist/streams/realtime.js +50 -0
- package/dist/streams/realtime.js.map +1 -0
- package/dist/streams/state.d.ts +26 -0
- package/dist/streams/state.d.ts.map +1 -0
- package/dist/streams/state.js +34 -0
- package/dist/streams/state.js.map +1 -0
- package/dist/users/index.d.ts +9 -0
- package/dist/users/index.d.ts.map +1 -0
- package/dist/users/index.js +23 -0
- package/dist/users/index.js.map +1 -0
- package/dist/users/service.d.ts +43 -0
- package/dist/users/service.d.ts.map +1 -0
- package/dist/users/service.js +113 -0
- package/dist/users/service.js.map +1 -0
- package/dist/webhooks/inbound.d.ts +70 -0
- package/dist/webhooks/inbound.d.ts.map +1 -0
- package/dist/webhooks/inbound.js +146 -0
- package/dist/webhooks/inbound.js.map +1 -0
- package/dist/webhooks/index.d.ts +12 -0
- package/dist/webhooks/index.d.ts.map +1 -0
- package/dist/webhooks/index.js +65 -0
- package/dist/webhooks/index.js.map +1 -0
- package/dist/webhooks/outgoing.d.ts +89 -0
- package/dist/webhooks/outgoing.d.ts.map +1 -0
- package/dist/webhooks/outgoing.js +375 -0
- package/dist/webhooks/outgoing.js.map +1 -0
- package/dist/webhooks/signature.d.ts +40 -0
- package/dist/webhooks/signature.d.ts.map +1 -0
- package/dist/webhooks/signature.js +77 -0
- package/dist/webhooks/signature.js.map +1 -0
- package/dist/workspaces/context.d.ts +30 -0
- package/dist/workspaces/context.d.ts.map +1 -0
- package/dist/workspaces/context.js +12 -0
- package/dist/workspaces/context.js.map +1 -0
- package/dist/workspaces/index.d.ts +5 -0
- package/dist/workspaces/index.d.ts.map +1 -0
- package/dist/workspaces/index.js +21 -0
- package/dist/workspaces/index.js.map +1 -0
- package/dist/workspaces/service.d.ts +126 -0
- package/dist/workspaces/service.d.ts.map +1 -0
- package/dist/workspaces/service.js +495 -0
- package/dist/workspaces/service.js.map +1 -0
- package/docs/README.md +71 -0
- package/docs/api-keys.md +184 -0
- package/docs/audit.md +198 -0
- package/docs/auth.md +196 -0
- package/docs/authorization.md +225 -0
- package/docs/billing.md +293 -0
- package/docs/cli.md +238 -0
- package/docs/config.md +163 -0
- package/docs/db.md +219 -0
- package/docs/email.md +222 -0
- package/docs/entitlements.md +194 -0
- package/docs/errors.md +158 -0
- package/docs/events.md +279 -0
- package/docs/feature-flags.md +237 -0
- package/docs/files.md +229 -0
- package/docs/jobs.md +288 -0
- package/docs/logger.md +269 -0
- package/docs/notifications.md +179 -0
- package/docs/publishing.md +128 -0
- package/docs/realtime.md +360 -0
- package/docs/storage.md +279 -0
- package/docs/streams.md +296 -0
- package/docs/users.md +117 -0
- package/docs/webhooks.md +272 -0
- package/docs/workspaces.md +269 -0
- package/drizzle/0000_init_users.sql +21 -0
- package/drizzle/0001_workspaces_roles_permissions.sql +86 -0
- package/drizzle/0002_billing_entitlements.sql +97 -0
- package/drizzle/0003_events.sql +17 -0
- package/drizzle/0004_notifications_audit.sql +64 -0
- package/drizzle/0005_api_keys_webhooks.sql +77 -0
- package/drizzle/0006_feature_flags.sql +22 -0
- package/drizzle/0007_files.sql +23 -0
- package/drizzle/meta/0000_snapshot.json +160 -0
- package/drizzle/meta/0001_snapshot.json +837 -0
- package/drizzle/meta/0002_snapshot.json +1580 -0
- package/drizzle/meta/0003_snapshot.json +1751 -0
- package/drizzle/meta/0004_snapshot.json +2109 -0
- package/drizzle/meta/0005_snapshot.json +2719 -0
- package/drizzle/meta/0006_snapshot.json +2909 -0
- package/drizzle/meta/0007_snapshot.json +3113 -0
- package/drizzle/meta/_journal.json +62 -0
- package/package.json +180 -0
package/docs/events.md
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# Events
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
coreEvents, createEventsService, defineEvents, mergeEventRegistries,
|
|
6
|
+
} from "@digimuza/saas-core/events";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
> Orientation and recipes: [`src/events/README.md`](../src/events/README.md).
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
One durable record of everything that happens, typed at compile time and validated
|
|
13
|
+
at runtime. Realtime delivery, RxJS streams, notifications, audit logs, outgoing
|
|
14
|
+
webhooks and durable jobs all read from here rather than inventing their own
|
|
15
|
+
channel.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The registry
|
|
20
|
+
|
|
21
|
+
Each event type is paired with a Zod schema, which does double duty: `z.infer`
|
|
22
|
+
gives the compile-time payload type, and the same schema validates before anything
|
|
23
|
+
is written.
|
|
24
|
+
|
|
25
|
+
A `Record<string, unknown>` payload would give neither. A hand-written TypeScript
|
|
26
|
+
interface would give only the first, leaving the database to accept whatever a
|
|
27
|
+
caller happened to pass.
|
|
28
|
+
|
|
29
|
+
### `defineEvents(schemas)`
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
function defineEvents<const S extends Record<string, z.ZodType>>(schemas: S): EventRegistry<S>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { z } from "zod";
|
|
37
|
+
|
|
38
|
+
export const appEvents = defineEvents({
|
|
39
|
+
"project.created": z.object({ projectId: z.uuid(), name: z.string().min(1) }),
|
|
40
|
+
"project.deleted": z.object({ projectId: z.uuid() }),
|
|
41
|
+
});
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `const` type parameter preserves the literal keys — that is what makes
|
|
45
|
+
`publish({ type: "project.created", ... })` autocomplete and narrow `data`.
|
|
46
|
+
|
|
47
|
+
### `mergeEventRegistries(first, second)`
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
function mergeEventRegistries<A, B>(first: EventRegistry<A>, second: EventRegistry<B>): EventRegistry<A & B>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
// lib/events.ts
|
|
55
|
+
import { getDatabase } from "@digimuza/saas-core/db";
|
|
56
|
+
|
|
57
|
+
const registry = mergeEventRegistries(coreEvents, appEvents);
|
|
58
|
+
export const events = createEventsService({ db: getDatabase(), registry });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Duplicate keys **throw**. An application that redefined
|
|
62
|
+
`workspace.member.created` with a different payload would make every core consumer
|
|
63
|
+
of that event wrong, and the failure would surface far from the cause.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## `events.publish(input)`
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
function publish(input: {
|
|
71
|
+
type: K; // a key of the registry
|
|
72
|
+
data: z.input<S[K]>; // narrowed to that key's schema
|
|
73
|
+
workspaceId?: string | null;
|
|
74
|
+
actorId?: string | null;
|
|
75
|
+
idempotencyKey?: string;
|
|
76
|
+
occurredAt?: Date;
|
|
77
|
+
}): Promise<SaasEvent>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
await events.publish({
|
|
82
|
+
type: "project.created",
|
|
83
|
+
workspaceId: context.workspaceId,
|
|
84
|
+
actorId: user.id,
|
|
85
|
+
data: { projectId, name },
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Validates, persists, then fans out. The row is committed **before** handlers run,
|
|
90
|
+
so an event is never lost because a subscriber failed.
|
|
91
|
+
|
|
92
|
+
Passing a payload that does not match its type is a **compile error**. There is a
|
|
93
|
+
test asserting that with `@ts-expect-error`, so the guarantee cannot silently
|
|
94
|
+
regress.
|
|
95
|
+
|
|
96
|
+
`workspaceId` may be `null` for platform-wide events that precede any workspace,
|
|
97
|
+
such as a user being provisioned. `occurredAt` lets you record when something
|
|
98
|
+
actually happened, which is not always when it was recorded — a webhook may report
|
|
99
|
+
an event minutes after the fact.
|
|
100
|
+
|
|
101
|
+
Throws `InvalidInputError` for a payload that fails its schema, or an unknown type
|
|
102
|
+
(reachable only from untyped JavaScript).
|
|
103
|
+
|
|
104
|
+
## `events.scoped(context)`
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
function scoped(context: WorkspaceContext): ScopedPublisher
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Binds the verified workspace so callers cannot pass a different one:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const scoped = events.scoped(context);
|
|
114
|
+
await scoped.publish({ type: "project.created", actorId: user.id, data });
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## `events.record(input, tx?)` and `events.dispatch(event)`
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
function record(input: PublishInput, tx?: CoreDatabase): Promise<{ event: SaasEvent; created: boolean }>
|
|
121
|
+
function dispatch(event: SaasEvent): Promise<void>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The transactional outbox. Write the event in the **same transaction** as the state
|
|
125
|
+
change it describes, then dispatch after the commit:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const { event, created } = await db.transaction(async (tx) => {
|
|
129
|
+
await createProject(tx, input);
|
|
130
|
+
return events.record({ type: "project.created", data }, tx);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (created) {
|
|
134
|
+
await events.dispatch(event);
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
So there is no state change without its event, and no event for a change that
|
|
139
|
+
rolled back. Both directions are tested.
|
|
140
|
+
|
|
141
|
+
**Dispatch only when `created` is true.** A deduplicated event was already fanned
|
|
142
|
+
out when first recorded, and firing again would make every handler run twice for
|
|
143
|
+
one real occurrence. This was a real bug: the outbox originally queued deduplicated
|
|
144
|
+
events, which would have re-fired `user.created` handlers on every sign-in.
|
|
145
|
+
|
|
146
|
+
## `events.on(type, handler)` / `events.onAny(handler)`
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
function on<K>(type: K, handler: (event: SaasEventOf<S, K>) => void | Promise<void>): Unsubscribe
|
|
150
|
+
function onAny(handler: (event: SaasEvent<S>) => void | Promise<void>): Unsubscribe
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
const unsubscribe = events.on("project.created", async (event) => {
|
|
155
|
+
event.data.name; // narrowed to this event's schema
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Subscribers are in-process and not durable
|
|
160
|
+
|
|
161
|
+
A crash between the commit and the handler loses the **reaction**, not the event.
|
|
162
|
+
That is deliberate: durable reactions belong in Trigger.dev (see
|
|
163
|
+
[jobs.md](./jobs.md)), reading the persisted row. Never keep state in a handler
|
|
164
|
+
that must survive a restart.
|
|
165
|
+
|
|
166
|
+
### Handlers cannot fail a publish
|
|
167
|
+
|
|
168
|
+
They run concurrently via `Promise.allSettled`; each failure goes to the service's
|
|
169
|
+
error sink and the others still run. The event has happened and is committed, so a
|
|
170
|
+
broken subscriber is a separate operational problem.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
createEventsService({
|
|
174
|
+
db, registry,
|
|
175
|
+
onHandlerError: (error, event) => logger.error({ error, type: event.type }),
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Without a sink, failures are logged with `console.error`. Provide one in production
|
|
180
|
+
so they reach your error tracker.
|
|
181
|
+
|
|
182
|
+
## `events.list(context, options?)`
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
function list(context: WorkspaceContext, options?: {
|
|
186
|
+
types?: readonly string[];
|
|
187
|
+
limit?: number; // default 50, capped at 500
|
|
188
|
+
before?: Date; // keyset pagination
|
|
189
|
+
actorId?: string;
|
|
190
|
+
}): Promise<SaasEvent[]>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Always workspace-scoped: an event feed is tenant data. Newest first, tie-broken on
|
|
194
|
+
id so keyset pagination cannot loop on equal timestamps.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const page = await events.list(context, { types: ["project.created"], limit: 20 });
|
|
198
|
+
const next = await events.list(context, { before: page.at(-1)!.occurredAt });
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Idempotency
|
|
204
|
+
|
|
205
|
+
`(type, idempotency_key)` is a **partial** unique index, so a retried operation
|
|
206
|
+
records its event once. Keys are scoped per type — two event types may legitimately
|
|
207
|
+
derive a key from the same request.
|
|
208
|
+
|
|
209
|
+
`user.created` uses the WorkOS id as its key, which is how a re-login provisions no
|
|
210
|
+
second event without a racy select-then-decide.
|
|
211
|
+
|
|
212
|
+
> Because the index is partial (`WHERE idempotency_key IS NOT NULL`), the
|
|
213
|
+
> `ON CONFLICT` clause must carry a matching `where` predicate. PostgreSQL refuses
|
|
214
|
+
> to infer a partial index as a conflict arbiter otherwise, and every insert fails
|
|
215
|
+
> with `42P10`. Worth knowing if you add a similar index of your own.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## What the core emits
|
|
220
|
+
|
|
221
|
+
| Event | Payload |
|
|
222
|
+
| --- | --- |
|
|
223
|
+
| `user.created` | `userId`, `workosUserId` |
|
|
224
|
+
| `workspace.created` | `workspaceId`, `slug` |
|
|
225
|
+
| `workspace.updated` | `workspaceId`, `changed: ("name"\|"slug")[]` |
|
|
226
|
+
| `workspace.deleted` | `workspaceId`, `slug` |
|
|
227
|
+
| `workspace.member.created` | `userId`, `roleIds` |
|
|
228
|
+
| `workspace.member.removed` | `userId` |
|
|
229
|
+
| `workspace.member.role.updated` | `userId`, `roleId`, `action` |
|
|
230
|
+
| `workspace.invitation.created` | `invitationId`, `email`, `roleId` |
|
|
231
|
+
| `workspace.invitation.accepted` | `invitationId`, `userId` |
|
|
232
|
+
| `workspace.invitation.revoked` | `invitationId` |
|
|
233
|
+
| `billing.subscription.updated` | `subscriptionId`, `planKey`, `status`, `previousStatus?` |
|
|
234
|
+
| `billing.subscription.canceled` | `subscriptionId`, `planKey`, `atPeriodEnd` |
|
|
235
|
+
| `job.started` | `runId`, `taskId` |
|
|
236
|
+
| `job.progress` | `runId`, `taskId`, `percent?`, `message?`, `step?`, `totalSteps?` |
|
|
237
|
+
| `job.completed` | `runId`, `taskId`, `durationMs?` |
|
|
238
|
+
| `job.failed` | `runId`, `taskId`, `error`, `attempt?` |
|
|
239
|
+
|
|
240
|
+
Notification and realtime events arrive with the phases that introduce them.
|
|
241
|
+
Declaring them now would ship types nothing publishes — worse than no type, since
|
|
242
|
+
consumers would write handlers that never fire.
|
|
243
|
+
|
|
244
|
+
### Payload conventions
|
|
245
|
+
|
|
246
|
+
**Identifiers, not snapshots.** An embedded copy goes stale between publication and
|
|
247
|
+
delivery, and would put mutable user data in an append-only table. A consumer that
|
|
248
|
+
needs current state should read it.
|
|
249
|
+
|
|
250
|
+
**No credentials.** Invitation events deliberately omit the token: events are
|
|
251
|
+
durable and readable, and the token is a bearer credential. There is a test
|
|
252
|
+
asserting it never appears.
|
|
253
|
+
|
|
254
|
+
**`workspace.deleted` carries `workspaceId: null`** on the row itself. It is
|
|
255
|
+
recorded before the delete, in the same transaction, because `events.workspace_id`
|
|
256
|
+
cascades — a scoped row would vanish along with the tenant it was recording.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Emission is opt-in
|
|
261
|
+
|
|
262
|
+
`createWorkspacesService({ db })` emits nothing.
|
|
263
|
+
`createWorkspacesService({ db, events })` does.
|
|
264
|
+
|
|
265
|
+
The default exports are wired together, so
|
|
266
|
+
`import { workspaces } from "@digimuza/saas-core/workspaces"` already emits to the
|
|
267
|
+
core registry. If you build your own services from a merged registry, pass it in —
|
|
268
|
+
`CoreEventPublisher` is a supertype-friendly slice, so a merged-registry service is
|
|
269
|
+
accepted without the core knowing about your extra event types.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## The events table
|
|
274
|
+
|
|
275
|
+
Append-only. There is no `updated_at`: an event is a statement about the past, and
|
|
276
|
+
revising one would make every derived record untrustworthy.
|
|
277
|
+
|
|
278
|
+
Indexed for `(workspace_id, occurred_at DESC)` — the activity-feed query — and
|
|
279
|
+
`(type, occurred_at DESC)` for backfills and diagnostics.
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# Feature flags
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
defineFeatureFlags, createFeaturesService, createConfiguredFeaturesService,
|
|
6
|
+
} from "@digimuza/saas-core/feature-flags";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
> Orientation and recipes: [`src/features/README.md`](../src/features/README.md).
|
|
10
|
+
|
|
11
|
+
Simple flags with four scopes, resolved most-specific-first. **Not** a feature
|
|
12
|
+
management platform — no segments, no rule engines, no audience builders.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Declaring flags
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
export const flags = defineFeatureFlags({
|
|
20
|
+
"new-editor": { default: false, description: "Rebuilt document editor" },
|
|
21
|
+
ranking: { default: true, variants: ["control", "v2"], defaultVariant: "control" },
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
interface FeatureFlagDefinition {
|
|
27
|
+
default: boolean;
|
|
28
|
+
description?: string;
|
|
29
|
+
variants?: readonly string[];
|
|
30
|
+
defaultVariant?: string;
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Definitions live in **code**, not the database. So a fresh database behaves the
|
|
35
|
+
same as a configured one, a misspelled key is a compile error, and deleting the
|
|
36
|
+
flag from the code deletes it from the product. Database rows are only *overrides*.
|
|
37
|
+
|
|
38
|
+
Keys must be lowercase alphanumerics with dots, dashes or underscores — they reach
|
|
39
|
+
URLs, log lines and client payloads, and constraining them here avoids escaping
|
|
40
|
+
them everywhere downstream.
|
|
41
|
+
|
|
42
|
+
`mergeFeatureFlags(a, b)` combines registries and **throws** on a duplicate: two
|
|
43
|
+
modules defining the same flag would make its behaviour depend on import order.
|
|
44
|
+
|
|
45
|
+
### Constructing the service
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
createConfiguredFeaturesService(flags); // application = APP_NAME
|
|
49
|
+
createFeaturesService({ db, registry: flags, application }); // explicit
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
There is deliberately **no default `features` export** — flags are
|
|
53
|
+
application-defined, so a service with an empty registry could not answer
|
|
54
|
+
anything.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Scopes
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
global < application < workspace < user
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The most specific matching override wins. `FLAG_SCOPE_PRECEDENCE` is exported, so
|
|
65
|
+
a debug view can show the order.
|
|
66
|
+
|
|
67
|
+
`global` is every application sharing this database; `application` narrows to one
|
|
68
|
+
product built on the core. That distinction exists because this package is
|
|
69
|
+
explicitly meant to back several SaaS products — without it, turning a flag on for
|
|
70
|
+
one would turn it on for all of them.
|
|
71
|
+
|
|
72
|
+
An `application`-scoped override only matches when the service was given an
|
|
73
|
+
`application`. Otherwise it is ignored rather than guessed at.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Reading
|
|
78
|
+
|
|
79
|
+
### `features.all(subject?)`
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
function all(subject?: FlagSubject): Promise<Map<Key, ResolvedFlag>>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Every declared flag, resolved in **one** query. The recommended path: resolve once
|
|
86
|
+
per request and pass the result to the client, rather than querying per
|
|
87
|
+
conditional.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const resolved = await features.all({ workspaceId: context.workspaceId, userId: user.id });
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### `features.isEnabled(key, subject?)` / `getVariant(key, subject?)`
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
function isEnabled(key: Key, subject?: FlagSubject): Promise<boolean>
|
|
97
|
+
function getVariant<K>(key: K, subject?: FlagSubject): Promise<Variant<K> | undefined>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`getVariant` is typed to the flag's declared variants, so a `switch` is
|
|
101
|
+
exhaustively checkable.
|
|
102
|
+
|
|
103
|
+
**A disabled flag has no variant** — `undefined`. Returning one would invite code
|
|
104
|
+
that reads the variant without checking `isEnabled` first.
|
|
105
|
+
|
|
106
|
+
### `features.resolve(key, subject?)`
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
interface ResolvedFlag {
|
|
110
|
+
key: string;
|
|
111
|
+
enabled: boolean;
|
|
112
|
+
variant: string | undefined;
|
|
113
|
+
source: "global" | "application" | "workspace" | "user" | "default";
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`source` answers "why is this on for me", which is the question every flag system
|
|
118
|
+
eventually has to answer.
|
|
119
|
+
|
|
120
|
+
### `FlagSubject`
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
interface FlagSubject {
|
|
124
|
+
workspaceId?: string;
|
|
125
|
+
userId?: string;
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Plain ids, **not** a `WorkspaceContext` — the one deliberate exception in the core.
|
|
130
|
+
Flags are read many times per request, and a membership check per read would make
|
|
131
|
+
them expensive enough that people would cache them badly. A flag state is
|
|
132
|
+
configuration, not tenant data.
|
|
133
|
+
|
|
134
|
+
Pass ids that came from a verified context. The service will not check for you.
|
|
135
|
+
|
|
136
|
+
### Unknown keys throw
|
|
137
|
+
|
|
138
|
+
`InvalidInputError`, not `false`. Reachable only from untyped JavaScript, but a
|
|
139
|
+
flag that silently never turns on is far harder to diagnose than one that fails at
|
|
140
|
+
the call site.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## Writing
|
|
145
|
+
|
|
146
|
+
### `features.set(input)`
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
function set(input: {
|
|
150
|
+
key: Key;
|
|
151
|
+
scope: FeatureFlagScope;
|
|
152
|
+
application?: string; // required for scope: "application"
|
|
153
|
+
workspaceId?: string; // required for scope: "workspace"
|
|
154
|
+
userId?: string; // required for scope: "user"
|
|
155
|
+
enabled: boolean;
|
|
156
|
+
variant?: string | null;
|
|
157
|
+
rolloutPercentage?: number | null; // 0-100
|
|
158
|
+
description?: string;
|
|
159
|
+
}): Promise<FeatureFlagRow>
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Idempotent per target — setting the same override twice updates it, which is what
|
|
163
|
+
both an admin UI and a seeding script want.
|
|
164
|
+
|
|
165
|
+
Two things are rejected rather than tolerated:
|
|
166
|
+
|
|
167
|
+
- **A variant not declared on the flag.** Better than discovering it when a
|
|
168
|
+
`switch` silently matches nothing.
|
|
169
|
+
- **A target that contradicts the scope.** `{ scope: "global", workspaceId }` is
|
|
170
|
+
always a mistake, and ignoring it would look like it had worked.
|
|
171
|
+
|
|
172
|
+
### `features.unset(input)` / `overrides(key)`
|
|
173
|
+
|
|
174
|
+
`unset` removes one override, falling back to the next-broadest scope. Throws
|
|
175
|
+
`NotFoundError` if there was none. `overrides` lists every stored override for a
|
|
176
|
+
key, broadest first.
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Percentage rollout
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
await features.set({ key: "new-editor", scope: "global", enabled: true, rolloutPercentage: 10 });
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Rollout narrows an override; it never turns one on.** A `rolloutPercentage` on a
|
|
187
|
+
*disabled* override leaves it disabled. On an enabled one it means "on for this
|
|
188
|
+
percentage of the subjects this override covers".
|
|
189
|
+
|
|
190
|
+
Bucketing is `sha256(key + subjectId) % 100`, so:
|
|
191
|
+
|
|
192
|
+
- **Stable.** A subject stays on the same side across requests, processes and
|
|
193
|
+
deploys. A flag that flickered would be worse than no flag — a user would see
|
|
194
|
+
the new editor, reload, and lose their work.
|
|
195
|
+
- **Salted by flag key.** Otherwise the same unlucky cohort would receive every
|
|
196
|
+
experiment.
|
|
197
|
+
- **Stateless.** Nothing to store, nothing to keep in sync.
|
|
198
|
+
|
|
199
|
+
The subject is the **most specific available**: `userId` if present, else
|
|
200
|
+
`workspaceId`. So a per-user rollout is stable per user and a per-workspace one per
|
|
201
|
+
workspace.
|
|
202
|
+
|
|
203
|
+
With **no subject**, anything below 100 is off. There is nothing stable to hash,
|
|
204
|
+
and guessing would reintroduce the flickering this design exists to prevent.
|
|
205
|
+
|
|
206
|
+
`bucketOf(key, subjectId)` and `isInRollout(key, percentage, subjectId)` are
|
|
207
|
+
exported, if you need the same bucketing for something the service does not cover.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Storage
|
|
212
|
+
|
|
213
|
+
One table, `feature_flags`, one row per override per target.
|
|
214
|
+
|
|
215
|
+
```
|
|
216
|
+
unique nulls not distinct (key, scope, application, workspace_id, user_id)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`NULLS NOT DISTINCT` is load-bearing. PostgreSQL treats NULLs as distinct in a
|
|
220
|
+
unique index by default, so without it every global override — where all three
|
|
221
|
+
target columns are null — would count as unique and you could insert the same one
|
|
222
|
+
endlessly. There is a test for exactly that.
|
|
223
|
+
|
|
224
|
+
`workspace_id` and `user_id` are real foreign keys with `ON DELETE CASCADE`, so an
|
|
225
|
+
override for a deleted tenant does not accumulate as dead configuration. Contrast
|
|
226
|
+
[audit](./audit.md), where the identifiers are deliberately *not* foreign keys
|
|
227
|
+
because an entry must outlive its subject.
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## No caching
|
|
232
|
+
|
|
233
|
+
Every call is a query. `all()` exists so you make one per request rather than one
|
|
234
|
+
per conditional; wrap it in your own cache if you need more.
|
|
235
|
+
|
|
236
|
+
Deliberate: a library that cached by default would give you stale flags at a moment
|
|
237
|
+
you did not choose, and the right TTL depends on how you deploy.
|