@rikalabs/akter 0.1.0-alpha.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/CHANGELOG.md +15 -0
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +116 -0
- package/dist/actor/codecs.d.ts +77 -0
- package/dist/actor/codecs.js +87 -0
- package/dist/actor/connections.d.ts +34 -0
- package/dist/actor/connections.js +145 -0
- package/dist/actor/definition.d.ts +893 -0
- package/dist/actor/definition.js +147 -0
- package/dist/actor/descriptor.d.ts +172 -0
- package/dist/actor/descriptor.js +367 -0
- package/dist/actor/handles.d.ts +77 -0
- package/dist/actor/handles.js +265 -0
- package/dist/actor/jobs.d.ts +10 -0
- package/dist/actor/jobs.js +137 -0
- package/dist/actor/layers.d.ts +41 -0
- package/dist/actor/layers.js +177 -0
- package/dist/actor/reads.d.ts +47 -0
- package/dist/actor/reads.js +224 -0
- package/dist/actor/served.d.ts +120 -0
- package/dist/actor/served.js +72 -0
- package/dist/actor/turns.d.ts +13 -0
- package/dist/actor/turns.js +332 -0
- package/dist/actor/workflows.d.ts +16 -0
- package/dist/actor/workflows.js +37 -0
- package/dist/client/calls.d.ts +47 -0
- package/dist/client/calls.js +425 -0
- package/dist/client/clock.d.ts +56 -0
- package/dist/client/clock.js +119 -0
- package/dist/client/fleet-page.d.ts +15 -0
- package/dist/client/fleet-page.js +13 -0
- package/dist/client/fleet.d.ts +29 -0
- package/dist/client/fleet.js +51 -0
- package/dist/client/index.d.ts +17 -0
- package/dist/client/index.js +6 -0
- package/dist/client/make.d.ts +157 -0
- package/dist/client/make.js +221 -0
- package/dist/client/offline/queue.d.ts +118 -0
- package/dist/client/offline/queue.js +292 -0
- package/dist/client/offline/store.d.ts +74 -0
- package/dist/client/offline/store.js +78 -0
- package/dist/client/optimistic.d.ts +57 -0
- package/dist/client/optimistic.js +136 -0
- package/dist/client/report.d.ts +2 -0
- package/dist/client/report.js +8 -0
- package/dist/client/sessions/connection.d.ts +118 -0
- package/dist/client/sessions/connection.js +162 -0
- package/dist/client/sessions/feed.d.ts +42 -0
- package/dist/client/sessions/feed.js +43 -0
- package/dist/client/sessions/sse.d.ts +47 -0
- package/dist/client/sessions/sse.js +112 -0
- package/dist/client/sessions/stream.d.ts +28 -0
- package/dist/client/sessions/stream.js +35 -0
- package/dist/client/sessions/watch.d.ts +34 -0
- package/dist/client/sessions/watch.js +40 -0
- package/dist/client/transport.d.ts +37 -0
- package/dist/client/transport.js +66 -0
- package/dist/contexts/command.d.ts +132 -0
- package/dist/contexts/command.js +20 -0
- package/dist/contexts/connection.d.ts +68 -0
- package/dist/contexts/connection.js +0 -0
- package/dist/contexts/job.d.ts +49 -0
- package/dist/contexts/job.js +0 -0
- package/dist/contexts/workflow.d.ts +147 -0
- package/dist/contexts/workflow.js +22 -0
- package/dist/errors/actor.d.ts +200 -0
- package/dist/errors/actor.js +271 -0
- package/dist/errors/content.d.ts +19 -0
- package/dist/errors/content.js +14 -0
- package/dist/errors/database.d.ts +22 -0
- package/dist/errors/database.js +23 -0
- package/dist/errors/events.d.ts +17 -0
- package/dist/errors/events.js +14 -0
- package/dist/errors/subscription.d.ts +12 -0
- package/dist/errors/subscription.js +8 -0
- package/dist/errors/workflow.d.ts +28 -0
- package/dist/errors/workflow.js +17 -0
- package/dist/handles/actors.d.ts +17 -0
- package/dist/handles/actors.js +7 -0
- package/dist/handles/content.d.ts +59 -0
- package/dist/handles/content.js +36 -0
- package/dist/handles/intents.d.ts +164 -0
- package/dist/handles/intents.js +214 -0
- package/dist/handles/run.d.ts +31 -0
- package/dist/handles/run.js +21 -0
- package/dist/handles/workflow.d.ts +27 -0
- package/dist/handles/workflow.js +23 -0
- package/dist/identity/caller.d.ts +105 -0
- package/dist/identity/caller.js +68 -0
- package/dist/identity/child.d.ts +18 -0
- package/dist/identity/child.js +39 -0
- package/dist/identity/command.d.ts +19 -0
- package/dist/identity/command.js +21 -0
- package/dist/identity/connection.d.ts +24 -0
- package/dist/identity/connection.js +29 -0
- package/dist/identity/content.d.ts +18 -0
- package/dist/identity/content.js +12 -0
- package/dist/identity/digest.d.ts +4 -0
- package/dist/identity/digest.js +5 -0
- package/dist/identity/execution.d.ts +32 -0
- package/dist/identity/execution.js +63 -0
- package/dist/identity/mint.d.ts +51 -0
- package/dist/identity/mint.js +76 -0
- package/dist/identity/version.d.ts +2 -0
- package/dist/identity/version.js +3 -0
- package/dist/index.d.ts +159 -0
- package/dist/index.js +97 -0
- package/dist/members/blob.d.ts +43 -0
- package/dist/members/blob.js +29 -0
- package/dist/members/command.d.ts +109 -0
- package/dist/members/command.js +61 -0
- package/dist/members/connection.d.ts +55 -0
- package/dist/members/connection.js +49 -0
- package/dist/members/event.d.ts +36 -0
- package/dist/members/event.js +24 -0
- package/dist/members/job.d.ts +157 -0
- package/dist/members/job.js +67 -0
- package/dist/members/payload.d.ts +93 -0
- package/dist/members/payload.js +145 -0
- package/dist/members/reducer.d.ts +64 -0
- package/dist/members/reducer.js +27 -0
- package/dist/members/stream.d.ts +36 -0
- package/dist/members/stream.js +14 -0
- package/dist/members/subscription.d.ts +182 -0
- package/dist/members/subscription.js +91 -0
- package/dist/members/workflow.d.ts +99 -0
- package/dist/members/workflow.js +2 -0
- package/dist/policies/access.d.ts +35 -0
- package/dist/policies/access.js +6 -0
- package/dist/policies/command.d.ts +127 -0
- package/dist/policies/command.js +45 -0
- package/dist/policies/job.d.ts +28 -0
- package/dist/policies/job.js +60 -0
- package/dist/policies/schedules.d.ts +33 -0
- package/dist/policies/schedules.js +131 -0
- package/dist/protocol/frames.d.ts +66 -0
- package/dist/protocol/frames.js +55 -0
- package/dist/protocol/inspection.d.ts +807 -0
- package/dist/protocol/inspection.js +243 -0
- package/dist/protocol/wire.d.ts +184 -0
- package/dist/protocol/wire.js +150 -0
- package/dist/runtime/actors.d.ts +155 -0
- package/dist/runtime/actors.js +4 -0
- package/dist/runtime/admission.d.ts +34 -0
- package/dist/runtime/admission.js +72 -0
- package/dist/runtime/adoption/backfill.d.ts +27 -0
- package/dist/runtime/adoption/backfill.js +144 -0
- package/dist/runtime/adoption/enforce.d.ts +55 -0
- package/dist/runtime/adoption/enforce.js +241 -0
- package/dist/runtime/adoption/observe.d.ts +43 -0
- package/dist/runtime/adoption/observe.js +139 -0
- package/dist/runtime/adoption/plan.d.ts +45 -0
- package/dist/runtime/adoption/plan.js +192 -0
- package/dist/runtime/adoption/startup.d.ts +32 -0
- package/dist/runtime/adoption/startup.js +76 -0
- package/dist/runtime/adoption/status.d.ts +23 -0
- package/dist/runtime/adoption/status.js +47 -0
- package/dist/runtime/adoption/target.d.ts +33 -0
- package/dist/runtime/adoption/target.js +49 -0
- package/dist/runtime/connections/holder.d.ts +221 -0
- package/dist/runtime/connections/holder.js +816 -0
- package/dist/runtime/connections/owner.d.ts +263 -0
- package/dist/runtime/connections/owner.js +896 -0
- package/dist/runtime/connections/protocol.d.ts +441 -0
- package/dist/runtime/connections/protocol.js +222 -0
- package/dist/runtime/connections/reads.d.ts +24 -0
- package/dist/runtime/connections/reads.js +19 -0
- package/dist/runtime/connections/streams.d.ts +62 -0
- package/dist/runtime/connections/streams.js +128 -0
- package/dist/runtime/connections/transport.d.ts +40 -0
- package/dist/runtime/connections/transport.js +68 -0
- package/dist/runtime/connections/watch.d.ts +52 -0
- package/dist/runtime/connections/watch.js +159 -0
- package/dist/runtime/content/grant.d.ts +34 -0
- package/dist/runtime/content/grant.js +58 -0
- package/dist/runtime/content/store.d.ts +63 -0
- package/dist/runtime/content/store.js +294 -0
- package/dist/runtime/cron/key.d.ts +2 -0
- package/dist/runtime/cron/key.js +2 -0
- package/dist/runtime/cron/schedule.d.ts +69 -0
- package/dist/runtime/cron/schedule.js +144 -0
- package/dist/runtime/database/bounded.d.ts +43 -0
- package/dist/runtime/database/bounded.js +108 -0
- package/dist/runtime/database/connection.d.ts +8 -0
- package/dist/runtime/database/connection.js +18 -0
- package/dist/runtime/database/coordination.d.ts +19 -0
- package/dist/runtime/database/coordination.js +72 -0
- package/dist/runtime/database/flock.d.ts +10 -0
- package/dist/runtime/database/flock.js +60 -0
- package/dist/runtime/database/gate.d.ts +26 -0
- package/dist/runtime/database/gate.js +54 -0
- package/dist/runtime/database/keepalive.d.ts +11 -0
- package/dist/runtime/database/keepalive.js +22 -0
- package/dist/runtime/database/migrations.d.ts +177 -0
- package/dist/runtime/database/migrations.js +1018 -0
- package/dist/runtime/database/neki/migrations.d.ts +31 -0
- package/dist/runtime/database/neki/migrations.js +276 -0
- package/dist/runtime/database/neki/session.d.ts +23 -0
- package/dist/runtime/database/neki/session.js +31 -0
- package/dist/runtime/database/pglite.d.ts +21 -0
- package/dist/runtime/database/pglite.js +74 -0
- package/dist/runtime/database/replica.d.ts +41 -0
- package/dist/runtime/database/replica.js +51 -0
- package/dist/runtime/database/shards.d.ts +21 -0
- package/dist/runtime/database/shards.js +37 -0
- package/dist/runtime/database/snapshot.d.ts +7 -0
- package/dist/runtime/database/snapshot.js +7 -0
- package/dist/runtime/database/tenancy.d.ts +69 -0
- package/dist/runtime/database/tenancy.js +145 -0
- package/dist/runtime/database/transient.d.ts +10 -0
- package/dist/runtime/database/transient.js +25 -0
- package/dist/runtime/drain.d.ts +132 -0
- package/dist/runtime/drain.js +109 -0
- package/dist/runtime/entity/mailbox.d.ts +86 -0
- package/dist/runtime/entity/mailbox.js +121 -0
- package/dist/runtime/entity/register.d.ts +424 -0
- package/dist/runtime/entity/register.js +669 -0
- package/dist/runtime/events/append.d.ts +192 -0
- package/dist/runtime/events/append.js +128 -0
- package/dist/runtime/events/cursor.d.ts +2 -0
- package/dist/runtime/events/cursor.js +5 -0
- package/dist/runtime/events/replay.d.ts +20 -0
- package/dist/runtime/events/replay.js +45 -0
- package/dist/runtime/feeds.d.ts +26 -0
- package/dist/runtime/feeds.js +100 -0
- package/dist/runtime/fleet/checks.d.ts +17 -0
- package/dist/runtime/fleet/checks.js +106 -0
- package/dist/runtime/fleet/maintainer.d.ts +38 -0
- package/dist/runtime/fleet/maintainer.js +383 -0
- package/dist/runtime/fleet/pgoutput.d.ts +41 -0
- package/dist/runtime/fleet/pgoutput.js +102 -0
- package/dist/runtime/fleet/setup.d.ts +36 -0
- package/dist/runtime/fleet/setup.js +58 -0
- package/dist/runtime/fleet/subscribe.d.ts +42 -0
- package/dist/runtime/fleet/subscribe.js +147 -0
- package/dist/runtime/index.d.ts +66 -0
- package/dist/runtime/index.js +37 -0
- package/dist/runtime/inspector/http.d.ts +19 -0
- package/dist/runtime/inspector/http.js +36 -0
- package/dist/runtime/inspector/layer.d.ts +46 -0
- package/dist/runtime/inspector/layer.js +180 -0
- package/dist/runtime/inspector/queries.d.ts +711 -0
- package/dist/runtime/inspector/queries.js +494 -0
- package/dist/runtime/jobs/attempt.d.ts +254 -0
- package/dist/runtime/jobs/attempt.js +413 -0
- package/dist/runtime/jobs/isolation.d.ts +20 -0
- package/dist/runtime/jobs/isolation.js +10 -0
- package/dist/runtime/jobs/progress.d.ts +95 -0
- package/dist/runtime/jobs/progress.js +144 -0
- package/dist/runtime/layer.d.ts +260 -0
- package/dist/runtime/layer.js +876 -0
- package/dist/runtime/members.d.ts +381 -0
- package/dist/runtime/members.js +8 -0
- package/dist/runtime/operators/audit.d.ts +64 -0
- package/dist/runtime/operators/audit.js +56 -0
- package/dist/runtime/operators/auth.d.ts +29 -0
- package/dist/runtime/operators/auth.js +42 -0
- package/dist/runtime/operators/export.d.ts +68 -0
- package/dist/runtime/operators/export.js +137 -0
- package/dist/runtime/operators/grants.d.ts +45 -0
- package/dist/runtime/operators/grants.js +40 -0
- package/dist/runtime/operators/repair.d.ts +206 -0
- package/dist/runtime/operators/repair.js +226 -0
- package/dist/runtime/operators/routes.d.ts +35 -0
- package/dist/runtime/operators/routes.js +190 -0
- package/dist/runtime/operators/seed.d.ts +123 -0
- package/dist/runtime/operators/seed.js +123 -0
- package/dist/runtime/payloads/versions.d.ts +65 -0
- package/dist/runtime/payloads/versions.js +240 -0
- package/dist/runtime/peering/authority.d.ts +42 -0
- package/dist/runtime/peering/authority.js +132 -0
- package/dist/runtime/peering/credentials.d.ts +21 -0
- package/dist/runtime/peering/credentials.js +13 -0
- package/dist/runtime/peering/transport.d.ts +51 -0
- package/dist/runtime/peering/transport.js +175 -0
- package/dist/runtime/queries.d.ts +18 -0
- package/dist/runtime/queries.js +169 -0
- package/dist/runtime/readiness.d.ts +36 -0
- package/dist/runtime/readiness.js +44 -0
- package/dist/runtime/registration.d.ts +58 -0
- package/dist/runtime/registration.js +186 -0
- package/dist/runtime/request.d.ts +158 -0
- package/dist/runtime/request.js +102 -0
- package/dist/runtime/retry.d.ts +8 -0
- package/dist/runtime/retry.js +11 -0
- package/dist/runtime/runner.d.ts +93 -0
- package/dist/runtime/runner.js +122 -0
- package/dist/runtime/storage/codec.d.ts +39 -0
- package/dist/runtime/storage/codec.js +41 -0
- package/dist/runtime/storage/generation.d.ts +69 -0
- package/dist/runtime/storage/generation.js +49 -0
- package/dist/runtime/storage/placements.d.ts +15 -0
- package/dist/runtime/storage/placements.js +38 -0
- package/dist/runtime/storage/platform.d.ts +6 -0
- package/dist/runtime/storage/platform.js +9 -0
- package/dist/runtime/storage/retention.d.ts +73 -0
- package/dist/runtime/storage/retention.js +158 -0
- package/dist/runtime/subscriptions/identity.d.ts +31 -0
- package/dist/runtime/subscriptions/identity.js +45 -0
- package/dist/runtime/subscriptions/relay.d.ts +175 -0
- package/dist/runtime/subscriptions/relay.js +763 -0
- package/dist/runtime/subscriptions/storage.d.ts +40 -0
- package/dist/runtime/subscriptions/storage.js +98 -0
- package/dist/runtime/sweeps.d.ts +39 -0
- package/dist/runtime/sweeps.js +54 -0
- package/dist/runtime/telemetry/defects.d.ts +57 -0
- package/dist/runtime/telemetry/defects.js +43 -0
- package/dist/runtime/telemetry/metrics.d.ts +52 -0
- package/dist/runtime/telemetry/metrics.js +138 -0
- package/dist/runtime/telemetry/routes.d.ts +19 -0
- package/dist/runtime/telemetry/routes.js +18 -0
- package/dist/runtime/telemetry/sampler.d.ts +26 -0
- package/dist/runtime/telemetry/sampler.js +101 -0
- package/dist/runtime/telemetry/spans.d.ts +38 -0
- package/dist/runtime/telemetry/spans.js +44 -0
- package/dist/runtime/telemetry/usage.d.ts +52 -0
- package/dist/runtime/telemetry/usage.js +30 -0
- package/dist/runtime/topology/admission.d.ts +13 -0
- package/dist/runtime/topology/admission.js +36 -0
- package/dist/runtime/topology/locks.d.ts +56 -0
- package/dist/runtime/topology/locks.js +81 -0
- package/dist/runtime/topology/messages.d.ts +15 -0
- package/dist/runtime/topology/messages.js +32 -0
- package/dist/runtime/turn/admission.d.ts +22 -0
- package/dist/runtime/turn/admission.js +41 -0
- package/dist/runtime/turn/blobs.d.ts +38 -0
- package/dist/runtime/turn/blobs.js +215 -0
- package/dist/runtime/turn/execute.d.ts +208 -0
- package/dist/runtime/turn/execute.js +972 -0
- package/dist/runtime/turn/group.d.ts +165 -0
- package/dist/runtime/turn/group.js +326 -0
- package/dist/runtime/turn/hooks.d.ts +44 -0
- package/dist/runtime/turn/hooks.js +28 -0
- package/dist/runtime/turn/outbox.d.ts +159 -0
- package/dist/runtime/turn/outbox.js +269 -0
- package/dist/runtime/turn/pipeline.d.ts +68 -0
- package/dist/runtime/turn/pipeline.js +83 -0
- package/dist/runtime/turn/receipt.d.ts +81 -0
- package/dist/runtime/turn/receipt.js +32 -0
- package/dist/runtime/turn/relay.d.ts +179 -0
- package/dist/runtime/turn/relay.js +685 -0
- package/dist/runtime/turn/rows.d.ts +43 -0
- package/dist/runtime/turn/rows.js +592 -0
- package/dist/runtime/workflows/compatibility.d.ts +125 -0
- package/dist/runtime/workflows/compatibility.js +285 -0
- package/dist/runtime/workflows/engine.d.ts +107 -0
- package/dist/runtime/workflows/engine.js +826 -0
- package/dist/runtime/workflows/manifest.d.ts +34 -0
- package/dist/runtime/workflows/manifest.js +39 -0
- package/dist/runtime/workflows/steps.d.ts +33 -0
- package/dist/runtime/workflows/steps.js +152 -0
- package/dist/serve/api.d.ts +99 -0
- package/dist/serve/api.js +403 -0
- package/dist/serve/assertion/binding.d.ts +41 -0
- package/dist/serve/assertion/binding.js +92 -0
- package/dist/serve/assertion/verify.d.ts +93 -0
- package/dist/serve/assertion/verify.js +189 -0
- package/dist/serve/auth.d.ts +200 -0
- package/dist/serve/auth.js +92 -0
- package/dist/serve/clock.d.ts +10 -0
- package/dist/serve/clock.js +25 -0
- package/dist/serve/jwt.d.ts +65 -0
- package/dist/serve/jwt.js +202 -0
- package/dist/serve/layer.d.ts +98 -0
- package/dist/serve/layer.js +809 -0
- package/dist/serve/mcp/endpoint.d.ts +39 -0
- package/dist/serve/mcp/endpoint.js +187 -0
- package/dist/serve/mcp/tools.d.ts +37 -0
- package/dist/serve/mcp/tools.js +125 -0
- package/dist/serve/origin.d.ts +6 -0
- package/dist/serve/origin.js +21 -0
- package/dist/serve/sessions/feed.d.ts +79 -0
- package/dist/serve/sessions/feed.js +115 -0
- package/dist/serve/sessions/socket.d.ts +56 -0
- package/dist/serve/sessions/socket.js +212 -0
- package/dist/serve/sessions/sse.d.ts +27 -0
- package/dist/serve/sessions/sse.js +26 -0
- package/dist/serve/sessions/stream.d.ts +9 -0
- package/dist/serve/sessions/stream.js +9 -0
- package/dist/serve/sessions/watch.d.ts +12 -0
- package/dist/serve/sessions/watch.js +11 -0
- package/dist/serve/wire.d.ts +16 -0
- package/dist/serve/wire.js +50 -0
- package/dist/state/blob.d.ts +71 -0
- package/dist/state/blob.js +0 -0
- package/dist/state/migration.d.ts +53 -0
- package/dist/state/migration.js +29 -0
- package/dist/tables/fleet.d.ts +170 -0
- package/dist/tables/fleet.js +214 -0
- package/dist/tables/owned.d.ts +250 -0
- package/dist/tables/owned.js +233 -0
- package/dist/testing/actor-test.d.ts +360 -0
- package/dist/testing/actor-test.js +337 -0
- package/dist/testing/cluster.d.ts +93 -0
- package/dist/testing/cluster.js +296 -0
- package/dist/testing/conformance/access.d.ts +5 -0
- package/dist/testing/conformance/access.js +531 -0
- package/dist/testing/conformance/admission.d.ts +11 -0
- package/dist/testing/conformance/admission.js +182 -0
- package/dist/testing/conformance/adoption.d.ts +2 -0
- package/dist/testing/conformance/adoption.js +1019 -0
- package/dist/testing/conformance/assertions.d.ts +202 -0
- package/dist/testing/conformance/assertions.js +849 -0
- package/dist/testing/conformance/batches.d.ts +41 -0
- package/dist/testing/conformance/batches.js +388 -0
- package/dist/testing/conformance/blobs.d.ts +14 -0
- package/dist/testing/conformance/blobs.js +405 -0
- package/dist/testing/conformance/capacity.d.ts +8 -0
- package/dist/testing/conformance/capacity.js +433 -0
- package/dist/testing/conformance/client.d.ts +5 -0
- package/dist/testing/conformance/client.js +719 -0
- package/dist/testing/conformance/cold-serve.d.ts +16 -0
- package/dist/testing/conformance/cold-serve.js +254 -0
- package/dist/testing/conformance/connections/actors.d.ts +1143 -0
- package/dist/testing/conformance/connections/actors.js +180 -0
- package/dist/testing/conformance/connections/harness.d.ts +1754 -0
- package/dist/testing/conformance/connections/harness.js +142 -0
- package/dist/testing/conformance/connections/holders.d.ts +4 -0
- package/dist/testing/conformance/connections/holders.js +165 -0
- package/dist/testing/conformance/connections/lifecycle.d.ts +4 -0
- package/dist/testing/conformance/connections/lifecycle.js +113 -0
- package/dist/testing/conformance/connections/limits.d.ts +4 -0
- package/dist/testing/conformance/connections/limits.js +127 -0
- package/dist/testing/conformance/connections/reauthorization.d.ts +4 -0
- package/dist/testing/conformance/connections/reauthorization.js +170 -0
- package/dist/testing/conformance/connections/resync.d.ts +4 -0
- package/dist/testing/conformance/connections/resync.js +360 -0
- package/dist/testing/conformance/connections/takeover.d.ts +4 -0
- package/dist/testing/conformance/connections/takeover.js +287 -0
- package/dist/testing/conformance/connections.d.ts +4 -0
- package/dist/testing/conformance/connections.js +15 -0
- package/dist/testing/conformance/content-blobs.d.ts +16 -0
- package/dist/testing/conformance/content-blobs.js +534 -0
- package/dist/testing/conformance/counter.d.ts +16 -0
- package/dist/testing/conformance/counter.js +813 -0
- package/dist/testing/conformance/cron.d.ts +5 -0
- package/dist/testing/conformance/cron.js +665 -0
- package/dist/testing/conformance/drain.d.ts +26 -0
- package/dist/testing/conformance/drain.js +540 -0
- package/dist/testing/conformance/effect-control.d.ts +38 -0
- package/dist/testing/conformance/effect-control.js +1059 -0
- package/dist/testing/conformance/effects.d.ts +23 -0
- package/dist/testing/conformance/effects.js +432 -0
- package/dist/testing/conformance/events.d.ts +28 -0
- package/dist/testing/conformance/events.js +335 -0
- package/dist/testing/conformance/export.d.ts +2 -0
- package/dist/testing/conformance/export.js +368 -0
- package/dist/testing/conformance/fleet.d.ts +444 -0
- package/dist/testing/conformance/fleet.js +1009 -0
- package/dist/testing/conformance/groups.d.ts +7 -0
- package/dist/testing/conformance/groups.js +353 -0
- package/dist/testing/conformance/heap.d.ts +3 -0
- package/dist/testing/conformance/heap.js +149 -0
- package/dist/testing/conformance/http.d.ts +2809 -0
- package/dist/testing/conformance/http.js +1234 -0
- package/dist/testing/conformance/inspection-views.d.ts +8 -0
- package/dist/testing/conformance/inspection-views.js +272 -0
- package/dist/testing/conformance/inspector.d.ts +7 -0
- package/dist/testing/conformance/inspector.js +650 -0
- package/dist/testing/conformance/mint.d.ts +58 -0
- package/dist/testing/conformance/mint.js +692 -0
- package/dist/testing/conformance/multi-runner.d.ts +8 -0
- package/dist/testing/conformance/multi-runner.js +403 -0
- package/dist/testing/conformance/neki/plan.d.ts +19 -0
- package/dist/testing/conformance/neki/plan.js +30 -0
- package/dist/testing/conformance/observability.d.ts +3 -0
- package/dist/testing/conformance/observability.js +343 -0
- package/dist/testing/conformance/offline.d.ts +4 -0
- package/dist/testing/conformance/offline.js +474 -0
- package/dist/testing/conformance/operator-harness.d.ts +57 -0
- package/dist/testing/conformance/operator-harness.js +78 -0
- package/dist/testing/conformance/operator.d.ts +3 -0
- package/dist/testing/conformance/operator.js +379 -0
- package/dist/testing/conformance/outbox-cross-shard.d.ts +14 -0
- package/dist/testing/conformance/outbox-cross-shard.js +160 -0
- package/dist/testing/conformance/outbox.d.ts +36 -0
- package/dist/testing/conformance/outbox.js +437 -0
- package/dist/testing/conformance/payload-migrations.d.ts +3 -0
- package/dist/testing/conformance/payload-migrations.js +612 -0
- package/dist/testing/conformance/pipeline.d.ts +3 -0
- package/dist/testing/conformance/pipeline.js +1040 -0
- package/dist/testing/conformance/placement.d.ts +22 -0
- package/dist/testing/conformance/placement.js +539 -0
- package/dist/testing/conformance/platform.d.ts +40 -0
- package/dist/testing/conformance/platform.js +101 -0
- package/dist/testing/conformance/progress.d.ts +39 -0
- package/dist/testing/conformance/progress.js +666 -0
- package/dist/testing/conformance/properties.d.ts +6 -0
- package/dist/testing/conformance/properties.js +295 -0
- package/dist/testing/conformance/protocols.d.ts +4 -0
- package/dist/testing/conformance/protocols.js +618 -0
- package/dist/testing/conformance/provider/advertise.d.ts +21 -0
- package/dist/testing/conformance/provider/advertise.js +44 -0
- package/dist/testing/conformance/read-your-writes.d.ts +20 -0
- package/dist/testing/conformance/read-your-writes.js +192 -0
- package/dist/testing/conformance/reducers.d.ts +8 -0
- package/dist/testing/conformance/reducers.js +258 -0
- package/dist/testing/conformance/relay.d.ts +42 -0
- package/dist/testing/conformance/relay.js +1115 -0
- package/dist/testing/conformance/restore.d.ts +43 -0
- package/dist/testing/conformance/restore.js +341 -0
- package/dist/testing/conformance/retention.d.ts +14 -0
- package/dist/testing/conformance/retention.js +463 -0
- package/dist/testing/conformance/rls.d.ts +24 -0
- package/dist/testing/conformance/rls.js +576 -0
- package/dist/testing/conformance/simulation.d.ts +31 -0
- package/dist/testing/conformance/simulation.js +167 -0
- package/dist/testing/conformance/single-shard.d.ts +2 -0
- package/dist/testing/conformance/single-shard.js +125 -0
- package/dist/testing/conformance/singleton.d.ts +3 -0
- package/dist/testing/conformance/singleton.js +293 -0
- package/dist/testing/conformance/statements.d.ts +60 -0
- package/dist/testing/conformance/statements.js +108 -0
- package/dist/testing/conformance/streams.d.ts +7 -0
- package/dist/testing/conformance/streams.js +280 -0
- package/dist/testing/conformance/subscriptions/access.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/access.js +98 -0
- package/dist/testing/conformance/subscriptions/actors.d.ts +4910 -0
- package/dist/testing/conformance/subscriptions/actors.js +372 -0
- package/dist/testing/conformance/subscriptions/cluster.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/cluster.js +89 -0
- package/dist/testing/conformance/subscriptions/delivery.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/delivery.js +253 -0
- package/dist/testing/conformance/subscriptions/deploys.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/deploys.js +162 -0
- package/dist/testing/conformance/subscriptions/epochs.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/epochs.js +169 -0
- package/dist/testing/conformance/subscriptions/failures.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/failures.js +108 -0
- package/dist/testing/conformance/subscriptions/feeds.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/feeds.js +218 -0
- package/dist/testing/conformance/subscriptions/harness.d.ts +124 -0
- package/dist/testing/conformance/subscriptions/harness.js +143 -0
- package/dist/testing/conformance/subscriptions/operator.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/operator.js +128 -0
- package/dist/testing/conformance/subscriptions/retention.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/retention.js +288 -0
- package/dist/testing/conformance/subscriptions/routing.d.ts +4 -0
- package/dist/testing/conformance/subscriptions/routing.js +179 -0
- package/dist/testing/conformance/subscriptions.d.ts +4 -0
- package/dist/testing/conformance/subscriptions.js +19 -0
- package/dist/testing/conformance/tables.d.ts +125 -0
- package/dist/testing/conformance/tables.js +664 -0
- package/dist/testing/conformance/transports/actors.d.ts +1907 -0
- package/dist/testing/conformance/transports/actors.js +163 -0
- package/dist/testing/conformance/transports/client.d.ts +3 -0
- package/dist/testing/conformance/transports/client.js +237 -0
- package/dist/testing/conformance/transports/feeds.d.ts +3 -0
- package/dist/testing/conformance/transports/feeds.js +232 -0
- package/dist/testing/conformance/transports/harness.d.ts +93 -0
- package/dist/testing/conformance/transports/harness.js +84 -0
- package/dist/testing/conformance/transports/streams.d.ts +3 -0
- package/dist/testing/conformance/transports/streams.js +96 -0
- package/dist/testing/conformance/transports/websocket.d.ts +3 -0
- package/dist/testing/conformance/transports/websocket.js +384 -0
- package/dist/testing/conformance/transports/wire.d.ts +141 -0
- package/dist/testing/conformance/transports/wire.js +204 -0
- package/dist/testing/conformance/transports.d.ts +3 -0
- package/dist/testing/conformance/transports.js +11 -0
- package/dist/testing/conformance/watch.d.ts +59 -0
- package/dist/testing/conformance/watch.js +879 -0
- package/dist/testing/conformance/workflow-engine.d.ts +97 -0
- package/dist/testing/conformance/workflow-engine.js +253 -0
- package/dist/testing/conformance/workflow-versions.d.ts +3 -0
- package/dist/testing/conformance/workflow-versions.js +545 -0
- package/dist/testing/conformance/workflows/access.d.ts +4 -0
- package/dist/testing/conformance/workflows/access.js +147 -0
- package/dist/testing/conformance/workflows/actors.d.ts +2317 -0
- package/dist/testing/conformance/workflows/actors.js +305 -0
- package/dist/testing/conformance/workflows/basics.d.ts +4 -0
- package/dist/testing/conformance/workflows/basics.js +144 -0
- package/dist/testing/conformance/workflows/engine.d.ts +4 -0
- package/dist/testing/conformance/workflows/engine.js +80 -0
- package/dist/testing/conformance/workflows/guards.d.ts +4 -0
- package/dist/testing/conformance/workflows/guards.js +172 -0
- package/dist/testing/conformance/workflows/harness.d.ts +14 -0
- package/dist/testing/conformance/workflows/harness.js +44 -0
- package/dist/testing/conformance/workflows/recovery.d.ts +4 -0
- package/dist/testing/conformance/workflows/recovery.js +181 -0
- package/dist/testing/conformance/workflows/starts.d.ts +4 -0
- package/dist/testing/conformance/workflows/starts.js +57 -0
- package/dist/testing/conformance/workflows/suspension.d.ts +4 -0
- package/dist/testing/conformance/workflows/suspension.js +106 -0
- package/dist/testing/conformance/workflows/waits.d.ts +4 -0
- package/dist/testing/conformance/workflows/waits.js +284 -0
- package/dist/testing/conformance/workflows.d.ts +4 -0
- package/dist/testing/conformance/workflows.js +19 -0
- package/dist/testing/conformance.d.ts +330 -0
- package/dist/testing/conformance.js +364 -0
- package/dist/testing/database.d.ts +31 -0
- package/dist/testing/database.js +70 -0
- package/dist/testing/foundation.d.ts +41 -0
- package/dist/testing/foundation.js +517 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.js +12 -0
- package/dist/testing/property.d.ts +40 -0
- package/dist/testing/property.js +95 -0
- package/dist/testing/simulate-cluster.d.ts +112 -0
- package/dist/testing/simulate-cluster.js +257 -0
- package/dist/testing/simulate.d.ts +90 -0
- package/dist/testing/simulate.js +127 -0
- package/package.json +1 -0
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
import { connect, createServer } from "node:net";
|
|
2
|
+
import { bigint, boolean, pgTable, text } from "drizzle-orm/pg-core";
|
|
3
|
+
import { Cause, Clock, Equal, Crypto, Option, Predicate, Stream, Effect, Exit, Fiber, Layer, Redacted, Schedule, Schema, } from "effect";
|
|
4
|
+
import { SqlClient } from "effect/sql";
|
|
5
|
+
import { Actor, Fleet, User } from "../../index.js";
|
|
6
|
+
import { principal } from "../../identity/caller.js";
|
|
7
|
+
import { ActorError, Unauthorized } from "../../errors/actor.js";
|
|
8
|
+
import { fleetClient } from "../../client/fleet.js";
|
|
9
|
+
import { FleetHooks } from "../../runtime/fleet/maintainer.js";
|
|
10
|
+
import { grants } from "./rls.js";
|
|
11
|
+
import { serveHttp, httpSuite } from "./http.js";
|
|
12
|
+
import { Database } from "../../runtime/layer.js";
|
|
13
|
+
import { checkFleet } from "../../runtime/fleet/checks.js";
|
|
14
|
+
import { rebuildFleetView, setupFleet } from "../../runtime/fleet/setup.js";
|
|
15
|
+
import { FLEET_SLOT, LOCK_RETRY } from "../../runtime/fleet/maintainer.js";
|
|
16
|
+
import { tenantRoutingKey } from "../../runtime/storage/codec.js";
|
|
17
|
+
import { backfillAdoption } from "../../runtime/adoption/backfill.js";
|
|
18
|
+
import { observeAdoption } from "../../runtime/adoption/observe.js";
|
|
19
|
+
import { migrate } from "../../runtime/database/migrations.js";
|
|
20
|
+
import { TurnPoolSettings } from "../../runtime/turn/pipeline.js";
|
|
21
|
+
import { ActorTest } from "../actor-test.js";
|
|
22
|
+
import { ActorCluster } from "../cluster.js";
|
|
23
|
+
import { spawnFixture } from "./platform.js";
|
|
24
|
+
/** The source table every fleet case reads. */
|
|
25
|
+
export const fleetOrders = Actor.table(pgTable("fleet_orders", {
|
|
26
|
+
id: text("id").primaryKey(),
|
|
27
|
+
status: text("status").notNull(),
|
|
28
|
+
region: text("region").notNull(),
|
|
29
|
+
amountCents: bigint("amount_cents", { mode: "bigint" }).notNull(),
|
|
30
|
+
archived: boolean("archived").notNull(),
|
|
31
|
+
}));
|
|
32
|
+
const legacyInvoices = pgTable("fleet_legacy_invoices", {
|
|
33
|
+
id: text("id").primaryKey(),
|
|
34
|
+
orgId: text("org_id").notNull(),
|
|
35
|
+
accountId: text("account_id").notNull(),
|
|
36
|
+
status: text("status").notNull(),
|
|
37
|
+
amount: bigint("amount", { mode: "bigint" }).notNull(),
|
|
38
|
+
});
|
|
39
|
+
const invoiceRows = Actor.table(legacyInvoices, {
|
|
40
|
+
owner: { tenant: legacyInvoices.orgId, actor: legacyInvoices.accountId },
|
|
41
|
+
});
|
|
42
|
+
const LegacyAccount = Actor.make("FleetLegacyAccount", {
|
|
43
|
+
key: Schema.String,
|
|
44
|
+
tables: [invoiceRows],
|
|
45
|
+
api: {},
|
|
46
|
+
});
|
|
47
|
+
/** Invoices of an adopted table, by status. */
|
|
48
|
+
const InvoicesByStatus = Fleet.view("InvoicesByStatus", {
|
|
49
|
+
from: invoiceRows,
|
|
50
|
+
groupBy: ["status"],
|
|
51
|
+
select: { invoices: Fleet.count(), total: Fleet.sum("amount") },
|
|
52
|
+
});
|
|
53
|
+
const spread = Actor.table(pgTable("fleet_spread", { id: text("id").primaryKey(), kind: text("kind").notNull() }));
|
|
54
|
+
/** Orders not archived, by status, with every aggregate kind. */
|
|
55
|
+
export const OrdersByStatus = Fleet.view("OrdersByStatus", {
|
|
56
|
+
from: fleetOrders,
|
|
57
|
+
where: { archived: false },
|
|
58
|
+
groupBy: ["status"],
|
|
59
|
+
select: {
|
|
60
|
+
orders: Fleet.count(),
|
|
61
|
+
total: Fleet.sum("amountCents"),
|
|
62
|
+
low: Fleet.min("amountCents"),
|
|
63
|
+
high: Fleet.max("amountCents"),
|
|
64
|
+
mean: Fleet.avg("amountCents"),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
/** Every order, archived or not, by region and status. */
|
|
68
|
+
export const OrdersByRegion = Fleet.view("OrdersByRegion", {
|
|
69
|
+
from: fleetOrders,
|
|
70
|
+
groupBy: ["region", "status"],
|
|
71
|
+
select: { orders: Fleet.count(), total: Fleet.sum("amountCents") },
|
|
72
|
+
});
|
|
73
|
+
/** The views an entry module exports for `durable fleet setup`. */
|
|
74
|
+
export const fleet = [OrdersByStatus, OrdersByRegion];
|
|
75
|
+
/** `OrdersByStatus` with its filter dropped: the same table, another definition. */
|
|
76
|
+
const OrdersByStatusAll = Fleet.view("OrdersByStatus", {
|
|
77
|
+
from: fleetOrders,
|
|
78
|
+
groupBy: ["status"],
|
|
79
|
+
select: {
|
|
80
|
+
orders: Fleet.count(),
|
|
81
|
+
total: Fleet.sum("amountCents"),
|
|
82
|
+
low: Fleet.min("amountCents"),
|
|
83
|
+
high: Fleet.max("amountCents"),
|
|
84
|
+
mean: Fleet.avg("amountCents"),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const SpreadByKind = Fleet.view("SpreadByKind", {
|
|
88
|
+
from: spread,
|
|
89
|
+
groupBy: ["kind"],
|
|
90
|
+
select: { rows: Fleet.count() },
|
|
91
|
+
});
|
|
92
|
+
const Order = Schema.Struct({
|
|
93
|
+
id: Schema.String,
|
|
94
|
+
status: Schema.String,
|
|
95
|
+
region: Schema.String,
|
|
96
|
+
amountCents: Schema.BigInt,
|
|
97
|
+
archived: Schema.Boolean,
|
|
98
|
+
});
|
|
99
|
+
class Refused extends Schema.TaggedError()("Refused", {}) {
|
|
100
|
+
}
|
|
101
|
+
const Put = Actor.command("Put", { payload: Order });
|
|
102
|
+
const Remove = Actor.command("Remove", { payload: Schema.String });
|
|
103
|
+
const PutThenRefuse = Actor.command("PutThenRefuse", { payload: Order, error: Refused });
|
|
104
|
+
/** A tenant-placed actor owning `fleet_orders`. */
|
|
105
|
+
const FleetOrder = Actor.make("FleetOrder", {
|
|
106
|
+
key: Schema.String,
|
|
107
|
+
tables: [fleetOrders],
|
|
108
|
+
api: { Put, Remove, PutThenRefuse },
|
|
109
|
+
policy: { reauthorizeEvery: "1 second" },
|
|
110
|
+
});
|
|
111
|
+
/** Subjects whose access the fleet cases revoke; the runtime's `authorize` refuses them. */
|
|
112
|
+
const revoked = new Set();
|
|
113
|
+
/** Allows every caller but a revoked subject, as an application's hook would. */
|
|
114
|
+
const authorize = (request) => Effect.sync(() => Option.match(principal(request.caller), {
|
|
115
|
+
onNone: () => true,
|
|
116
|
+
onSome: ({ subject }) => !revoked.has(subject),
|
|
117
|
+
}));
|
|
118
|
+
Actor.make("FleetSpread", { key: Schema.String, placement: "actor", tables: [spread], api: {} });
|
|
119
|
+
const put = (order) => Effect.gen(function* () {
|
|
120
|
+
yield* (yield* FleetOrder.Turn).rows(fleetOrders).upsert(order);
|
|
121
|
+
});
|
|
122
|
+
/** The command layer of `FleetOrder`. */
|
|
123
|
+
export const FleetOrderLive = FleetOrder.toLayer(Effect.succeed({
|
|
124
|
+
Put: put,
|
|
125
|
+
Remove: (id) => Effect.gen(function* () {
|
|
126
|
+
yield* (yield* FleetOrder.Turn).rows(fleetOrders).delete().where({ id });
|
|
127
|
+
}),
|
|
128
|
+
PutThenRefuse: (order) => put(order).pipe(Effect.andThen(Refused.make({}))),
|
|
129
|
+
}));
|
|
130
|
+
/** What drizzle-kit generates for `fleet_orders` and both derived tables, then the recompute indexes. */
|
|
131
|
+
const fleetDdl = [
|
|
132
|
+
`CREATE TABLE "fleet_orders" (
|
|
133
|
+
"routing_key" bigint,
|
|
134
|
+
"tenant_id" text,
|
|
135
|
+
"actor_id" text,
|
|
136
|
+
"id" text,
|
|
137
|
+
"status" text NOT NULL,
|
|
138
|
+
"region" text NOT NULL,
|
|
139
|
+
"amount_cents" bigint NOT NULL,
|
|
140
|
+
"archived" boolean NOT NULL,
|
|
141
|
+
CONSTRAINT "fleet_orders_pkey" PRIMARY KEY("routing_key","tenant_id","actor_id","id")
|
|
142
|
+
);
|
|
143
|
+
`,
|
|
144
|
+
`ALTER TABLE "fleet_orders" ENABLE ROW LEVEL SECURITY;`,
|
|
145
|
+
`CREATE TABLE "fleet_orders_by_status" (
|
|
146
|
+
"tenant_id" text,
|
|
147
|
+
"status" text,
|
|
148
|
+
"orders" bigint NOT NULL,
|
|
149
|
+
"total" bigint NOT NULL,
|
|
150
|
+
"low" bigint,
|
|
151
|
+
"high" bigint,
|
|
152
|
+
"mean" double precision NOT NULL,
|
|
153
|
+
"as_of" numeric NOT NULL,
|
|
154
|
+
CONSTRAINT "fleet_orders_by_status_pkey" PRIMARY KEY("tenant_id","status")
|
|
155
|
+
);
|
|
156
|
+
`,
|
|
157
|
+
`ALTER TABLE "fleet_orders_by_status" ENABLE ROW LEVEL SECURITY;`,
|
|
158
|
+
`CREATE TABLE "fleet_orders_by_region" (
|
|
159
|
+
"tenant_id" text,
|
|
160
|
+
"region" text,
|
|
161
|
+
"status" text,
|
|
162
|
+
"orders" bigint NOT NULL,
|
|
163
|
+
"total" bigint NOT NULL,
|
|
164
|
+
"as_of" numeric NOT NULL,
|
|
165
|
+
CONSTRAINT "fleet_orders_by_region_pkey" PRIMARY KEY("tenant_id","region","status")
|
|
166
|
+
);
|
|
167
|
+
`,
|
|
168
|
+
`ALTER TABLE "fleet_orders_by_region" ENABLE ROW LEVEL SECURITY;`,
|
|
169
|
+
`CREATE POLICY "durable_tenant" ON "fleet_orders" AS PERMISSIVE FOR ALL TO public USING (tenant_id = current_setting('durable.tenant', true)) WITH CHECK (tenant_id = current_setting('durable.tenant', true));`,
|
|
170
|
+
`CREATE POLICY "durable_tenant" ON "fleet_orders_by_status" AS PERMISSIVE FOR ALL TO public USING (tenant_id = current_setting('durable.tenant', true)) WITH CHECK (tenant_id = current_setting('durable.tenant', true));`,
|
|
171
|
+
`CREATE POLICY "durable_tenant" ON "fleet_orders_by_region" AS PERMISSIVE FOR ALL TO public USING (tenant_id = current_setting('durable.tenant', true)) WITH CHECK (tenant_id = current_setting('durable.tenant', true));`,
|
|
172
|
+
`CREATE INDEX fleet_orders_by_status_recompute ON fleet_orders (routing_key, tenant_id, status)`,
|
|
173
|
+
`CREATE INDEX fleet_orders_by_region_recompute ON fleet_orders (routing_key, tenant_id, region, status)`,
|
|
174
|
+
];
|
|
175
|
+
const record = (model, tenant, written) => {
|
|
176
|
+
const orders = model.get(tenant) ?? new Map();
|
|
177
|
+
orders.set(written.id, written);
|
|
178
|
+
model.set(tenant, orders);
|
|
179
|
+
};
|
|
180
|
+
const forget = (model, tenant, id) => {
|
|
181
|
+
model.get(tenant)?.delete(id);
|
|
182
|
+
};
|
|
183
|
+
const byValues = (a, b) => JSON.stringify(Object.values(a)) < JSON.stringify(Object.values(b)) ? -1 : 1;
|
|
184
|
+
const groupRows = (model, admit, keyOf, project) => [...model.entries()]
|
|
185
|
+
.flatMap(([tenant, orders]) => {
|
|
186
|
+
const groups = new Map();
|
|
187
|
+
for (const row of orders.values())
|
|
188
|
+
if (admit(row)) {
|
|
189
|
+
const key = JSON.stringify([tenant, ...keyOf(row)]);
|
|
190
|
+
groups.set(key, [...(groups.get(key) ?? []), row]);
|
|
191
|
+
}
|
|
192
|
+
return [...groups.entries()].map(([key, members]) => project(JSON.parse(key), members));
|
|
193
|
+
})
|
|
194
|
+
.sort(byValues);
|
|
195
|
+
/** `OrdersByStatus` recomputed from the model; `archivedToo` drops its filter. */
|
|
196
|
+
const expectedByStatus = (model, archivedToo = false) => groupRows(model, (row) => archivedToo || !row.archived, (row) => [row.status], ([tenant = "", status = ""], orders) => {
|
|
197
|
+
const amounts = orders.map((row) => row.amountCents);
|
|
198
|
+
const total = amounts.reduce((sum, amount) => sum + amount, 0n);
|
|
199
|
+
return {
|
|
200
|
+
tenant_id: tenant,
|
|
201
|
+
status,
|
|
202
|
+
orders: orders.length,
|
|
203
|
+
total: String(total),
|
|
204
|
+
low: String(amounts.reduce((low, amount) => (amount < low ? amount : low))),
|
|
205
|
+
high: String(amounts.reduce((high, amount) => (amount > high ? amount : high))),
|
|
206
|
+
mean: Number(total) / orders.length,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
/** `OrdersByRegion` recomputed from the model. */
|
|
210
|
+
const expectedByRegion = (model) => groupRows(model, () => true, (row) => [row.region, row.status], ([tenant = "", region = "", status = ""], orders) => ({
|
|
211
|
+
tenant_id: tenant,
|
|
212
|
+
region,
|
|
213
|
+
status,
|
|
214
|
+
orders: orders.length,
|
|
215
|
+
total: String(orders.reduce((sum, row) => sum + row.amountCents, 0n)),
|
|
216
|
+
}));
|
|
217
|
+
/** The derived rows of `OrdersByStatus`, in the order `expectedByStatus` sorts. */
|
|
218
|
+
const byStatusRows = (sql) => sql `SELECT tenant_id, status, orders::int AS orders, total::text AS total,
|
|
219
|
+
low::text AS low, high::text AS high, mean
|
|
220
|
+
FROM fleet_orders_by_status`.pipe(Effect.map((rows) => [...rows].sort(byValues)));
|
|
221
|
+
/** The derived rows of `OrdersByRegion`, in the order `expectedByRegion` sorts. */
|
|
222
|
+
const byRegionRows = (sql) => sql `SELECT tenant_id, region, status, orders::int AS orders, total::text AS total
|
|
223
|
+
FROM fleet_orders_by_region`.pipe(Effect.map((rows) => [...rows].sort(byValues)));
|
|
224
|
+
/** Waits until `read` returns `expected`, then asserts it, so a timeout still shows the difference. */
|
|
225
|
+
const settle = (expect, read, expected, duration = "20 seconds") => Effect.gen(function* () {
|
|
226
|
+
const last = yield* read.pipe(Effect.orDie, Effect.repeat({
|
|
227
|
+
schedule: Schedule.spaced("50 millis"),
|
|
228
|
+
until: (value) => Equal.equals(value, expected),
|
|
229
|
+
}), Effect.timeoutOption(duration));
|
|
230
|
+
expect(Option.isSome(last) ? last.value : yield* Effect.orDie(read)).toEqual(expected);
|
|
231
|
+
});
|
|
232
|
+
const viewStates = (sql) => sql `SELECT view_name, status, applied_lsn::text AS applied_lsn, last_error
|
|
233
|
+
FROM actor_fleet_views ORDER BY view_name`.pipe(Effect.orDie);
|
|
234
|
+
/** Drops the slot, retrying while a peek holds it, so the database can be dropped. */
|
|
235
|
+
const dropSlot = (sql) => sql `SELECT count(*)::int AS dropped FROM (SELECT pg_drop_replication_slot(slot_name)
|
|
236
|
+
FROM pg_replication_slots WHERE slot_name = ${FLEET_SLOT} AND database = current_database()) d`.pipe(Effect.retry({ times: 200, schedule: Schedule.spaced("50 millis") }), Effect.orDie);
|
|
237
|
+
/**
|
|
238
|
+
* A fresh database with the fleet tables and their indexes, set up with
|
|
239
|
+
* `durable fleet setup` unless `setup` is false, and an administrative client
|
|
240
|
+
* to it. The slot is dropped when the scope closes, before the backend drops
|
|
241
|
+
* the database, which a logical slot would block.
|
|
242
|
+
*/
|
|
243
|
+
const fleetDatabase = (environment, options = {}) => Effect.gen(function* () {
|
|
244
|
+
const database = yield* environment.freshDatabase;
|
|
245
|
+
if (!Redacted.isRedacted(database))
|
|
246
|
+
return yield* Effect.die(new Error("Fleet cases need a Postgres database"));
|
|
247
|
+
const context = yield* Layer.build(Database.postgres({ url: database, maxConnections: 2 }));
|
|
248
|
+
const admin = SqlClient.SqlClient.pipe(Effect.provideContext(context), Effect.runSync);
|
|
249
|
+
for (const statement of fleetDdl)
|
|
250
|
+
yield* admin.unsafe(statement).pipe(Effect.orDie);
|
|
251
|
+
yield* Effect.addFinalizer(() => dropSlot(admin));
|
|
252
|
+
if (options.setup !== false)
|
|
253
|
+
yield* setupFleet([OrdersByStatus, OrdersByRegion]).pipe(Effect.provideService(SqlClient.SqlClient, admin), Effect.orDie);
|
|
254
|
+
return { database, admin };
|
|
255
|
+
});
|
|
256
|
+
/** Runs `body` on a cluster of `runners` maintaining `views` over a fleet database. */
|
|
257
|
+
const withFleet = (environment, options, body) => environment.run(Effect.gen(function* () {
|
|
258
|
+
const { database, admin } = yield* fleetDatabase(environment);
|
|
259
|
+
const context = yield* Layer.build(ActorTest.cluster({
|
|
260
|
+
database,
|
|
261
|
+
runners: options.runners ?? 1,
|
|
262
|
+
shardLockExpiration: "3 seconds",
|
|
263
|
+
actors: FleetOrderLive,
|
|
264
|
+
as: User.make({ subject: "alice" }),
|
|
265
|
+
authorize,
|
|
266
|
+
fleet: options.views ?? [OrdersByStatus, OrdersByRegion],
|
|
267
|
+
}));
|
|
268
|
+
return yield* body(admin).pipe(Effect.provideContext(context));
|
|
269
|
+
}));
|
|
270
|
+
const call = (runner, tenant, id, use) => Effect.gen(function* () {
|
|
271
|
+
const cluster = yield* ActorCluster;
|
|
272
|
+
const handle = FleetOrder.get(id).pipe(Effect.flatMap(use));
|
|
273
|
+
return yield* cluster.on(runner)(tenant === undefined ? handle : Actor.tenant(tenant)(handle));
|
|
274
|
+
});
|
|
275
|
+
/** Puts `row` through a turn of `tenant` (the cluster's own when undefined) and records it. */
|
|
276
|
+
const place = (model, row, tenant, runner = 0) => Effect.gen(function* () {
|
|
277
|
+
const cluster = yield* ActorCluster;
|
|
278
|
+
yield* call(runner, tenant, row.id, (handle) => handle.Put(row)).pipe(Effect.orDie);
|
|
279
|
+
record(model, tenant ?? cluster.tenant, row);
|
|
280
|
+
});
|
|
281
|
+
const remove = (model, id, tenant) => Effect.gen(function* () {
|
|
282
|
+
const cluster = yield* ActorCluster;
|
|
283
|
+
yield* call(0, tenant, id, (handle) => handle.Remove(id)).pipe(Effect.orDie);
|
|
284
|
+
forget(model, tenant ?? cluster.tenant, id);
|
|
285
|
+
});
|
|
286
|
+
const order = (id, status, amount, extra = {}) => ({
|
|
287
|
+
id,
|
|
288
|
+
status,
|
|
289
|
+
region: "eu",
|
|
290
|
+
amountCents: BigInt(amount),
|
|
291
|
+
archived: false,
|
|
292
|
+
...extra,
|
|
293
|
+
});
|
|
294
|
+
const settleBoth = (expect, admin, model) => Effect.gen(function* () {
|
|
295
|
+
yield* settle(expect, byStatusRows(admin), expectedByStatus(model));
|
|
296
|
+
yield* settle(expect, byRegionRows(admin), expectedByRegion(model));
|
|
297
|
+
});
|
|
298
|
+
const readyViews = (expect, admin, names) => settle(expect, viewStates(admin).pipe(Effect.map((rows) => rows.map(({ view_name, status }) => ({ view_name, status })))), names.map((view_name) => ({ view_name, status: "ready" })));
|
|
299
|
+
/** The number of sessions holding the fleet maintainer lock on this database. */
|
|
300
|
+
const lockHolders = (admin) => admin `
|
|
301
|
+
WITH k AS (SELECT hashtext('akter/fleet')::bigint AS key)
|
|
302
|
+
SELECT count(*)::int AS holders FROM pg_locks, k
|
|
303
|
+
WHERE locktype = 'advisory' AND granted AND objsubid = 1
|
|
304
|
+
AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
|
|
305
|
+
AND classid = ((key >> 32) & 4294967295)::text::oid
|
|
306
|
+
AND objid = (key & 4294967295)::text::oid`.pipe(Effect.map((rows) => rows[0].holders), Effect.orDie);
|
|
307
|
+
/** Writes `row` with plain SQL, as a writer outside any turn would, and records it. */
|
|
308
|
+
const write = (admin, model, tenant, row) => admin `INSERT INTO fleet_orders (routing_key, tenant_id, actor_id, id, status, region, amount_cents, archived)
|
|
309
|
+
VALUES (${String(tenantRoutingKey(tenant))}::bigint, ${tenant}, ${row.id}, ${row.id}, ${row.status},
|
|
310
|
+
${row.region}, ${String(row.amountCents)}::bigint, ${row.archived})
|
|
311
|
+
ON CONFLICT (routing_key, tenant_id, actor_id, id) DO UPDATE SET status = EXCLUDED.status,
|
|
312
|
+
region = EXCLUDED.region, amount_cents = EXCLUDED.amount_cents, archived = EXCLUDED.archived`.pipe(Effect.orDie, Effect.tap(() => Effect.sync(() => record(model, tenant, row))));
|
|
313
|
+
/** Starts the maintainer fixture in its own process; the scope SIGKILLs it if it still runs. */
|
|
314
|
+
const maintainerProcess = (database, crash) => spawnFixture(new URL("./crash/fleet/maintainer.ts", import.meta.url), {
|
|
315
|
+
FLEET_DATABASE_URL: Redacted.value(database),
|
|
316
|
+
FLEET_CRASH: crash ? "afterApply" : "none",
|
|
317
|
+
});
|
|
318
|
+
/** Resolves once `child` prints `line`, or dies when it exits first. */
|
|
319
|
+
const printed = (child, line) => child.stdout.pipe(Stream.decodeText(), Stream.splitLines, Stream.filter((printedLine) => printedLine === line), Stream.take(1), Stream.runCollect, Effect.map((lines) => lines.length === 1), Effect.orElseSucceed(() => false), Effect.timeoutOption("30 seconds"), Effect.flatMap((found) => Option.isSome(found) && found.value
|
|
320
|
+
? Effect.void
|
|
321
|
+
: Effect.die(new Error(`The maintainer process never printed ${line}`))));
|
|
322
|
+
const slotPosition = (admin) => admin `SELECT (confirmed_flush_lsn - '0/0')::text AS confirmed
|
|
323
|
+
FROM pg_replication_slots WHERE slot_name = ${FLEET_SLOT} AND database = current_database()`.pipe(Effect.map((rows) => rows[0].confirmed), Effect.orDie);
|
|
324
|
+
/**
|
|
325
|
+
* A TCP relay in front of Postgres for the turn pool that counts the
|
|
326
|
+
* statements clients send: each Bind of the extended protocol and each simple
|
|
327
|
+
* Query, read from the frontend message framing.
|
|
328
|
+
*/
|
|
329
|
+
const statementRelay = (url) => Effect.acquireRelease(Effect.callback((resume) => {
|
|
330
|
+
const sockets = new Set();
|
|
331
|
+
let statements = 0;
|
|
332
|
+
const server = createServer((client) => {
|
|
333
|
+
const upstream = connect({ host: url.hostname, port: Number(url.port || 5432) });
|
|
334
|
+
let pending = Buffer.alloc(0);
|
|
335
|
+
let started = false;
|
|
336
|
+
sockets.add(client);
|
|
337
|
+
sockets.add(upstream);
|
|
338
|
+
client.on("data", (chunk) => {
|
|
339
|
+
upstream.write(chunk);
|
|
340
|
+
pending = Buffer.concat([pending, chunk]);
|
|
341
|
+
for (;;) {
|
|
342
|
+
if (!started) {
|
|
343
|
+
if (pending.length < 8)
|
|
344
|
+
break;
|
|
345
|
+
const length = pending.readInt32BE(0);
|
|
346
|
+
if (pending.length < length)
|
|
347
|
+
break;
|
|
348
|
+
started = pending.readInt32BE(4) !== 80877103;
|
|
349
|
+
pending = pending.subarray(length);
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (pending.length < 5)
|
|
353
|
+
break;
|
|
354
|
+
const length = pending.readInt32BE(1) + 1;
|
|
355
|
+
if (pending.length < length)
|
|
356
|
+
break;
|
|
357
|
+
if (pending[0] === 0x42 || pending[0] === 0x51)
|
|
358
|
+
statements += 1;
|
|
359
|
+
pending = pending.subarray(length);
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
upstream.on("data", (chunk) => client.write(chunk));
|
|
363
|
+
const end = () => {
|
|
364
|
+
client.destroy();
|
|
365
|
+
upstream.destroy();
|
|
366
|
+
};
|
|
367
|
+
client.on("close", end);
|
|
368
|
+
upstream.on("close", end);
|
|
369
|
+
client.on("error", end);
|
|
370
|
+
upstream.on("error", end);
|
|
371
|
+
});
|
|
372
|
+
server.listen(0, "127.0.0.1", () => resume(Effect.succeed({
|
|
373
|
+
port: server.address().port,
|
|
374
|
+
statements: () => statements,
|
|
375
|
+
close: () => {
|
|
376
|
+
for (const socket of sockets)
|
|
377
|
+
socket.destroy();
|
|
378
|
+
server.close();
|
|
379
|
+
},
|
|
380
|
+
})));
|
|
381
|
+
}), (relay) => Effect.sync(relay.close));
|
|
382
|
+
/** The statements the turn pool sends for `turns` Put commands on a runtime maintaining `views`. */
|
|
383
|
+
const turnStatements = (database, views, turns) => Effect.scoped(Effect.gen(function* () {
|
|
384
|
+
const relay = yield* statementRelay(new URL(Redacted.value(database)));
|
|
385
|
+
const context = yield* Layer.build(FleetOrderLive.pipe(Layer.provideMerge(ActorTest.layer({ database, fleet: views, authorize: () => Effect.succeed(true) })), Layer.provide(Layer.succeed(TurnPoolSettings, {
|
|
386
|
+
stream: () => connect({ host: "127.0.0.1", port: relay.port, noDelay: true }),
|
|
387
|
+
}))));
|
|
388
|
+
return yield* Effect.gen(function* () {
|
|
389
|
+
const handle = yield* FleetOrder.get("counted");
|
|
390
|
+
yield* handle.Put(order("counted", "warm", 0)).pipe(Effect.orDie);
|
|
391
|
+
const before = relay.statements();
|
|
392
|
+
for (let index = 0; index < turns; index++)
|
|
393
|
+
yield* handle.Put(order("counted", `s${index}`, index)).pipe(Effect.orDie);
|
|
394
|
+
return relay.statements() - before;
|
|
395
|
+
}).pipe(Effect.provideContext(context));
|
|
396
|
+
}));
|
|
397
|
+
/** Pulls the next page of `pages` within `duration`, or dies naming what it waited for. */
|
|
398
|
+
const nextPage = (pages, waiting, duration = "20 seconds") => Effect.promise(() => pages.next()).pipe(Effect.timeoutOrElse({
|
|
399
|
+
duration,
|
|
400
|
+
orElse: () => Effect.die(new Error(`No fleet page arrived: ${waiting}`)),
|
|
401
|
+
}), Effect.flatMap((result) => result.done === true
|
|
402
|
+
? Effect.die(new Error(`The fleet subscription ended: ${waiting}`))
|
|
403
|
+
: Effect.succeed(result.value)));
|
|
404
|
+
/** The rows of `OrdersByStatus` a subscriber of `tenant` should see, in the served shape. */
|
|
405
|
+
const servedByStatus = (model, tenant) => expectedByStatus(model)
|
|
406
|
+
.filter((row) => row["tenant_id"] === tenant)
|
|
407
|
+
.map((row) => ({
|
|
408
|
+
status: row["status"],
|
|
409
|
+
orders: row["orders"],
|
|
410
|
+
total: Number(row["total"]),
|
|
411
|
+
low: Number(row["low"]),
|
|
412
|
+
high: Number(row["high"]),
|
|
413
|
+
mean: row["mean"],
|
|
414
|
+
}));
|
|
415
|
+
/** Every message in the cause chain of an exit's failure, joined, to find the database's own words. */
|
|
416
|
+
const causeMessages = (exit) => {
|
|
417
|
+
const messages = [];
|
|
418
|
+
let current = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined;
|
|
419
|
+
while (Predicate.hasProperty(current, "cause") || current instanceof Error) {
|
|
420
|
+
if (current instanceof Error)
|
|
421
|
+
messages.push(current.message);
|
|
422
|
+
current = Predicate.hasProperty(current, "reason")
|
|
423
|
+
? current.reason
|
|
424
|
+
: Predicate.hasProperty(current, "cause")
|
|
425
|
+
? current.cause
|
|
426
|
+
: undefined;
|
|
427
|
+
}
|
|
428
|
+
return messages.join("\n");
|
|
429
|
+
};
|
|
430
|
+
const refusal = (exit) => Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : "no refusal";
|
|
431
|
+
const statusOf = (rows, name) => rows.find(({ view_name }) => view_name === name);
|
|
432
|
+
/** Fleet views: maintenance from the change feed, failover, rebuilds, poison isolation, freshness, and startup refusals. */
|
|
433
|
+
export const fleetConformance = [
|
|
434
|
+
{
|
|
435
|
+
name: "fleet: 0025_fleet applies",
|
|
436
|
+
requiresIndependentConnections: true,
|
|
437
|
+
run: ({ expect, environment }) => environment.run(Effect.gen(function* () {
|
|
438
|
+
const sql = yield* SqlClient.SqlClient;
|
|
439
|
+
const columns = yield* sql `
|
|
440
|
+
SELECT column_name FROM information_schema.columns
|
|
441
|
+
WHERE table_name = 'actor_fleet_views' ORDER BY ordinal_position`;
|
|
442
|
+
expect(columns.map(({ column_name }) => column_name)).toEqual([
|
|
443
|
+
"view_name",
|
|
444
|
+
"source_schema",
|
|
445
|
+
"source_table",
|
|
446
|
+
"definition_hash",
|
|
447
|
+
"status",
|
|
448
|
+
"applied_lsn",
|
|
449
|
+
"updated_at_ms",
|
|
450
|
+
"last_error",
|
|
451
|
+
]);
|
|
452
|
+
expect(yield* sql `SELECT 1 AS applied FROM actor_migrations WHERE migration_id = 25`).toEqual([{ applied: 1 }]);
|
|
453
|
+
})),
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: "fleet: every aggregate equals a recompute over the source after inserts, updates, deletes, and the removal of a group's last row, per tenant",
|
|
457
|
+
requiresFreshDatabase: true,
|
|
458
|
+
requiresLogicalDecoding: true,
|
|
459
|
+
timeoutMs: 60_000,
|
|
460
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
461
|
+
const cluster = yield* ActorCluster;
|
|
462
|
+
const other = `${cluster.tenant}-other`;
|
|
463
|
+
const model = new Map();
|
|
464
|
+
yield* place(model, order("a", "new", 100));
|
|
465
|
+
yield* place(model, order("b", "new", 250, { region: "us" }));
|
|
466
|
+
yield* place(model, order("c", "paid", 40));
|
|
467
|
+
yield* place(model, order("a", "new", 900), other);
|
|
468
|
+
yield* place(model, order("z", "lost", 7), other);
|
|
469
|
+
yield* settleBoth(expect, admin, model);
|
|
470
|
+
yield* place(model, order("a", "new", 130));
|
|
471
|
+
yield* place(model, order("c", "paid", 40, { archived: true }));
|
|
472
|
+
yield* remove(model, "b");
|
|
473
|
+
yield* remove(model, "z", other);
|
|
474
|
+
yield* settleBoth(expect, admin, model);
|
|
475
|
+
expect((yield* byStatusRows(admin))
|
|
476
|
+
.filter((row) => row["tenant_id"] === other)
|
|
477
|
+
.map((row) => [row["status"], row["orders"]])).toEqual([["new", 1]]);
|
|
478
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
479
|
+
})),
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
name: "fleet: an update that moves a row between groups changes both groups",
|
|
483
|
+
requiresFreshDatabase: true,
|
|
484
|
+
requiresLogicalDecoding: true,
|
|
485
|
+
timeoutMs: 60_000,
|
|
486
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
487
|
+
const model = new Map();
|
|
488
|
+
yield* place(model, order("a", "new", 10));
|
|
489
|
+
yield* place(model, order("b", "new", 20));
|
|
490
|
+
yield* settleBoth(expect, admin, model);
|
|
491
|
+
yield* place(model, order("a", "shipped", 10));
|
|
492
|
+
yield* settleBoth(expect, admin, model);
|
|
493
|
+
expect((yield* byStatusRows(admin)).map((row) => [row["status"], row["orders"]])).toEqual([
|
|
494
|
+
["new", 1],
|
|
495
|
+
["shipped", 1],
|
|
496
|
+
]);
|
|
497
|
+
})),
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
name: "fleet: a rolled-back turn changes no view",
|
|
501
|
+
requiresFreshDatabase: true,
|
|
502
|
+
requiresLogicalDecoding: true,
|
|
503
|
+
timeoutMs: 60_000,
|
|
504
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
505
|
+
const model = new Map();
|
|
506
|
+
yield* place(model, order("a", "new", 10));
|
|
507
|
+
yield* settleBoth(expect, admin, model);
|
|
508
|
+
const refused = yield* call(0, undefined, "a", (handle) => handle.PutThenRefuse(order("a", "refused", 999))).pipe(Effect.exit);
|
|
509
|
+
expect(Exit.isFailure(refused)).toBe(true);
|
|
510
|
+
yield* place(model, order("marker", "later", 1));
|
|
511
|
+
yield* settleBoth(expect, admin, model);
|
|
512
|
+
expect((yield* byStatusRows(admin)).map((row) => row["status"])).toEqual(["later", "new"]);
|
|
513
|
+
})),
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
name: "fleet: a writer outside any turn changes the source and the view follows",
|
|
517
|
+
requiresFreshDatabase: true,
|
|
518
|
+
requiresLogicalDecoding: true,
|
|
519
|
+
timeoutMs: 60_000,
|
|
520
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
521
|
+
const cluster = yield* ActorCluster;
|
|
522
|
+
const model = new Map();
|
|
523
|
+
yield* place(model, order("a", "new", 10));
|
|
524
|
+
yield* settleBoth(expect, admin, model);
|
|
525
|
+
yield* admin `INSERT INTO fleet_orders (routing_key, tenant_id, actor_id, id, status, region,
|
|
526
|
+
amount_cents, archived)
|
|
527
|
+
VALUES (${String(tenantRoutingKey(cluster.tenant))}::bigint, ${cluster.tenant}, 'legacy',
|
|
528
|
+
'l1', 'new', 'eu', 5, false)`.pipe(Effect.orDie);
|
|
529
|
+
record(model, cluster.tenant, order("l1", "new", 5));
|
|
530
|
+
yield* settleBoth(expect, admin, model);
|
|
531
|
+
})),
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
name: "fleet: a legacy writer's change to an adopted table appears in the view",
|
|
535
|
+
requiresFreshDatabase: true,
|
|
536
|
+
requiresLogicalDecoding: true,
|
|
537
|
+
requiresIndependentConnections: true,
|
|
538
|
+
timeoutMs: 60_000,
|
|
539
|
+
run: ({ expect, environment }) => environment.run(Effect.gen(function* () {
|
|
540
|
+
const { database, admin } = yield* fleetDatabase(environment, { setup: false });
|
|
541
|
+
const tenant = "legacy-tenant";
|
|
542
|
+
const on = Effect.provideService(SqlClient.SqlClient, admin);
|
|
543
|
+
yield* admin `CREATE TABLE fleet_legacy_invoices (id text PRIMARY KEY, org_id text NOT NULL,
|
|
544
|
+
account_id text NOT NULL, status text NOT NULL, amount bigint NOT NULL)`.pipe(Effect.orDie);
|
|
545
|
+
yield* admin `CREATE TABLE fleet_invoices_by_status (tenant_id text, status text,
|
|
546
|
+
invoices bigint NOT NULL, total bigint NOT NULL, as_of numeric NOT NULL,
|
|
547
|
+
PRIMARY KEY (tenant_id, status))`.pipe(Effect.orDie);
|
|
548
|
+
yield* admin `INSERT INTO fleet_legacy_invoices VALUES
|
|
549
|
+
('i1', ${tenant}, 'acct-a', 'open', 10), ('i2', ${tenant}, 'acct-b', 'open', 20)`.pipe(Effect.orDie);
|
|
550
|
+
yield* migrate.pipe(on, Effect.orDie);
|
|
551
|
+
yield* observeAdoption([LegacyAccount]).pipe(on, Effect.orDie);
|
|
552
|
+
yield* backfillAdoption([LegacyAccount], {}).pipe(on, Effect.orDie);
|
|
553
|
+
yield* admin `CREATE INDEX fleet_legacy_invoices_recompute
|
|
554
|
+
ON fleet_legacy_invoices (routing_key, org_id, status)`.pipe(Effect.orDie);
|
|
555
|
+
yield* setupFleet([InvoicesByStatus]).pipe(on, Effect.orDie);
|
|
556
|
+
yield* Layer.build(ActorTest.layer({ database, fleet: [InvoicesByStatus] }));
|
|
557
|
+
const rows = admin `
|
|
558
|
+
SELECT tenant_id, status, invoices::int AS invoices, total::text AS total
|
|
559
|
+
FROM fleet_invoices_by_status ORDER BY status`.pipe(Effect.orDie);
|
|
560
|
+
yield* settle(expect, rows, [
|
|
561
|
+
{ tenant_id: tenant, status: "open", invoices: 2, total: "30" },
|
|
562
|
+
]);
|
|
563
|
+
yield* admin `UPDATE fleet_legacy_invoices SET status = 'paid', amount = 25 WHERE id = 'i2'`.pipe(Effect.orDie);
|
|
564
|
+
yield* settle(expect, rows, [
|
|
565
|
+
{ tenant_id: tenant, status: "open", invoices: 1, total: "10" },
|
|
566
|
+
{ tenant_id: tenant, status: "paid", invoices: 1, total: "25" },
|
|
567
|
+
]);
|
|
568
|
+
})),
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "fleet: a maintainer killed between applying a batch and advancing the slot replays it and the view still equals the recompute",
|
|
572
|
+
requiresFreshDatabase: true,
|
|
573
|
+
requiresLogicalDecoding: true,
|
|
574
|
+
requiresIndependentConnections: true,
|
|
575
|
+
timeoutMs: 120_000,
|
|
576
|
+
run: ({ expect, environment }) => environment.run(Effect.gen(function* () {
|
|
577
|
+
const { database, admin } = yield* fleetDatabase(environment);
|
|
578
|
+
const model = new Map();
|
|
579
|
+
const before = yield* slotPosition(admin);
|
|
580
|
+
yield* write(admin, model, "t1", order("a", "new", 10));
|
|
581
|
+
yield* write(admin, model, "t1", order("b", "new", 20));
|
|
582
|
+
yield* write(admin, model, "t2", order("a", "paid", 30, { region: "us" }));
|
|
583
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
584
|
+
const crashing = yield* maintainerProcess(database, true);
|
|
585
|
+
yield* printed(crashing, "APPLIED");
|
|
586
|
+
expect(yield* byStatusRows(admin)).toEqual(expectedByStatus(model));
|
|
587
|
+
expect(yield* slotPosition(admin)).toBe(before);
|
|
588
|
+
const exit = yield* crashing.kill;
|
|
589
|
+
expect(exit.code).not.toBe(0);
|
|
590
|
+
expect(exit.signal).toBe("SIGKILL");
|
|
591
|
+
}));
|
|
592
|
+
yield* write(admin, model, "t1", order("a", "shipped", 10));
|
|
593
|
+
yield* admin `DELETE FROM fleet_orders WHERE tenant_id = 't2'`.pipe(Effect.orDie);
|
|
594
|
+
forget(model, "t2", "a");
|
|
595
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
596
|
+
yield* maintainerProcess(database, false);
|
|
597
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
598
|
+
yield* settleBoth(expect, admin, model);
|
|
599
|
+
expect(BigInt(yield* slotPosition(admin)) > BigInt(before)).toBe(true);
|
|
600
|
+
}));
|
|
601
|
+
})),
|
|
602
|
+
},
|
|
603
|
+
{
|
|
604
|
+
name: "fleet: of two runners exactly one maintains, and the other takes over within the retry interval after the first is killed",
|
|
605
|
+
requiresFreshDatabase: true,
|
|
606
|
+
requiresLogicalDecoding: true,
|
|
607
|
+
requiresIndependentConnections: true,
|
|
608
|
+
timeoutMs: 90_000,
|
|
609
|
+
run: ({ expect, environment }) => withFleet(environment, { runners: 2 }, (admin) => Effect.gen(function* () {
|
|
610
|
+
const cluster = yield* ActorCluster;
|
|
611
|
+
const model = new Map();
|
|
612
|
+
const takeover = `${Number(LOCK_RETRY.split(" ")[0]) + 3} seconds`;
|
|
613
|
+
yield* place(model, order("a", "new", 1));
|
|
614
|
+
yield* settleBoth(expect, admin, model);
|
|
615
|
+
expect(yield* lockHolders(admin)).toBe(1);
|
|
616
|
+
for (const [killed, survivor] of [
|
|
617
|
+
[0, 1],
|
|
618
|
+
[1, 0],
|
|
619
|
+
]) {
|
|
620
|
+
yield* cluster.kill(killed);
|
|
621
|
+
yield* place(model, order(`after-${killed}`, "new", 2), undefined, survivor);
|
|
622
|
+
yield* settle(expect, lockHolders(admin), 1, takeover);
|
|
623
|
+
yield* settle(expect, byStatusRows(admin), expectedByStatus(model), takeover);
|
|
624
|
+
expect(yield* lockHolders(admin)).toBe(1);
|
|
625
|
+
if (killed === 0) {
|
|
626
|
+
yield* cluster.restart(0);
|
|
627
|
+
yield* settle(expect, lockHolders(admin), 1, takeover);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
})),
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
name: "fleet: a lost slot marks every view stale and a rebuild restores them while writes continue",
|
|
634
|
+
requiresFreshDatabase: true,
|
|
635
|
+
requiresLogicalDecoding: true,
|
|
636
|
+
timeoutMs: 90_000,
|
|
637
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
638
|
+
const model = new Map();
|
|
639
|
+
yield* place(model, order("a", "new", 10));
|
|
640
|
+
yield* settleBoth(expect, admin, model);
|
|
641
|
+
yield* dropSlot(admin);
|
|
642
|
+
yield* settle(expect, viewStates(admin).pipe(Effect.map((rows) => rows.map(({ status }) => status))), ["stale", "stale"]);
|
|
643
|
+
yield* place(model, order("gap", "new", 3));
|
|
644
|
+
const writing = yield* Effect.forEach(Array.from({ length: 20 }, (_, index) => index), (index) => place(model, order(`w${index % 5}`, index % 2 === 0 ? "new" : "paid", index)), { discard: true }).pipe(Effect.forkChild);
|
|
645
|
+
yield* setupFleet([OrdersByStatus, OrdersByRegion]).pipe(Effect.provideService(SqlClient.SqlClient, admin), Effect.orDie);
|
|
646
|
+
yield* Fiber.join(writing);
|
|
647
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
648
|
+
yield* settleBoth(expect, admin, model);
|
|
649
|
+
})),
|
|
650
|
+
},
|
|
651
|
+
{
|
|
652
|
+
name: "fleet: a changed definition marks its view stale, and a new view builds from the existing rows",
|
|
653
|
+
requiresFreshDatabase: true,
|
|
654
|
+
requiresLogicalDecoding: true,
|
|
655
|
+
timeoutMs: 90_000,
|
|
656
|
+
run: ({ expect, environment }) => environment.run(Effect.gen(function* () {
|
|
657
|
+
const { database, admin } = yield* fleetDatabase(environment);
|
|
658
|
+
const model = new Map();
|
|
659
|
+
const cluster = (views) => Layer.build(ActorTest.cluster({
|
|
660
|
+
database,
|
|
661
|
+
runners: 1,
|
|
662
|
+
shardLockExpiration: "3 seconds",
|
|
663
|
+
actors: FleetOrderLive,
|
|
664
|
+
authorize: () => Effect.succeed(true),
|
|
665
|
+
fleet: views,
|
|
666
|
+
}));
|
|
667
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
668
|
+
const context = yield* cluster([OrdersByStatus]);
|
|
669
|
+
yield* Effect.gen(function* () {
|
|
670
|
+
yield* place(model, order("a", "new", 10));
|
|
671
|
+
yield* place(model, order("b", "new", 20, { archived: true, region: "us" }));
|
|
672
|
+
yield* settle(expect, byStatusRows(admin), expectedByStatus(model));
|
|
673
|
+
}).pipe(Effect.provideContext(context));
|
|
674
|
+
}));
|
|
675
|
+
yield* checkFleet([OrdersByStatusAll, OrdersByRegion], undefined).pipe(Effect.provideService(SqlClient.SqlClient, admin));
|
|
676
|
+
expect((yield* viewStates(admin)).map(({ view_name, status }) => ({ view_name, status }))).toEqual([
|
|
677
|
+
{ view_name: "OrdersByRegion", status: "building" },
|
|
678
|
+
{ view_name: "OrdersByStatus", status: "stale" },
|
|
679
|
+
]);
|
|
680
|
+
yield* cluster([OrdersByStatusAll, OrdersByRegion]);
|
|
681
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
682
|
+
yield* settle(expect, byStatusRows(admin), expectedByStatus(model, true));
|
|
683
|
+
yield* settle(expect, byRegionRows(admin), expectedByRegion(model));
|
|
684
|
+
})),
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
name: "fleet: a poisoned view goes stale and the other views keep advancing",
|
|
688
|
+
requiresFreshDatabase: true,
|
|
689
|
+
requiresLogicalDecoding: true,
|
|
690
|
+
timeoutMs: 60_000,
|
|
691
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
692
|
+
const model = new Map();
|
|
693
|
+
yield* place(model, order("a", "new", 10));
|
|
694
|
+
yield* settleBoth(expect, admin, model);
|
|
695
|
+
const huge = 5000000000000000000n;
|
|
696
|
+
yield* place(model, order("h1", "held", huge, { archived: true }));
|
|
697
|
+
yield* place(model, order("h2", "held", huge, { archived: true }));
|
|
698
|
+
yield* settle(expect, viewStates(admin).pipe(Effect.map((rows) => rows.map(({ view_name, status, last_error }) => ({
|
|
699
|
+
view_name,
|
|
700
|
+
status,
|
|
701
|
+
poisoned: last_error?.includes("out of range") ?? false,
|
|
702
|
+
})))), [
|
|
703
|
+
{ view_name: "OrdersByRegion", status: "stale", poisoned: true },
|
|
704
|
+
{ view_name: "OrdersByStatus", status: "ready", poisoned: false },
|
|
705
|
+
]);
|
|
706
|
+
const frozen = statusOf(yield* viewStates(admin), "OrdersByRegion").applied_lsn;
|
|
707
|
+
const before = BigInt(statusOf(yield* viewStates(admin), "OrdersByStatus").applied_lsn);
|
|
708
|
+
yield* place(model, order("b", "paid", 5));
|
|
709
|
+
yield* settle(expect, byStatusRows(admin), expectedByStatus(model));
|
|
710
|
+
yield* settle(expect, viewStates(admin).pipe(Effect.map((rows) => BigInt(statusOf(rows, "OrdersByStatus").applied_lsn) > before)), true);
|
|
711
|
+
expect(statusOf(yield* viewStates(admin), "OrdersByRegion").applied_lsn).toBe(frozen);
|
|
712
|
+
yield* remove(model, "h1");
|
|
713
|
+
yield* rebuildFleetView("OrdersByRegion").pipe(Effect.provideService(SqlClient.SqlClient, admin), Effect.orDie);
|
|
714
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
715
|
+
yield* settleBoth(expect, admin, model);
|
|
716
|
+
})),
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
name: "fleet: applied_lsn passes a command's durable-version after the view has seen its change, and never moves back",
|
|
720
|
+
requiresFreshDatabase: true,
|
|
721
|
+
requiresLogicalDecoding: true,
|
|
722
|
+
timeoutMs: 60_000,
|
|
723
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
724
|
+
const model = new Map();
|
|
725
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
726
|
+
const seen = [];
|
|
727
|
+
for (let index = 0; index < 5; index++) {
|
|
728
|
+
yield* place(model, order(`v${index}`, "new", index + 1));
|
|
729
|
+
const [token] = yield* admin `
|
|
730
|
+
SELECT (pg_current_wal_insert_lsn() - '0/0')::text AS version`.pipe(Effect.orDie);
|
|
731
|
+
const applied = yield* viewStates(admin).pipe(Effect.map((rows) => BigInt(statusOf(rows, "OrdersByStatus").applied_lsn ?? "0")), Effect.tap((lsn) => Effect.sync(() => seen.push(lsn))), Effect.repeat({
|
|
732
|
+
schedule: Schedule.spaced("25 millis"),
|
|
733
|
+
until: (lsn) => lsn >= BigInt(token.version),
|
|
734
|
+
}), Effect.timeout("20 seconds"), Effect.orDie);
|
|
735
|
+
expect(applied >= BigInt(token.version)).toBe(true);
|
|
736
|
+
expect((yield* byStatusRows(admin)).find((row) => row["status"] === "new")?.["orders"]).toBe(index + 1);
|
|
737
|
+
}
|
|
738
|
+
expect(seen.every((lsn, index) => index === 0 || lsn >= seen[index - 1])).toBe(true);
|
|
739
|
+
})),
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "fleet: startup refuses wal_level below logical, no publication or slot, a source without full replica identity, an actor-placed source, a missing index, a login without REPLICATION, and a derived table the tenant role owns",
|
|
743
|
+
requiresFreshDatabase: true,
|
|
744
|
+
requiresLogicalDecoding: true,
|
|
745
|
+
requiresIndependentConnections: true,
|
|
746
|
+
timeoutMs: 60_000,
|
|
747
|
+
run: ({ expect, environment }) => environment.run(Effect.gen(function* () {
|
|
748
|
+
const views = [OrdersByStatus, OrdersByRegion];
|
|
749
|
+
const embedded = yield* Layer.build(Database.pglite());
|
|
750
|
+
const low = yield* checkFleet(views, undefined).pipe(Effect.provideContext(embedded), Effect.exit);
|
|
751
|
+
expect(refusal(low)).toContain("set wal_level = logical");
|
|
752
|
+
const { database, admin } = yield* fleetDatabase(environment, { setup: false });
|
|
753
|
+
const check = (fleet, role) => checkFleet(fleet, role).pipe(Effect.provideService(SqlClient.SqlClient, admin), Effect.exit);
|
|
754
|
+
const runtime = yield* Layer.build(ActorTest.layer({ database, fleet: views, authorize: () => Effect.succeed(true) })).pipe(Effect.exit);
|
|
755
|
+
expect(refusal(runtime)).toContain("public.fleet_orders is not in publication durable_fleet; run durable fleet setup");
|
|
756
|
+
yield* admin `CREATE PUBLICATION durable_fleet FOR TABLE fleet_orders`.pipe(Effect.orDie);
|
|
757
|
+
expect(refusal(yield* check(views))).toContain("does not have full replica identity");
|
|
758
|
+
yield* admin `ALTER TABLE fleet_orders REPLICA IDENTITY FULL`.pipe(Effect.orDie);
|
|
759
|
+
expect(refusal(yield* check(views))).toContain(`replication slot ${FLEET_SLOT} does not exist`);
|
|
760
|
+
yield* setupFleet(views).pipe(Effect.provideService(SqlClient.SqlClient, admin), Effect.orDie);
|
|
761
|
+
expect(Exit.isSuccess(yield* check(views))).toBe(true);
|
|
762
|
+
yield* admin `ALTER TABLE fleet_orders REPLICA IDENTITY DEFAULT`.pipe(Effect.orDie);
|
|
763
|
+
expect(refusal(yield* check(views))).toContain("REPLICA IDENTITY FULL");
|
|
764
|
+
yield* admin `ALTER TABLE fleet_orders REPLICA IDENTITY FULL`.pipe(Effect.orDie);
|
|
765
|
+
expect(refusal(yield* check([SpreadByKind]))).toContain('placement: "tenant"');
|
|
766
|
+
yield* admin `DROP INDEX fleet_orders_by_region_recompute`.pipe(Effect.orDie);
|
|
767
|
+
expect(refusal(yield* check(views))).toContain("ON public.fleet_orders (routing_key, tenant_id, region, status)");
|
|
768
|
+
yield* admin.unsafe(fleetDdl.at(-1)).pipe(Effect.orDie);
|
|
769
|
+
const [random] = yield* admin `SELECT substr(md5(random()::text), 1, 12) AS id`.pipe(Effect.orDie);
|
|
770
|
+
const login = `fleet_login_${random.id}`;
|
|
771
|
+
const tenantRole = `fleet_tenant_${random.id}`;
|
|
772
|
+
yield* Effect.acquireRelease(admin
|
|
773
|
+
.unsafe(`CREATE ROLE ${login} LOGIN PASSWORD 'fleet' NOREPLICATION`)
|
|
774
|
+
.pipe(Effect.andThen(admin.unsafe(`CREATE ROLE ${tenantRole} NOLOGIN`))), () => admin
|
|
775
|
+
.unsafe(`ALTER TABLE fleet_orders_by_status OWNER TO CURRENT_USER`)
|
|
776
|
+
.pipe(Effect.andThen(admin.unsafe(`DROP OWNED BY ${login}, ${tenantRole}`)), Effect.andThen(admin.unsafe(`DROP ROLE ${login}`)), Effect.andThen(admin.unsafe(`DROP ROLE ${tenantRole}`)), Effect.ignore)).pipe(Effect.orDie);
|
|
777
|
+
const url = new URL(Redacted.value(database));
|
|
778
|
+
url.username = login;
|
|
779
|
+
url.password = "fleet";
|
|
780
|
+
const unprivileged = yield* Layer.build(Database.postgres({ url: Redacted.make(url.href), maxConnections: 1 }));
|
|
781
|
+
expect(refusal(yield* checkFleet(views, undefined).pipe(Effect.provideContext(unprivileged), Effect.exit))).toContain(`ALTER ROLE ${login} REPLICATION`);
|
|
782
|
+
yield* admin
|
|
783
|
+
.unsafe(`ALTER TABLE fleet_orders_by_status OWNER TO ${tenantRole}`)
|
|
784
|
+
.pipe(Effect.orDie);
|
|
785
|
+
expect(refusal(yield* check(views, tenantRole))).toContain(`the tenant role ${tenantRole} owns public.fleet_orders_by_status`);
|
|
786
|
+
})),
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
name: "fleet: the statements of a turn are unchanged when views are registered",
|
|
790
|
+
requiresFreshDatabase: true,
|
|
791
|
+
requiresLogicalDecoding: true,
|
|
792
|
+
requiresIndependentConnections: true,
|
|
793
|
+
timeoutMs: 90_000,
|
|
794
|
+
run: ({ expect, environment }) => environment.run(Effect.service(Crypto.Crypto)).then((crypto) => Effect.runPromise(Effect.gen(function* () {
|
|
795
|
+
yield* Effect.acquireRelease(environment.stop, () => environment.restart);
|
|
796
|
+
const plain = yield* fleetDatabase(environment, { setup: false });
|
|
797
|
+
const { database } = yield* fleetDatabase(environment);
|
|
798
|
+
const without = yield* turnStatements(plain.database, [], 5);
|
|
799
|
+
const withViews = yield* turnStatements(database, [OrdersByStatus, OrdersByRegion], 5);
|
|
800
|
+
expect(without > 0).toBe(true);
|
|
801
|
+
expect(withViews).toBe(without);
|
|
802
|
+
}).pipe(Effect.scoped, Effect.provideService(Crypto.Crypto, crypto)))),
|
|
803
|
+
},
|
|
804
|
+
{
|
|
805
|
+
name: "fleet: subscribe sends the caller's tenant's page first, then a changed page after a commit, suppresses an identical page, and shows stale and asOf",
|
|
806
|
+
requiresFreshDatabase: true,
|
|
807
|
+
requiresLogicalDecoding: true,
|
|
808
|
+
requiresIndependentConnections: true,
|
|
809
|
+
timeoutMs: 90_000,
|
|
810
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
811
|
+
const cluster = yield* ActorCluster;
|
|
812
|
+
const tenant = cluster.tenant;
|
|
813
|
+
const model = new Map();
|
|
814
|
+
yield* place(model, order("a", "new", 10));
|
|
815
|
+
yield* place(model, order("b", "paid", 20));
|
|
816
|
+
yield* place(model, order("x", "new", 999), `${tenant}-other`);
|
|
817
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
818
|
+
yield* settle(expect, byStatusRows(admin), expectedByStatus(model));
|
|
819
|
+
const server = yield* cluster.on(0)(serveHttp({ actors: [], fleet: [OrdersByStatus, OrdersByRegion] }));
|
|
820
|
+
const client = fleetClient({
|
|
821
|
+
views: [OrdersByStatus],
|
|
822
|
+
baseUrl: server.url,
|
|
823
|
+
headers: { authorization: `Bearer ${tenant}:alice` },
|
|
824
|
+
});
|
|
825
|
+
const pages = client.OrdersByStatus.subscribe()[Symbol.asyncIterator]();
|
|
826
|
+
yield* Effect.addFinalizer(() => Effect.promise(() => pages.return?.() ?? Promise.resolve(undefined)));
|
|
827
|
+
const first = yield* nextPage(pages, "the first page");
|
|
828
|
+
expect(first.rows).toEqual(servedByStatus(model, tenant));
|
|
829
|
+
expect(first.stale).toBe(false);
|
|
830
|
+
expect(first.asOf === null).toBe(false);
|
|
831
|
+
yield* place(model, order("a", "shipped", 10));
|
|
832
|
+
const changed = yield* nextPage(pages, "the page after a commit");
|
|
833
|
+
expect(changed.rows).toEqual(servedByStatus(model, tenant));
|
|
834
|
+
expect(BigInt(changed.asOf) > BigInt(first.asOf)).toBe(true);
|
|
835
|
+
yield* place(model, order("hidden", "new", 1, { archived: true }));
|
|
836
|
+
yield* place(model, order("c", "new", 5));
|
|
837
|
+
const afterIdentical = yield* nextPage(pages, "the page after an unrelated commit");
|
|
838
|
+
expect(afterIdentical.rows).toEqual(servedByStatus(model, tenant));
|
|
839
|
+
const frozen = servedByStatus(model, tenant);
|
|
840
|
+
const huge = 5000000000000000000n;
|
|
841
|
+
yield* place(model, order("h1", "held", huge));
|
|
842
|
+
yield* place(model, order("h2", "held", huge));
|
|
843
|
+
const stale = yield* nextPage(pages, "the page of a poisoned view");
|
|
844
|
+
expect(stale.stale).toBe(true);
|
|
845
|
+
expect(stale.rows).toEqual(frozen);
|
|
846
|
+
yield* remove(model, "h1");
|
|
847
|
+
yield* rebuildFleetView("OrdersByStatus").pipe(Effect.provideService(SqlClient.SqlClient, admin), Effect.orDie);
|
|
848
|
+
const ready = yield* nextPage(pages, "the page once rebuilt");
|
|
849
|
+
expect(ready.stale).toBe(false);
|
|
850
|
+
expect(ready.rows).toEqual(servedByStatus(model, tenant));
|
|
851
|
+
const filtered = client.OrdersByStatus.subscribe({ status: "new" }, { limit: 1 })[Symbol.asyncIterator]();
|
|
852
|
+
expect((yield* nextPage(filtered, "a filtered page")).rows).toEqual(servedByStatus(model, tenant).filter((row) => row["status"] === "new"));
|
|
853
|
+
yield* Effect.promise(() => filtered.return?.() ?? Promise.resolve(undefined));
|
|
854
|
+
})),
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
name: "fleet: subscribe denies at open, ends within reauthorizeEvery after revocation, never returns another tenant's rows, and refuses a foreign origin, a tenant parameter, and an unregistered view",
|
|
858
|
+
requiresFreshDatabase: true,
|
|
859
|
+
requiresLogicalDecoding: true,
|
|
860
|
+
requiresIndependentConnections: true,
|
|
861
|
+
timeoutMs: 90_000,
|
|
862
|
+
run: ({ expect, environment }) => withFleet(environment, {}, (admin) => Effect.gen(function* () {
|
|
863
|
+
const cluster = yield* ActorCluster;
|
|
864
|
+
const tenant = cluster.tenant;
|
|
865
|
+
const model = new Map();
|
|
866
|
+
yield* place(model, order("a", "new", 10));
|
|
867
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
868
|
+
yield* settle(expect, byStatusRows(admin), expectedByStatus(model));
|
|
869
|
+
const server = yield* cluster.on(0)(serveHttp({ actors: [], fleet: [OrdersByStatus] }));
|
|
870
|
+
const path = "/fleet/OrdersByStatus";
|
|
871
|
+
revoked.add("mallory");
|
|
872
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => revoked.clear()));
|
|
873
|
+
const deniedAtOpen = yield* server.send(path, {
|
|
874
|
+
method: "GET",
|
|
875
|
+
token: `${tenant}:mallory`,
|
|
876
|
+
});
|
|
877
|
+
expect(deniedAtOpen.status).toBe(403);
|
|
878
|
+
expect(deniedAtOpen.body).toMatchObject({ reason: { code: "access_denied" } });
|
|
879
|
+
const foreign = yield* server.send(path, {
|
|
880
|
+
method: "GET",
|
|
881
|
+
token: `${tenant}:alice`,
|
|
882
|
+
headers: { origin: "https://evil.example" },
|
|
883
|
+
});
|
|
884
|
+
expect(foreign.status).toBe(403);
|
|
885
|
+
expect(foreign.body).toMatchObject({ reason: { code: "origin_not_allowed" } });
|
|
886
|
+
for (const query of ["?tenant_id=x", "?limit=0", "?limit=1001", "?status=a&status=b"])
|
|
887
|
+
expect((yield* server.send(`${path}${query}`, { method: "GET", token: `${tenant}:alice` }))
|
|
888
|
+
.status).toBe(400);
|
|
889
|
+
expect((yield* server.send("/fleet/OrdersByRegion", {
|
|
890
|
+
method: "GET",
|
|
891
|
+
token: `${tenant}:alice`,
|
|
892
|
+
})).status).toBe(404);
|
|
893
|
+
const stranger = fleetClient({
|
|
894
|
+
views: [OrdersByStatus],
|
|
895
|
+
baseUrl: server.url,
|
|
896
|
+
headers: { authorization: `Bearer ${tenant}-stranger:alice` },
|
|
897
|
+
})
|
|
898
|
+
.OrdersByStatus.subscribe()[Symbol.asyncIterator]();
|
|
899
|
+
expect((yield* nextPage(stranger, "a stranger's page")).rows).toEqual([]);
|
|
900
|
+
yield* Effect.promise(() => stranger.return?.() ?? Promise.resolve(undefined));
|
|
901
|
+
const pages = fleetClient({
|
|
902
|
+
views: [OrdersByStatus],
|
|
903
|
+
baseUrl: server.url,
|
|
904
|
+
headers: { authorization: `Bearer ${tenant}:bob` },
|
|
905
|
+
})
|
|
906
|
+
.OrdersByStatus.subscribe()[Symbol.asyncIterator]();
|
|
907
|
+
expect((yield* nextPage(pages, "bob's first page")).rows).toEqual(servedByStatus(model, tenant));
|
|
908
|
+
const revokedAt = yield* Clock.currentTimeMillis;
|
|
909
|
+
revoked.add("bob");
|
|
910
|
+
const ended = yield* Effect.tryPromise({
|
|
911
|
+
try: () => pages.next(),
|
|
912
|
+
catch: (error) => Option.liftPredicate(error, Schema.is(ActorError)),
|
|
913
|
+
}).pipe(Effect.flip, Effect.timeout("10 seconds"), Effect.orDie);
|
|
914
|
+
expect(Option.exists(ended, (error) => Schema.is(Unauthorized)(error.reason))).toBe(true);
|
|
915
|
+
expect((yield* Clock.currentTimeMillis) - revokedAt < 1_000 + 3_000).toBe(true);
|
|
916
|
+
})),
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
name: "fleet: with row-level security on, a subscription's page runs as the tenant role",
|
|
920
|
+
requiresFreshDatabase: true,
|
|
921
|
+
requiresLogicalDecoding: true,
|
|
922
|
+
requiresIndependentConnections: true,
|
|
923
|
+
timeoutMs: 90_000,
|
|
924
|
+
run: ({ expect, environment }) => environment.run(Effect.gen(function* () {
|
|
925
|
+
const { database, admin } = yield* fleetDatabase(environment);
|
|
926
|
+
const [random] = yield* admin `SELECT substr(md5(random()::text), 1, 12) AS id`.pipe(Effect.orDie);
|
|
927
|
+
const role = `fleet_tenant_${random.id}`;
|
|
928
|
+
const viewOwner = `fleet_views_${random.id}`;
|
|
929
|
+
yield* migrate.pipe(Effect.provideService(SqlClient.SqlClient, admin), Effect.orDie);
|
|
930
|
+
for (const statement of grants({ role, viewOwner }))
|
|
931
|
+
yield* admin.unsafe(statement).pipe(Effect.orDie);
|
|
932
|
+
yield* Effect.addFinalizer(() => Effect.forEach([role, viewOwner], (owner) => admin
|
|
933
|
+
.unsafe(`REASSIGN OWNED BY ${owner} TO CURRENT_USER`)
|
|
934
|
+
.pipe(Effect.andThen(admin.unsafe(`DROP OWNED BY ${owner}`)), Effect.andThen(admin.unsafe(`DROP ROLE ${owner}`)), Effect.ignore), { discard: true }));
|
|
935
|
+
const context = yield* Layer.build(FleetOrderLive.pipe(Layer.provideMerge(ActorTest.layer({
|
|
936
|
+
database,
|
|
937
|
+
authorize,
|
|
938
|
+
rowLevelSecurity: { role },
|
|
939
|
+
fleet: [OrdersByStatus, OrdersByRegion],
|
|
940
|
+
}))));
|
|
941
|
+
const model = new Map();
|
|
942
|
+
yield* Effect.gen(function* () {
|
|
943
|
+
const test = yield* ActorTest;
|
|
944
|
+
const handle = yield* FleetOrder.get("a");
|
|
945
|
+
yield* handle.Put(order("a", "new", 10)).pipe(Effect.orDie);
|
|
946
|
+
record(model, test.tenant, order("a", "new", 10));
|
|
947
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
948
|
+
yield* settle(expect, byStatusRows(admin), expectedByStatus(model));
|
|
949
|
+
const page = yield* Fleet.subscribe(OrdersByStatus).pipe(Stream.take(1), Stream.runHead);
|
|
950
|
+
expect(Option.getOrThrow(page).rows).toEqual(servedByStatus(model, test.tenant));
|
|
951
|
+
yield* admin
|
|
952
|
+
.unsafe(`REVOKE SELECT ON fleet_orders_by_status FROM ${role}`)
|
|
953
|
+
.pipe(Effect.orDie);
|
|
954
|
+
const refused = yield* Fleet.subscribe(OrdersByStatus).pipe(Stream.take(1), Stream.runHead, Effect.exit);
|
|
955
|
+
expect(causeMessages(refused)).toContain("permission denied");
|
|
956
|
+
}).pipe(Effect.provideContext(context));
|
|
957
|
+
})),
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
name: "fleet: one poll per view per runner regardless of subscriber count, and a page query only when the view changed",
|
|
961
|
+
requiresFreshDatabase: true,
|
|
962
|
+
requiresLogicalDecoding: true,
|
|
963
|
+
requiresIndependentConnections: true,
|
|
964
|
+
timeoutMs: 90_000,
|
|
965
|
+
run: ({ expect, environment }) => environment.run(Effect.gen(function* () {
|
|
966
|
+
const { database, admin } = yield* fleetDatabase(environment);
|
|
967
|
+
const polls = [];
|
|
968
|
+
const pages = [];
|
|
969
|
+
const hooks = {
|
|
970
|
+
afterApply: Effect.void,
|
|
971
|
+
poll: (view) => Effect.sync(() => void polls.push(view)),
|
|
972
|
+
page: (view) => Effect.sync(() => void pages.push(view)),
|
|
973
|
+
};
|
|
974
|
+
const context = yield* Layer.build(FleetOrderLive.pipe(Layer.provideMerge(ActorTest.layer({ database, authorize, fleet: [OrdersByStatus, OrdersByRegion] })))).pipe(Effect.provideService(FleetHooks, hooks));
|
|
975
|
+
yield* Effect.gen(function* () {
|
|
976
|
+
yield* readyViews(expect, admin, ["OrdersByRegion", "OrdersByStatus"]);
|
|
977
|
+
const window = (subscribers) => Effect.gen(function* () {
|
|
978
|
+
const fibers = yield* Effect.forEach(Array.from({ length: subscribers }, (_, index) => index), () => Fleet.subscribe(OrdersByStatus).pipe(Stream.runDrain, Effect.forkChild));
|
|
979
|
+
yield* Effect.sleep("500 millis");
|
|
980
|
+
polls.length = 0;
|
|
981
|
+
pages.length = 0;
|
|
982
|
+
yield* Effect.sleep("3 seconds");
|
|
983
|
+
const counted = { polls: polls.length, pages: pages.length };
|
|
984
|
+
yield* Effect.forEach(fibers, Fiber.interrupt, { discard: true });
|
|
985
|
+
return counted;
|
|
986
|
+
});
|
|
987
|
+
const one = yield* window(1);
|
|
988
|
+
const many = yield* window(8);
|
|
989
|
+
expect(one.polls >= 2 && one.polls <= 4).toBe(true);
|
|
990
|
+
expect(many.polls >= 2 && many.polls <= 4).toBe(true);
|
|
991
|
+
expect(Math.abs(many.polls - one.polls) <= 1).toBe(true);
|
|
992
|
+
expect(one.pages <= one.polls).toBe(true);
|
|
993
|
+
expect(many.pages <= many.polls * 8).toBe(true);
|
|
994
|
+
expect(polls.every((view) => view === "OrdersByStatus")).toBe(true);
|
|
995
|
+
}).pipe(Effect.provideContext(context));
|
|
996
|
+
})),
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
name: "fleet: PGlite refuses Fleet.view at layer build",
|
|
1000
|
+
run: ({ expect, environment }) => environment.run(Effect.gen(function* () {
|
|
1001
|
+
const exit = yield* Layer.build(ActorTest.layer({ database: {}, fleet: [OrdersByStatus] })).pipe(Effect.exit);
|
|
1002
|
+
expect(refusal(exit)).toContain("PGlite has no logical replication");
|
|
1003
|
+
})),
|
|
1004
|
+
},
|
|
1005
|
+
];
|
|
1006
|
+
/** Fleet cases serve the HTTP actors. */
|
|
1007
|
+
export const fleetSuite = {
|
|
1008
|
+
uses: [httpSuite],
|
|
1009
|
+
};
|