effect-jobs 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 +16 -0
- package/LICENSE +22 -0
- package/README.md +142 -0
- package/dist/Job.d.ts +33 -0
- package/dist/Job.d.ts.map +1 -0
- package/dist/Job.js +66 -0
- package/dist/Job.js.map +1 -0
- package/dist/JobConsumer.d.ts +34 -0
- package/dist/JobConsumer.d.ts.map +1 -0
- package/dist/JobConsumer.js +28 -0
- package/dist/JobConsumer.js.map +1 -0
- package/dist/JobContract.d.ts +128 -0
- package/dist/JobContract.d.ts.map +1 -0
- package/dist/JobContract.js +15 -0
- package/dist/JobContract.js.map +1 -0
- package/dist/JobFailure.d.ts +51 -0
- package/dist/JobFailure.d.ts.map +1 -0
- package/dist/JobFailure.js +34 -0
- package/dist/JobFailure.js.map +1 -0
- package/dist/JobId.d.ts +5 -0
- package/dist/JobId.d.ts.map +1 -0
- package/dist/JobId.js +4 -0
- package/dist/JobId.js.map +1 -0
- package/dist/JobIdentity.d.ts +54 -0
- package/dist/JobIdentity.d.ts.map +1 -0
- package/dist/JobIdentity.js +82 -0
- package/dist/JobIdentity.js.map +1 -0
- package/dist/JobLifecycle.d.ts +173 -0
- package/dist/JobLifecycle.d.ts.map +1 -0
- package/dist/JobLifecycle.js +234 -0
- package/dist/JobLifecycle.js.map +1 -0
- package/dist/JobPayload.d.ts +18 -0
- package/dist/JobPayload.d.ts.map +1 -0
- package/dist/JobPayload.js +10 -0
- package/dist/JobPayload.js.map +1 -0
- package/dist/JobPayloadCodec.d.ts +14 -0
- package/dist/JobPayloadCodec.d.ts.map +1 -0
- package/dist/JobPayloadCodec.js +78 -0
- package/dist/JobPayloadCodec.js.map +1 -0
- package/dist/JobPolicy.d.ts +50 -0
- package/dist/JobPolicy.d.ts.map +1 -0
- package/dist/JobPolicy.js +85 -0
- package/dist/JobPolicy.js.map +1 -0
- package/dist/JobProducer.d.ts +14 -0
- package/dist/JobProducer.d.ts.map +1 -0
- package/dist/JobProducer.js +23 -0
- package/dist/JobProducer.js.map +1 -0
- package/dist/JobQueue.d.ts +16 -0
- package/dist/JobQueue.d.ts.map +1 -0
- package/dist/JobQueue.js +16 -0
- package/dist/JobQueue.js.map +1 -0
- package/dist/JobRegistry.d.ts +37 -0
- package/dist/JobRegistry.d.ts.map +1 -0
- package/dist/JobRegistry.js +47 -0
- package/dist/JobRegistry.js.map +1 -0
- package/dist/JobStore.d.ts +348 -0
- package/dist/JobStore.d.ts.map +1 -0
- package/dist/JobStore.js +19 -0
- package/dist/JobStore.js.map +1 -0
- package/dist/JobTelemetry.d.ts +7 -0
- package/dist/JobTelemetry.d.ts.map +1 -0
- package/dist/JobTelemetry.js +61 -0
- package/dist/JobTelemetry.js.map +1 -0
- package/dist/JobTransaction.d.ts +24 -0
- package/dist/JobTransaction.d.ts.map +1 -0
- package/dist/JobTransaction.js +7 -0
- package/dist/JobTransaction.js.map +1 -0
- package/dist/JobWorker.d.ts +24 -0
- package/dist/JobWorker.d.ts.map +1 -0
- package/dist/JobWorker.js +155 -0
- package/dist/JobWorker.js.map +1 -0
- package/dist/JobWorkerRuntime.d.ts +18 -0
- package/dist/JobWorkerRuntime.d.ts.map +1 -0
- package/dist/JobWorkerRuntime.js +5 -0
- package/dist/JobWorkerRuntime.js.map +1 -0
- package/dist/PollingJobWorker.d.ts +9 -0
- package/dist/PollingJobWorker.d.ts.map +1 -0
- package/dist/PollingJobWorker.js +54 -0
- package/dist/PollingJobWorker.js.map +1 -0
- package/dist/PostgreSqlDrizzleSchema.d.ts +432 -0
- package/dist/PostgreSqlDrizzleSchema.d.ts.map +1 -0
- package/dist/PostgreSqlDrizzleSchema.js +72 -0
- package/dist/PostgreSqlDrizzleSchema.js.map +1 -0
- package/dist/PostgreSqlJobs.d.ts +25 -0
- package/dist/PostgreSqlJobs.d.ts.map +1 -0
- package/dist/PostgreSqlJobs.js +38 -0
- package/dist/PostgreSqlJobs.js.map +1 -0
- package/dist/PostgreSqlSchema.d.ts +30 -0
- package/dist/PostgreSqlSchema.d.ts.map +1 -0
- package/dist/PostgreSqlSchema.js +63 -0
- package/dist/PostgreSqlSchema.js.map +1 -0
- package/dist/PostgreSqlTransaction.d.ts +42 -0
- package/dist/PostgreSqlTransaction.d.ts.map +1 -0
- package/dist/PostgreSqlTransaction.js +9 -0
- package/dist/PostgreSqlTransaction.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/JobTransaction.d.ts +16 -0
- package/dist/internal/JobTransaction.d.ts.map +1 -0
- package/dist/internal/JobTransaction.js +21 -0
- package/dist/internal/JobTransaction.js.map +1 -0
- package/dist/internal/codec/CanonicalJson.d.ts +9 -0
- package/dist/internal/codec/CanonicalJson.d.ts.map +1 -0
- package/dist/internal/codec/CanonicalJson.js +184 -0
- package/dist/internal/codec/CanonicalJson.js.map +1 -0
- package/dist/internal/codec/ProtectedProjection.d.ts +12 -0
- package/dist/internal/codec/ProtectedProjection.d.ts.map +1 -0
- package/dist/internal/codec/ProtectedProjection.js +121 -0
- package/dist/internal/codec/ProtectedProjection.js.map +1 -0
- package/dist/internal/lifecycle/Artifacts.d.ts +6 -0
- package/dist/internal/lifecycle/Artifacts.d.ts.map +1 -0
- package/dist/internal/lifecycle/Artifacts.js +49 -0
- package/dist/internal/lifecycle/Artifacts.js.map +1 -0
- package/dist/internal/lifecycle/Reconciliation.d.ts +82 -0
- package/dist/internal/lifecycle/Reconciliation.d.ts.map +1 -0
- package/dist/internal/lifecycle/Reconciliation.js +98 -0
- package/dist/internal/lifecycle/Reconciliation.js.map +1 -0
- package/dist/internal/postgresql/Producer.d.ts +14 -0
- package/dist/internal/postgresql/Producer.d.ts.map +1 -0
- package/dist/internal/postgresql/Producer.js +55 -0
- package/dist/internal/postgresql/Producer.js.map +1 -0
- package/dist/internal/postgresql/Readiness.d.ts +5 -0
- package/dist/internal/postgresql/Readiness.d.ts.map +1 -0
- package/dist/internal/postgresql/Readiness.js +53 -0
- package/dist/internal/postgresql/Readiness.js.map +1 -0
- package/dist/internal/postgresql/Rows.d.ts +45 -0
- package/dist/internal/postgresql/Rows.d.ts.map +1 -0
- package/dist/internal/postgresql/Rows.js +75 -0
- package/dist/internal/postgresql/Rows.js.map +1 -0
- package/dist/internal/postgresql/Store.d.ts +5 -0
- package/dist/internal/postgresql/Store.d.ts.map +1 -0
- package/dist/internal/postgresql/Store.js +211 -0
- package/dist/internal/postgresql/Store.js.map +1 -0
- package/dist/internal/worker/Capability.d.ts +106 -0
- package/dist/internal/worker/Capability.d.ts.map +1 -0
- package/dist/internal/worker/Capability.js +11 -0
- package/dist/internal/worker/Capability.js.map +1 -0
- package/dist/internal/worker/Drain.d.ts +22 -0
- package/dist/internal/worker/Drain.d.ts.map +1 -0
- package/dist/internal/worker/Drain.js +59 -0
- package/dist/internal/worker/Drain.js.map +1 -0
- package/dist/internal/worker/Execution.d.ts +7 -0
- package/dist/internal/worker/Execution.d.ts.map +1 -0
- package/dist/internal/worker/Execution.js +125 -0
- package/dist/internal/worker/Execution.js.map +1 -0
- package/dist/internal/worker/Results.d.ts +108 -0
- package/dist/internal/worker/Results.d.ts.map +1 -0
- package/dist/internal/worker/Results.js +3 -0
- package/dist/internal/worker/Results.js.map +1 -0
- package/package.json +169 -0
- package/specs/architecture-decisions.md +841 -0
- package/specs/core-contract.md +323 -0
- package/specs/drizzle-compatibility.md +167 -0
- package/specs/postgresql-backend.md +291 -0
- package/specs/release-qualification.md +107 -0
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
# Architecture decisions
|
|
2
|
+
|
|
3
|
+
D1–D8 are accepted. This document is the canonical extraction contract, including
|
|
4
|
+
later refinements recorded within the decisions. Earlier Multica proposals and
|
|
5
|
+
auth specifications are historical reference where they conflict with it.
|
|
6
|
+
Statements about downstream gates describe dependencies, not unaccepted choices
|
|
7
|
+
now that all eight decisions are recorded. Exact API spellings and supported
|
|
8
|
+
adapter coverage still need specification and qualification.
|
|
9
|
+
|
|
10
|
+
Decision acceptance alone did not dispatch implementation. The owner subsequently
|
|
11
|
+
authorized continuing the extraction and committing these decisions. Execution
|
|
12
|
+
must still follow task dependencies, isolated ownership, and the agreed review
|
|
13
|
+
workflow; this does not authorize push, publication, database resets, or changes
|
|
14
|
+
to unrelated auth work.
|
|
15
|
+
|
|
16
|
+
## D1 — focused initial scope and package boundary
|
|
17
|
+
|
|
18
|
+
Status: accepted by the owner on 2026-10-04. Tracked in Multica as UPVE-849.
|
|
19
|
+
|
|
20
|
+
### Decision
|
|
21
|
+
|
|
22
|
+
Choose variant A: extract and generalize the existing durable jobs substrate
|
|
23
|
+
before adding new queue features. The first release is an alpha, not a claim of
|
|
24
|
+
production qualification.
|
|
25
|
+
|
|
26
|
+
Ship one npm package, `effect-jobs`, with focused public subpaths. Keep the core
|
|
27
|
+
independent of PostgreSQL and Drizzle imports; isolate PostgreSQL persistence
|
|
28
|
+
behind a backend subpath with optional backend peers. Do not split core, worker,
|
|
29
|
+
and PostgreSQL into separate npm packages for the initial release.
|
|
30
|
+
|
|
31
|
+
The catalog supports application-defined jobs from the outset. This does not
|
|
32
|
+
require effect-auth to expose its own catalog to arbitrary application jobs.
|
|
33
|
+
|
|
34
|
+
### Initial scope
|
|
35
|
+
|
|
36
|
+
- Schema-defined, explicitly versioned jobs and logical queues.
|
|
37
|
+
- Application-defined catalogs and handler installation.
|
|
38
|
+
- Atomic enqueue within an application-owned transaction.
|
|
39
|
+
- Semantic deduplication and the existing durable lifecycle guarantees.
|
|
40
|
+
- Fixed leases, ownership fencing, and bounded unknown-outcome reconciliation.
|
|
41
|
+
- Persisted retries, bounded recovery, and retention.
|
|
42
|
+
- PostgreSQL persistence with application-owned migrations.
|
|
43
|
+
- Bounded worker drain and explicit scoped Node/Bun polling.
|
|
44
|
+
|
|
45
|
+
Imports must not start workers. Applications explicitly compose persistence,
|
|
46
|
+
handler, and runtime Layers. Core imports must work without backend peers.
|
|
47
|
+
|
|
48
|
+
### Ownership boundary
|
|
49
|
+
|
|
50
|
+
`effect-jobs` owns the generic durable execution mechanism and never depends on
|
|
51
|
+
`effect-auth`. `effect-auth` retains domain schemas, job definitions, handlers,
|
|
52
|
+
authority, providers, producer transactions, and domain receipt/retention rules.
|
|
53
|
+
There is one durable lifecycle and authority, not a second auth-specific runtime
|
|
54
|
+
or a dual-write migration.
|
|
55
|
+
|
|
56
|
+
### Deferred features
|
|
57
|
+
|
|
58
|
+
Standalone enqueue convenience, additional backends, recurring schedules,
|
|
59
|
+
dashboards, flows, pause/resume/cancel/redrive APIs, and a ready-made Cloudflare
|
|
60
|
+
coordinator are outside the first release. Bounded drain alone is not a
|
|
61
|
+
Cloudflare support claim.
|
|
62
|
+
|
|
63
|
+
### Proposed module shape
|
|
64
|
+
|
|
65
|
+
The following illustrates the accepted packaging direction, not implemented
|
|
66
|
+
exports or finalized symbol names:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { Job } from "effect-jobs/Job"
|
|
70
|
+
import { JobQueue } from "effect-jobs/JobQueue"
|
|
71
|
+
import * as JobWorker from "effect-jobs/JobWorker"
|
|
72
|
+
import * as PostgreSqlJobs from "effect-jobs/PostgreSqlJobs"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Rationale and downstream gates
|
|
76
|
+
|
|
77
|
+
A focused extraction limits simultaneous protocol changes and preserves the
|
|
78
|
+
existing transaction-bound producer guarantees. A single package avoids
|
|
79
|
+
premature release/version coordination while subpaths isolate backend loading.
|
|
80
|
+
|
|
81
|
+
D1 approves scope and ownership, not the illustrative method signatures from
|
|
82
|
+
the discussion. Envelope and handler inputs, IDs/deduplication, failure policy,
|
|
83
|
+
payload protection, transaction bridge/driver, physical storage compatibility,
|
|
84
|
+
and retention contracts remain gated by D2–D8. Baseline and regression
|
|
85
|
+
qualification tasks remain required. Acceptance does not dispatch agents or
|
|
86
|
+
authorize implementation, merge, push, or publication.
|
|
87
|
+
|
|
88
|
+
## D2 — domain payload, envelope, and handler input
|
|
89
|
+
|
|
90
|
+
Status: accepted by the owner on 2026-10-04 after clarification of the existing
|
|
91
|
+
auth API. Tracked in Multica as UPVE-851.
|
|
92
|
+
|
|
93
|
+
Choose variant B: a definition's payload Schema describes domain data only.
|
|
94
|
+
The library owns the standard envelope Schema, separate from mutable lifecycle
|
|
95
|
+
state. Enqueue accepts an explicit transaction and an input containing producer
|
|
96
|
+
identity, policy, optional initial availability, and decoded domain payload. D3 refines
|
|
97
|
+
the original job-ID example: the library assigns the job ID rather than requiring
|
|
98
|
+
the caller to preserve it across production retries.
|
|
99
|
+
|
|
100
|
+
Handlers receive `{ payload, context }`. The proposed minimal context exposes
|
|
101
|
+
job ID, catalog identity (queue/kind/version), producer identity, and current
|
|
102
|
+
attempt number. Exact field names and attempt counting semantics must be
|
|
103
|
+
specified before implementation. It does not expose a database client, lease
|
|
104
|
+
token, or ack/retry/complete capabilities; the worker owns lifecycle transitions.
|
|
105
|
+
Handler service dependencies remain ordinary Effect/Layer requirements, not an
|
|
106
|
+
ambient execution-context service.
|
|
107
|
+
|
|
108
|
+
Auth subject/actor/event fields remain domain-owned rather than mandatory
|
|
109
|
+
generic envelope fields. Auth retains any necessary cross-field validation when
|
|
110
|
+
adapting its definitions. The existing auth `Job.make` payload option points to
|
|
111
|
+
a full claimed-job Schema; that is an API modeling fact, not a claim that every
|
|
112
|
+
envelope field is physically stored in a payload blob.
|
|
113
|
+
|
|
114
|
+
Producers use decoded Schema types; durable encoding is bounded and workers
|
|
115
|
+
validate persisted artifacts before handlers receive decoded payloads. Required
|
|
116
|
+
encoding/decoding services must remain explicit in types and Layer composition.
|
|
117
|
+
Protected payload behavior remains gated by D5. The suggested existing 64 KiB
|
|
118
|
+
payload/projection limits are a starting point, not separately finalized here.
|
|
119
|
+
|
|
120
|
+
This separation makes the generic API clearer without changing transaction
|
|
121
|
+
atomicity or allowing handlers to own finalization. D3 still determines IDs and
|
|
122
|
+
deduplication; D4 determines failure outcomes; D6 determines the transaction
|
|
123
|
+
bridge. No runtime exports or implementation are introduced by this decision.
|
|
124
|
+
|
|
125
|
+
## D3 — producer identity, IDs, and semantic deduplication
|
|
126
|
+
|
|
127
|
+
Status: accepted by the owner on 2026-10-04. Tracked in Multica as UPVE-852.
|
|
128
|
+
|
|
129
|
+
### Producer identity and declaration
|
|
130
|
+
|
|
131
|
+
Deduplicate by the required tuple `(operation, operationId, slot)` within one
|
|
132
|
+
configured jobs storage, not separately per job kind or version. `operation`
|
|
133
|
+
names the business operation, `operationId` identifies its particular instance,
|
|
134
|
+
and `slot` identifies one consequence. Multiple slots may produce the same job
|
|
135
|
+
kind. Applications should namespace operation names.
|
|
136
|
+
|
|
137
|
+
Declare a producer once in an application-owned domain module and import it from
|
|
138
|
+
all callers. The accepted API direction is:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
export const IssueInvoiceProducer = JobProducer.make({
|
|
142
|
+
operation: "billing.issue-invoice",
|
|
143
|
+
slots: ["generate-pdf", "send-email"]
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const producer = IssueInvoiceProducer.identity({
|
|
147
|
+
operationId,
|
|
148
|
+
slot: "generate-pdf"
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The factory is immutable configuration, not a service or running producer.
|
|
153
|
+
`operation` retains its literal type; `slot` is inferred as the union of declared
|
|
154
|
+
literals. `operationId` is a bounded opaque string; application-branded strings
|
|
155
|
+
are accepted without requiring an additional library brand. Validate external
|
|
156
|
+
values at boundaries.
|
|
157
|
+
|
|
158
|
+
The owner subsequently accepted these identifier boundaries and diagnostics:
|
|
159
|
+
|
|
160
|
+
- `operationId` is 1–256 UTF-8 bytes, contains valid Unicode scalar values and
|
|
161
|
+
no NUL, and is compared exactly without trimming, case folding or normalization.
|
|
162
|
+
- `operation` and each `slot` are 1–128 ASCII characters matching
|
|
163
|
+
`^[a-z][a-z0-9.-]*$`. The simple regular expression is accepted.
|
|
164
|
+
- Invalid declaration names fail immediately when creating the producer, not
|
|
165
|
+
later during enqueue. Ordinary TypeScript string types do not validate this
|
|
166
|
+
grammar; inferred slot unions still reject undeclared slots statically.
|
|
167
|
+
- Configuration errors identify the exact field (including a slot index), the
|
|
168
|
+
invalid declaration name, and the expected length/format so callers can fix it.
|
|
169
|
+
Invalid operation IDs report the violated rule and, for length violations, the
|
|
170
|
+
actual UTF-8 byte length without echoing the potentially sensitive value.
|
|
171
|
+
- Tests must cover these boundaries and actionable diagnostics. These accepted
|
|
172
|
+
rules do not claim that runtime validation has already been implemented.
|
|
173
|
+
|
|
174
|
+
Start with `identity({ operationId, slot })`; fluent `forOperation(...).slot(...)`
|
|
175
|
+
is not required for the initial implementation. Do not introduce a mutable
|
|
176
|
+
builder or global registry to detect semantically similar names. Where multiple
|
|
177
|
+
callers share the same production logic, use an ordinary application-domain
|
|
178
|
+
function to bind producer slot, job definition, and input construction. Such
|
|
179
|
+
wrappers are not mandatory for every enqueue.
|
|
180
|
+
|
|
181
|
+
### Job IDs and enqueue results
|
|
182
|
+
|
|
183
|
+
The library assigns a unique opaque branded `JobId` on first production. The
|
|
184
|
+
caller is not required to supply or preserve a job ID. A matching duplicate
|
|
185
|
+
returns the existing ID:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
type EnqueueResult =
|
|
189
|
+
| { readonly _tag: "Inserted"; readonly jobId: JobId }
|
|
190
|
+
| { readonly _tag: "AlreadyPresent"; readonly jobId: JobId }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
This supersedes the earlier proposal requiring stable caller-supplied job IDs
|
|
194
|
+
and rejecting production retries solely because a newly generated ID differs.
|
|
195
|
+
An explicit caller-ID override is not approved by this decision. There is no
|
|
196
|
+
mandatory generic event ID; event IDs remain domain-owned.
|
|
197
|
+
|
|
198
|
+
### Equality and transaction guarantees
|
|
199
|
+
|
|
200
|
+
For the same producer identity, compare catalog identity and domain payload
|
|
201
|
+
semantics, including protected-field fingerprints. Changed catalog identity or
|
|
202
|
+
content produces a typed integrity error. The library-generated job ID is not a
|
|
203
|
+
new candidate input to compare. There is no payload overwrite, debounce, or
|
|
204
|
+
last-write-wins behavior.
|
|
205
|
+
|
|
206
|
+
Exclude mutable lifecycle state, counters, leases, rescheduled availability,
|
|
207
|
+
and encryption randomness/raw ciphertext from semantic equality. Protected-field
|
|
208
|
+
equality remains subject to D5.
|
|
209
|
+
|
|
210
|
+
The owner subsequently refined D3: policy and initial availability are immutable
|
|
211
|
+
after creation but are not part of duplicate semantic equality. For a matching
|
|
212
|
+
duplicate, retain the first stored policy and availability, even if the caller
|
|
213
|
+
supplies different values on a production retry. This is first-write-wins for
|
|
214
|
+
execution configuration only, not for payload content. Changed deployment
|
|
215
|
+
defaults must not mutate existing jobs or turn otherwise matching retries into
|
|
216
|
+
integrity conflicts.
|
|
217
|
+
|
|
218
|
+
`availableAt` is optional. If omitted on first insertion, the backend assigns it
|
|
219
|
+
using database time. A matching duplicate never resets it to a new current time.
|
|
220
|
+
A supplied past timestamp is valid and makes the job immediately eligible;
|
|
221
|
+
a future timestamp postpones eligibility, not guarantees execution at that time.
|
|
222
|
+
Availability is not a domain expiration deadline.
|
|
223
|
+
|
|
224
|
+
Policy is validated when creating the job and persisted durably. Re-enqueue is
|
|
225
|
+
not an API for rescheduling or changing policy. Such an operation would need a
|
|
226
|
+
separate contract, outside the current initial scope. A new producer identity
|
|
227
|
+
denotes a new consequence, not an update of the old job. This refinement
|
|
228
|
+
supersedes earlier discussion requiring callers to retain identical scheduling
|
|
229
|
+
timestamps and policy across production retries.
|
|
230
|
+
|
|
231
|
+
Storage uniqueness and conflict handling must enforce this behavior under
|
|
232
|
+
concurrent enqueue, not merely a pre-insert lookup. Enqueue remains inside the
|
|
233
|
+
application-owned transaction; integrity conflicts must not be swallowed to
|
|
234
|
+
commit the remaining domain writes. Results are provisional until commit.
|
|
235
|
+
|
|
236
|
+
### Application responsibilities and limits
|
|
237
|
+
|
|
238
|
+
Applications supply a stable operation ID across retries, for example an
|
|
239
|
+
idempotency key or durable operation/event ID. A new operation ID denotes a new
|
|
240
|
+
operation even if payload or job kind matches. Sharing producer declarations
|
|
241
|
+
across endpoints does not deduplicate requests with different operation IDs.
|
|
242
|
+
|
|
243
|
+
Retrying execution or recovering an existing job preserves its job ID; that is
|
|
244
|
+
distinct from retrying the business operation that produces a job. Deduplication
|
|
245
|
+
lasts only while durable identity evidence is retained; D8 determines retention.
|
|
246
|
+
It does not imply exactly-once external handler effects.
|
|
247
|
+
|
|
248
|
+
Auth should adapt its existing operation/consequence tuple through a shared
|
|
249
|
+
domain declaration. Its former job/event ID binding requires explicit adaptation
|
|
250
|
+
and must not be blindly copied. Exact storage representation is gated by D7.
|
|
251
|
+
This decision records a contract, not implementation or dispatch authorization.
|
|
252
|
+
|
|
253
|
+
## D4 — handler error adapter and execution outcomes
|
|
254
|
+
|
|
255
|
+
Status: accepted by the owner on 2026-10-04, including the clarified
|
|
256
|
+
`OutcomeUnknown` name and separation from durable lifecycle status. Tracked in
|
|
257
|
+
Multica as UPVE-854.
|
|
258
|
+
|
|
259
|
+
Choose ordinary Effect composition in the handler adapter rather than a separate
|
|
260
|
+
`classifyError` callback in handler configuration. Domain services retain their
|
|
261
|
+
domain errors. The application-owned adapter maps those errors into explicit job
|
|
262
|
+
failures, for example using `Effect.mapError` and exhaustive matching, before
|
|
263
|
+
installing the handler through `handlerLayer(execute)`.
|
|
264
|
+
|
|
265
|
+
Both proposed variants can preserve exhaustive typing, shared mapping, and
|
|
266
|
+
domain independence. Variant A avoids an extra library classification mechanism
|
|
267
|
+
and stays closer to the existing implementation. Mapping must not require
|
|
268
|
+
domain services to depend on the queue library. Handler service requirements
|
|
269
|
+
remain ordinary Effect/Layer requirements; the worker still owns durable
|
|
270
|
+
transitions.
|
|
271
|
+
|
|
272
|
+
### Outcome semantics
|
|
273
|
+
|
|
274
|
+
- Success: the worker attempts fenced completion.
|
|
275
|
+
- `Retry`: request a persisted retry according to policy and within its limits.
|
|
276
|
+
- `Dead`: a known terminal failure without another retry.
|
|
277
|
+
- `Isolate`: an invalid artifact or violated invariant requiring investigation,
|
|
278
|
+
rather than an ordinary business rejection.
|
|
279
|
+
- `OutcomeUnknown`: the operation may have executed, but its outcome cannot be
|
|
280
|
+
confirmed. This supersedes the proposed name `Unresolved`.
|
|
281
|
+
|
|
282
|
+
`OutcomeUnknown` is an attempt outcome, not a new durable lifecycle status.
|
|
283
|
+
Absent confirmed finalization, the row remains Active pending reconciliation or
|
|
284
|
+
fenced lease expiry/recovery. Recovery can invoke the handler again, so external
|
|
285
|
+
effects still need idempotency or domain reconciliation. Unknown does not mean
|
|
286
|
+
not executed and does not provide exactly-once execution.
|
|
287
|
+
|
|
288
|
+
For example, an explicit temporary provider rejection can be mapped to Retry;
|
|
289
|
+
a lost response after sending a request can be mapped to OutcomeUnknown.
|
|
290
|
+
Defects, interruptions, composite causes, and uncertain timeouts must not be
|
|
291
|
+
automatically interpreted as known-safe retries or successful completion.
|
|
292
|
+
An unknown finalization-write result requires storage reconciliation, not an
|
|
293
|
+
immediate rerun of the handler.
|
|
294
|
+
|
|
295
|
+
Only explicit bounded failure codes are persisted, not arbitrary error messages
|
|
296
|
+
or raw `Cause.pretty`. Preserve the existing persisted FixedDelayV1 retry and
|
|
297
|
+
bounded recovery protocol during extraction rather than adding exponential retry.
|
|
298
|
+
The owner subsequently accepted preserving the existing attempt-counting protocol:
|
|
299
|
+
`context.attemptNumber = attemptsMade + 1`. Claim/release do not increment
|
|
300
|
+
`attemptsMade`; a confirmed, fenced Complete/Retry/Dead/Isolate transition
|
|
301
|
+
increments it exactly once. An unknown outcome leaves it unchanged; recovery
|
|
302
|
+
uses a separate `stalledCount` and limit. Thus the exposed one-based attempt number
|
|
303
|
+
may repeat after an unknown outcome and recovery. It is neither an external
|
|
304
|
+
idempotency key nor a count of all handler invocations. `maxAttempts` bounds
|
|
305
|
+
finalized attempts, while `maxStalledCount` separately bounds uncertain recovery.
|
|
306
|
+
This accepts counter semantics, not unpresented exact time-boundary comparisons.
|
|
307
|
+
|
|
308
|
+
The owner subsequently accepted preserving these exact time boundaries:
|
|
309
|
+
|
|
310
|
+
- A job is due when `availableAt <= dbNow`.
|
|
311
|
+
- Fenced owned writes require `leaseExpiresAt > dbNow`; expiry recovery is
|
|
312
|
+
eligible when `leaseExpiresAt <= dbNow`.
|
|
313
|
+
- `attemptTimeoutMillis < leaseDurationMillis` is required.
|
|
314
|
+
- A claim or reconciled lease is usable only with remaining lease time
|
|
315
|
+
`>= attemptTimeoutMillis + operationResponseBudgetMillis`.
|
|
316
|
+
- A retry whose computed next availability is `>= notAfter` becomes Dead rather
|
|
317
|
+
than being scheduled. `notAfter` limits retry scheduling, not handler execution
|
|
318
|
+
or initial availability.
|
|
319
|
+
- Durable transitions use one database-time snapshot rather than process time.
|
|
320
|
+
Tests must exercise equality and immediately neighboring values.
|
|
321
|
+
|
|
322
|
+
Exact constructor spellings and remaining boundary details must be specified,
|
|
323
|
+
and the accepted behavior must be qualified before implementation; no unbounded retry or
|
|
324
|
+
new lifecycle status is approved. Acceptance does not authorize dispatch.
|
|
325
|
+
|
|
326
|
+
### Accepted policy input refinement
|
|
327
|
+
|
|
328
|
+
The owner accepted `JobPolicy.make` with all configuration fields optional and
|
|
329
|
+
documented defaults, including retention. Public callers do not manually write
|
|
330
|
+
internal tagged retry/retention objects or use choice callbacks.
|
|
331
|
+
|
|
332
|
+
- Counters use integer numbers.
|
|
333
|
+
- Timeout, lease duration and fixed retry delay use finite Effect `Duration`
|
|
334
|
+
values. Exact valid numeric ranges and execution defaults still need explicit
|
|
335
|
+
specification and qualification; example values are not approved defaults.
|
|
336
|
+
- Completed and Dead retention accept finite Effect `Duration` values or
|
|
337
|
+
`Duration.infinity`. Default Completed retention is 90 days; default Dead
|
|
338
|
+
retention is infinite. Retention is measured from terminal completion, not an
|
|
339
|
+
absolute timestamp. `availableAt` remains a separate enqueue input.
|
|
340
|
+
- The helper resolves a complete validated policy, including cross-field checks,
|
|
341
|
+
and the backend persists those resolved values in its versioned internal
|
|
342
|
+
representation. Updated defaults do not alter already stored jobs.
|
|
343
|
+
- Purging Completed jobs can remove deduplication evidence after the retention
|
|
344
|
+
period. Infinite retention avoids automatic expiry but can grow storage.
|
|
345
|
+
|
|
346
|
+
Illustrative public input:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
const policy = JobPolicy.make({
|
|
350
|
+
maxAttempts: 3,
|
|
351
|
+
retryDelay: Duration.seconds(5),
|
|
352
|
+
completedRetention: Duration.days(90),
|
|
353
|
+
deadRetention: Duration.infinity
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
`JobPolicy.make()` also resolves the documented defaults. This decision changes
|
|
358
|
+
the earlier proposal requiring explicit retention; it does not introduce
|
|
359
|
+
exponential retry, automatic Isolated cleanup, or unlimited Pending/Active cleanup.
|
|
360
|
+
|
|
361
|
+
The owner accepted these execution defaults (public names remain illustrative):
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
{
|
|
365
|
+
leaseDuration: Duration.seconds(90),
|
|
366
|
+
attemptTimeout: Duration.seconds(30),
|
|
367
|
+
retryDelay: Duration.seconds(5),
|
|
368
|
+
maxAttempts: 3,
|
|
369
|
+
maxStalledCount: 1
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The attempt limit includes the first finalized attempt, not three additional
|
|
374
|
+
retries. One stalled recovery is permitted by default; a subsequent recovery
|
|
375
|
+
exceeding the stored limit ends the job as Dead. Unknown outcomes can repeat an
|
|
376
|
+
attempt number and lead to additional handler invocations. An attempt timeout
|
|
377
|
+
does not prove that external effects did not occur.
|
|
378
|
+
|
|
379
|
+
The owner reaffirmed fixed-delay retry for this extraction. Exponential backoff
|
|
380
|
+
with jitter is recorded as a future strategy, not activated implementation scope.
|
|
381
|
+
Its multiplier, cap, jitter distribution and persisted/versioned representation
|
|
382
|
+
must be specified and qualified separately before adding it.
|
|
383
|
+
|
|
384
|
+
## D5 — explicit application-owned protection and codec boundaries
|
|
385
|
+
|
|
386
|
+
Status: accepted by the owner on 2026-10-04, including variant A, the separate-key
|
|
387
|
+
contract, deferred generic binding, and codec/equality direction. Tracked in
|
|
388
|
+
Multica as UPVE-856.
|
|
389
|
+
|
|
390
|
+
Choose explicit application-owned protection rather than automatic encryption
|
|
391
|
+
of Schema-marked subtrees. Producers call a protection service to seal sensitive
|
|
392
|
+
data before enqueue; handler adapters call it to open protected data. The
|
|
393
|
+
payload Schema describes the protected representation, not plaintext that the
|
|
394
|
+
queue would automatically encrypt.
|
|
395
|
+
|
|
396
|
+
Use `JobPayload.protected(...)` as the proposed honest marker name. The marker
|
|
397
|
+
identifies an already-protected envelope and keyed semantic fingerprint for the
|
|
398
|
+
codec; it does not encrypt, authenticate, or decrypt by itself. An arbitrary
|
|
399
|
+
plaintext value with a marker is not a valid substitute for the protected
|
|
400
|
+
representation. Domain services and protection adapters remain application-owned.
|
|
401
|
+
|
|
402
|
+
The queue core persists protected bytes and compares semantic fingerprints
|
|
403
|
+
without requiring key access. Application producers and handler adapters need
|
|
404
|
+
the protection service and its key configuration via ordinary Effect/Layer
|
|
405
|
+
composition. They may run in the same process as the queue; this is a dependency
|
|
406
|
+
and responsibility boundary, not a physical security isolation claim.
|
|
407
|
+
|
|
408
|
+
The owner subsequently selected protected representation option A: the
|
|
409
|
+
application defines the envelope and fingerprint Schemas. The protected marker
|
|
410
|
+
requires an own `fingerprint` field with a validated canonical JSON
|
|
411
|
+
representation; semantic equality uses that fingerprint and excludes the rest
|
|
412
|
+
of the marked subtree, including randomized ciphertext. The library does not
|
|
413
|
+
mandate a fixed encrypted-envelope format or a caller-supplied fingerprint
|
|
414
|
+
projection callback. The application remains responsible for sealing, opening
|
|
415
|
+
and cryptographic validation; the marker does not prove protection.
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
const Protected = JobPayload.protected(
|
|
419
|
+
Schema.Struct({
|
|
420
|
+
envelope: MyEncryptedEnvelope,
|
|
421
|
+
fingerprint: MyFingerprint
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
This API spelling is illustrative until implementation qualification. Existing
|
|
427
|
+
codec support and explicit unsupported-shape requirements below still apply.
|
|
428
|
+
|
|
429
|
+
Rationale: explicit encryption/decryption points, application control of keyring
|
|
430
|
+
or KMS, reusable protection services, and less new generic crypto machinery in
|
|
431
|
+
the focused extraction. The trade-off is additional producer/handler adapter
|
|
432
|
+
code; this choice is not inherently more secure than correctly implemented
|
|
433
|
+
automatic protection.
|
|
434
|
+
|
|
435
|
+
This refines D2: decoded domain payload does not mean automatically decrypted
|
|
436
|
+
plaintext. For protected fields, handlers receive the decoded protected envelope
|
|
437
|
+
and explicitly open it. The library must not silently persist plaintext under an
|
|
438
|
+
encryption marker. Existing auth protection is reference code, not a generic
|
|
439
|
+
implementation that can be copied unchanged.
|
|
440
|
+
|
|
441
|
+
Authenticated context binding (AAD) is deferred by explicit owner decision; it
|
|
442
|
+
is not a requirement of the generic library or its V1 protection API. Keep the
|
|
443
|
+
idea as a reference, not an implementation task. Codec boundaries are specified
|
|
444
|
+
below. The reproduced protected-subtree Union bug remains a required
|
|
445
|
+
regression fix. Automatic generic encryption is not required for the first
|
|
446
|
+
release. No implementation or agent dispatch is authorized by this choice.
|
|
447
|
+
|
|
448
|
+
### Deferred reference: authenticated context binding (AAD)
|
|
449
|
+
|
|
450
|
+
The owner found insufficient incremental value to justify introducing generic
|
|
451
|
+
context binding during extraction. Proposed scenarios included misassigned
|
|
452
|
+
ciphertexts during migrations and ciphertext substitution by a database writer
|
|
453
|
+
without key access. These were not accepted as sufficient motivation for added
|
|
454
|
+
library complexity. AAD does not protect against arbitrary database compromise
|
|
455
|
+
and is not a substitute for domain authority checks.
|
|
456
|
+
|
|
457
|
+
The following illustrates the deferred idea only, not an approved or existing
|
|
458
|
+
API. An application protection service could authenticate a canonical encoding
|
|
459
|
+
of context alongside ciphertext, using the same encryption key across jobs:
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
const example = Effect.gen(function* () {
|
|
463
|
+
const protectedCode = yield* protection.seal({
|
|
464
|
+
plaintext: otpCode,
|
|
465
|
+
aad: { purpose: "otp-delivery", challengeId: "challenge-A" }
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
const code = yield* protection.open({
|
|
469
|
+
protected: protectedCode,
|
|
470
|
+
aad: { purpose: "otp-delivery", challengeId: "challenge-A" }
|
|
471
|
+
}) // Matching context: succeeds.
|
|
472
|
+
|
|
473
|
+
yield* protection.open({
|
|
474
|
+
protected: protectedCode,
|
|
475
|
+
aad: { purpose: "otp-delivery", challengeId: "challenge-B" }
|
|
476
|
+
}) // Different expected context: authentication fails.
|
|
477
|
+
})
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Any benefit requires the expected context to be independently trustworthy;
|
|
481
|
+
copying ciphertext and all its expected context together defeats that check.
|
|
482
|
+
Deferring generic AAD does not disable authenticated encryption or remove
|
|
483
|
+
existing auth protections. In particular, do not silently strip the existing
|
|
484
|
+
OTP binding or break decryption of existing artifacts; any such consumer change
|
|
485
|
+
needs its own explicit decision and compatibility qualification. No new generic
|
|
486
|
+
AAD binding is to be added as part of this extraction.
|
|
487
|
+
|
|
488
|
+
### Separate encryption and fingerprint keys
|
|
489
|
+
|
|
490
|
+
The owner accepted two independent application-managed secrets: one for
|
|
491
|
+
encryption and one for keyed semantic fingerprints. A fingerprint is not the
|
|
492
|
+
deduplication identity: the producer tuple locates a duplicate, and fingerprints
|
|
493
|
+
help detect changed protected content under that same identity without giving
|
|
494
|
+
the queue access to decryption keys.
|
|
495
|
+
|
|
496
|
+
The fingerprint key stays stable while retained jobs must be compared with new
|
|
497
|
+
production attempts. Routine encryption-key rotation must not change it. All
|
|
498
|
+
producers that need to compare within that storage/protection namespace use
|
|
499
|
+
compatible fingerprint configuration. Applications may provide secrets through
|
|
500
|
+
environment configuration or a secret manager; storage contains key identifiers
|
|
501
|
+
and fingerprints, never the secret keys. No particular environment variable names
|
|
502
|
+
or key-management implementation are mandated by this decision.
|
|
503
|
+
|
|
504
|
+
Preserve old decryption keys for as long as retained encrypted artifacts require
|
|
505
|
+
them. Changing a fingerprint key does not erase data, but without compatibility
|
|
506
|
+
handling the same plaintext yields a different fingerprint and can be rejected
|
|
507
|
+
as a semantic conflict instead of AlreadyPresent. Removing required encryption
|
|
508
|
+
keys can independently make old ciphertext unreadable.
|
|
509
|
+
|
|
510
|
+
Automatic fingerprint-key rotation with cross-key semantic comparison is not a
|
|
511
|
+
V1 requirement. Key compromise or deliberate fingerprint-key replacement needs
|
|
512
|
+
an explicit operational procedure; no transparent transition is promised.
|
|
513
|
+
Deriving purpose-separated keys from a single master secret is not inherently
|
|
514
|
+
unsafe, but it is not the selected V1 configuration because master rotation
|
|
515
|
+
couples the two key lifecycles. Auth's existing envelope/keyring implementation
|
|
516
|
+
still requires adaptation and qualification rather than an unreviewed rewrite.
|
|
517
|
+
|
|
518
|
+
### Codec and equality contract
|
|
519
|
+
|
|
520
|
+
Allow finite JavaScript numbers, including fractional values, rather than
|
|
521
|
+
retaining the existing codec's safe-integer-only restriction. Reject NaN and
|
|
522
|
+
positive/negative Infinity before persistence. This does not provide exact
|
|
523
|
+
decimal arithmetic or approximate equality; applications can use integer minor
|
|
524
|
+
units or string-encoded decimal types when required.
|
|
525
|
+
|
|
526
|
+
Domain types may differ from persisted types (for example dates or bigints
|
|
527
|
+
encoded as strings). Support them only through Schemas with a persistable encoded
|
|
528
|
+
representation, not by silently coercing arbitrary JavaScript values. Encoding
|
|
529
|
+
and decoding service requirements remain explicit.
|
|
530
|
+
|
|
531
|
+
Use a deterministic canonical representation: object property order is
|
|
532
|
+
irrelevant, key ordering must not depend on locale, and array order is meaningful.
|
|
533
|
+
Compare an explicitly defined validated semantic representation, not mutable
|
|
534
|
+
lifecycle state or raw ciphertext. Decoded-domain equivalence and encoded-value
|
|
535
|
+
equivalence must not be silently interchanged across transformations.
|
|
536
|
+
|
|
537
|
+
Prefer Effect's `Schema.toEquivalence` where a structural comparator is needed,
|
|
538
|
+
with controlled fingerprint equivalence for protected values, rather than a
|
|
539
|
+
custom deep-equality implementation. Comparing correctly generated canonical
|
|
540
|
+
semantic-projection bytes also remains valid; this decision does not require
|
|
541
|
+
replacing that existing mechanism or honoring arbitrary user overrides that
|
|
542
|
+
ignore immutable fields. Equivalence is not serialization or validation.
|
|
543
|
+
|
|
544
|
+
Local runtime probes against Effect 4.0.0 confirmed order-independent struct
|
|
545
|
+
comparison, fractional-number equality, and fingerprint-only overrides inside
|
|
546
|
+
a tested Union. These probes do not qualify every Schema shape or repair the
|
|
547
|
+
existing projection traversal bug.
|
|
548
|
+
|
|
549
|
+
Support structs, arrays, optional fields, and unions with correct protected-field
|
|
550
|
+
handling. Transformations are supported only where encoded-side handling and
|
|
551
|
+
protected-field mapping can be made correct. Do not promise arbitrary custom or
|
|
552
|
+
recursive Schemas; unsupported shapes must fail explicitly rather than silently
|
|
553
|
+
skip protection. The known protected-subtree Union regression must be fixed.
|
|
554
|
+
|
|
555
|
+
Retain initial bounds: 64 KiB each for payload and semantic projection, depth 32,
|
|
556
|
+
at most 16 protected subtrees, and the existing 256-byte UTF-8 schema-path bound.
|
|
557
|
+
Exact supported-transform diagnostics and service provisioning must be specified
|
|
558
|
+
and tested before implementation is considered qualified. No new runtime API,
|
|
559
|
+
implementation, or agent dispatch is authorized by acceptance.
|
|
560
|
+
|
|
561
|
+
## D6 — transaction composition and integration boundaries
|
|
562
|
+
|
|
563
|
+
Status: composition contract accepted by the owner on 2026-10-04. Tracked in
|
|
564
|
+
Multica as UPVE-858. Concrete shipped adapter coverage still needs qualification.
|
|
565
|
+
|
|
566
|
+
The owner subsequently selected the backend callback join approach (option A).
|
|
567
|
+
The core transaction capability has a private constructor; a qualified backend
|
|
568
|
+
bridge provides it inside a callback joining the application's active transaction:
|
|
569
|
+
|
|
570
|
+
```ts
|
|
571
|
+
appDatabase.transaction((appTx) =>
|
|
572
|
+
PostgreSqlJobs.joinTransaction(appTx, (jobsTx) => definition.enqueue(jobsTx, input))
|
|
573
|
+
)
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
These names remain illustrative. The bridge uses the same active connection and
|
|
577
|
+
source, without a fallback connection, new transaction, savepoint, independent
|
|
578
|
+
commit or replay. The capability is invalidated when the callback scope ends,
|
|
579
|
+
including interruption; later enqueue must fail with an actionable error.
|
|
580
|
+
Custom backend adapters need an explicit documented extension contract and
|
|
581
|
+
qualification, not a public core constructor accepting any insert callback.
|
|
582
|
+
This is lifetime/atomicity correctness, not a sandbox for trusted application code.
|
|
583
|
+
|
|
584
|
+
### Two distinct capabilities
|
|
585
|
+
|
|
586
|
+
Keep `definition.enqueue(jobsTx, input)` as the canonical primitive: it uses the
|
|
587
|
+
supplied transaction and performs no BEGIN, COMMIT, ROLLBACK, connection borrowing,
|
|
588
|
+
or replay of domain operations.
|
|
589
|
+
|
|
590
|
+
A backend bridge joins an existing application transaction and exposes a scoped
|
|
591
|
+
job transaction capability. It does not open a transaction or create a savepoint.
|
|
592
|
+
Accept qualified transaction handles or an explicitly implemented adapter
|
|
593
|
+
contract, not arbitrary objects merely possessing a query method.
|
|
594
|
+
|
|
595
|
+
A configured `JobTransactions` integration may expose
|
|
596
|
+
`withTransaction((jobsTx) => body)`: delegate to the application transaction
|
|
597
|
+
manager to join a compatible active transaction or establish one when absent.
|
|
598
|
+
This is not a second transaction engine or a universal SQL client. Exact adapter
|
|
599
|
+
factory names are illustrative until specified and qualified.
|
|
600
|
+
|
|
601
|
+
### Composition examples
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
const program = Effect.gen(function* () {
|
|
605
|
+
const transactions = yield* JobTransactions
|
|
606
|
+
const billing = yield* Billing
|
|
607
|
+
|
|
608
|
+
return yield* transactions.withTransaction((jobsTx) =>
|
|
609
|
+
Effect.gen(function* () {
|
|
610
|
+
const invoice = yield* billing.insertInvoice(invoiceInput)
|
|
611
|
+
const result = yield* GenerateInvoiceV1.enqueue(jobsTx, {
|
|
612
|
+
producer,
|
|
613
|
+
payload: { invoiceId: invoice.id },
|
|
614
|
+
policy,
|
|
615
|
+
availableAt
|
|
616
|
+
})
|
|
617
|
+
yield* billing.insertReceipt(operationId, result.jobId)
|
|
618
|
+
return invoice
|
|
619
|
+
})
|
|
620
|
+
)
|
|
621
|
+
})
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
Billing and the jobs integration must actually share transaction infrastructure;
|
|
625
|
+
wrapping an Effect does not make an independently configured pool join. Running
|
|
626
|
+
this program within a compatible outer transaction joins without another BEGIN,
|
|
627
|
+
savepoint, or independent commit. Otherwise the configured manager establishes
|
|
628
|
+
the transaction.
|
|
629
|
+
|
|
630
|
+
An application with explicit handles can retain its own transaction runner:
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
const program = appDatabase.transaction((appTx) =>
|
|
634
|
+
PostgreSqlJobs.joinTransaction(appTx, (jobsTx) =>
|
|
635
|
+
Effect.gen(function* () {
|
|
636
|
+
yield* insertInvoice(appTx, invoice)
|
|
637
|
+
const result = yield* GenerateInvoiceV1.enqueue(jobsTx, input)
|
|
638
|
+
yield* insertReceipt(appTx, operationId, result.jobId)
|
|
639
|
+
})
|
|
640
|
+
)
|
|
641
|
+
)
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
These examples describe API direction, not existing exports or blanket support
|
|
645
|
+
for every application's transaction handle.
|
|
646
|
+
|
|
647
|
+
### Auth and other application integrations
|
|
648
|
+
|
|
649
|
+
The auth adapter delegates join-or-establish behavior to its existing
|
|
650
|
+
`PostgreSqlExecutor`, using the same connection-source service, rather than
|
|
651
|
+
copying the executor into jobs. The current auth implementation joins same-source
|
|
652
|
+
transactions without savepoints and disables local mutation replay/reconciliation
|
|
653
|
+
when joining. Matching connection strings alone do not establish shared scope.
|
|
654
|
+
|
|
655
|
+
Other applications can supply backend transaction bridges and manager adapters
|
|
656
|
+
without depending on auth. Effect SQL integration must join its actual active
|
|
657
|
+
transaction connection, not call nested `withTransaction` merely to enqueue:
|
|
658
|
+
Effect SQL 4 normally uses savepoints for nested wrappers. Drizzle integration
|
|
659
|
+
must execute and await the Effect within its owning transaction callback.
|
|
660
|
+
These are integration requirements, not claims of qualified adapter support.
|
|
661
|
+
|
|
662
|
+
### Ownership and failure semantics
|
|
663
|
+
|
|
664
|
+
The outer owner controls commit, rollback, and replay of the complete business
|
|
665
|
+
operation. Joined operations do not independently replay or reconcile the outer
|
|
666
|
+
commit. Unknown commit knowledge stays unknown and requires application-owned
|
|
667
|
+
workflow reconciliation. Inner success is provisional until outer commit.
|
|
668
|
+
|
|
669
|
+
Sequence operations sharing a transaction; keep transaction capabilities scoped
|
|
670
|
+
to the owning invocation, not application-lifetime Layers. A bridge must not
|
|
671
|
+
silently fall back to a different connection when asked to join a particular
|
|
672
|
+
transaction. Application-owned pools and migrations remain outside the queue;
|
|
673
|
+
workers must not close borrowed pools or run DDL on startup.
|
|
674
|
+
|
|
675
|
+
### One-off production and deferred convenience
|
|
676
|
+
|
|
677
|
+
One-off production can explicitly use the configured wrapper even without domain
|
|
678
|
+
writes to compose:
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
const program = Effect.gen(function* () {
|
|
682
|
+
const transactions = yield* JobTransactions
|
|
683
|
+
return yield* transactions.withTransaction((jobsTx) =>
|
|
684
|
+
GenerateInvoiceV1.enqueue(jobsTx, input)
|
|
685
|
+
)
|
|
686
|
+
})
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
The owner withdrew the subsequent `unsafeEnqueue` question and accepted the
|
|
690
|
+
preceding explicit-transaction design. Do not add `unsafeEnqueue`, a one-argument
|
|
691
|
+
enqueue overload, or a non-atomic fast path as part of this decision. D1's
|
|
692
|
+
deferred standalone convenience scope remains unchanged.
|
|
693
|
+
|
|
694
|
+
Before backend implementation, specify and qualify the concrete driver/bridge,
|
|
695
|
+
Layer construction, scoped-handle behavior, source sharing, commit-loss handling,
|
|
696
|
+
and rollback guarantees. The initial recommendation is to qualify the existing
|
|
697
|
+
auth/pg execution integration first, without making it a dependency of core or
|
|
698
|
+
claiming untested Effect SQL/Drizzle adapter support. No implementation, dispatch,
|
|
699
|
+
or publication is authorized by this decision.
|
|
700
|
+
|
|
701
|
+
## D7 — new generic PostgreSQL storage without legacy compatibility
|
|
702
|
+
|
|
703
|
+
Status: accepted by the owner on 2026-10-04. Tracked in Multica as UPVE-859.
|
|
704
|
+
The owner confirmed effect-auth has no users or data requiring preservation.
|
|
705
|
+
|
|
706
|
+
Choose variant B: design the generic storage directly, without an auth-specific
|
|
707
|
+
legacy adapter, historical-data backfill, dual reader/writer, or parallel queue
|
|
708
|
+
runtime. This is a breaking alpha storage change for new installations, not
|
|
709
|
+
authorization to delete data or reset a database.
|
|
710
|
+
|
|
711
|
+
### Storage structure
|
|
712
|
+
|
|
713
|
+
Use two generic relations:
|
|
714
|
+
|
|
715
|
+
- `jobs`: generated job ID, producer identity, catalog identity, persisted policy,
|
|
716
|
+
availability, and mutable lifecycle state, counters, and lease ownership.
|
|
717
|
+
- `job_payloads`: immutable encoded payload and its semantic projection, linked
|
|
718
|
+
to the parent job. Lifecycle attempts do not rewrite the payload.
|
|
719
|
+
|
|
720
|
+
Do not add auth-specific user/challenge/event fields to the generic table.
|
|
721
|
+
Domain data remains in the application payload or application-owned relations.
|
|
722
|
+
Auth keeps its domain authority, definitions, handlers, receipts, and transactions
|
|
723
|
+
that atomically compose domain, audit, job, and receipt writes.
|
|
724
|
+
|
|
725
|
+
### Schema integration and migration ownership
|
|
726
|
+
|
|
727
|
+
Provide optional Drizzle schema declarations with configurable namespace and
|
|
728
|
+
table names. The library defines required columns, constraints, and indexes;
|
|
729
|
+
arbitrary per-column remapping is not required. Core does not depend on Drizzle.
|
|
730
|
+
Backend and migration configuration must agree on the same storage mapping.
|
|
731
|
+
|
|
732
|
+
Proposed API direction, not existing or finalized exports:
|
|
733
|
+
|
|
734
|
+
```ts
|
|
735
|
+
import { makeJobTables } from "effect-jobs/PostgreSqlDrizzleSchema"
|
|
736
|
+
|
|
737
|
+
export const jobTables = makeJobTables({
|
|
738
|
+
schema: "public",
|
|
739
|
+
jobsTable: "jobs",
|
|
740
|
+
payloadsTable: "job_payloads"
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
const JobsLayer = PostgreSqlJobs.layer({
|
|
744
|
+
connectionSource,
|
|
745
|
+
tables: jobTables
|
|
746
|
+
})
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
Applications generate and run migrations. Runtime must not create, migrate, or
|
|
750
|
+
rebuild tables automatically. Concrete DDL, schema readiness checks, mapping
|
|
751
|
+
types, and backend constructors still need specification and qualification.
|
|
752
|
+
Optional Drizzle integration must not impose Drizzle on applications using a
|
|
753
|
+
different migration tool.
|
|
754
|
+
|
|
755
|
+
### Compatibility and qualification
|
|
756
|
+
|
|
757
|
+
There is no obligation to migrate historical Pending/Active/Completed/Dead/
|
|
758
|
+
Isolated rows or old receipts under the confirmed deployment profile. A physical
|
|
759
|
+
storage change alone does not change a job's catalog version; changes to its
|
|
760
|
+
semantic contract must be evaluated separately.
|
|
761
|
+
|
|
762
|
+
Tests must still qualify atomicity, concurrent production, conflict handling,
|
|
763
|
+
ownership fencing, recovery, packed imports, and migration/readiness behavior.
|
|
764
|
+
Absence of existing consumers removes upgrade machinery, not correctness
|
|
765
|
+
requirements. Acceptance does not authorize implementation, agent dispatch,
|
|
766
|
+
database resets, commit, push, or publication.
|
|
767
|
+
|
|
768
|
+
## D8 — replaceable cleanup with application-domain retention integration
|
|
769
|
+
|
|
770
|
+
Status: variant A and terminal-state rules accepted by the owner on 2026-10-04.
|
|
771
|
+
Tracked in Multica as UPVE-862.
|
|
772
|
+
|
|
773
|
+
### Decision and ownership
|
|
774
|
+
|
|
775
|
+
Provide a replaceable `JobCleanup` port with a bounded cleanup operation. Provide
|
|
776
|
+
a generic PostgreSQL implementation for installations without additional domain
|
|
777
|
+
references, and an application-owned implementation for domains that need to
|
|
778
|
+
coordinate jobs with receipts or other relations. Exact service and constructor
|
|
779
|
+
names are proposed, not existing exports.
|
|
780
|
+
|
|
781
|
+
Effect-auth retains a PostgreSQL cleanup adapter that understands registration
|
|
782
|
+
receipts and OTP challenge/issue-receipt relationships while using the generic
|
|
783
|
+
jobs storage. Do not move auth-specific cleanup SQL into the generic package or
|
|
784
|
+
install generic cleanup alongside the auth adapter in a way that bypasses its
|
|
785
|
+
retention checks. An application with domain references must supply a coordinated
|
|
786
|
+
adapter rather than assume generic cleanup knows those references.
|
|
787
|
+
|
|
788
|
+
Do not introduce generic retention holds or their additional persistence and
|
|
789
|
+
lifecycle machinery in V1. The adapter is a qualified backend integration, not
|
|
790
|
+
an arbitrary SQL callback claimed to be safe merely because it implements a port.
|
|
791
|
+
|
|
792
|
+
### Terminal-state rules
|
|
793
|
+
|
|
794
|
+
- Pending and Active jobs are never cleanup candidates.
|
|
795
|
+
- Completed jobs can be removed after their persisted retention period and
|
|
796
|
+
applicable domain protection conditions are satisfied.
|
|
797
|
+
- Dead jobs require explicit retention policy; do not infer a deletion period.
|
|
798
|
+
- Isolated jobs are never automatically purged.
|
|
799
|
+
|
|
800
|
+
Retention supports explicit duration or Forever choices where applicable. The
|
|
801
|
+
illustrative 30-day duration is not an approved mandatory default. Retention is
|
|
802
|
+
persisted execution configuration subject to D3's first-write-wins rule, not a
|
|
803
|
+
value silently replaced by a new deployment or duplicate enqueue.
|
|
804
|
+
|
|
805
|
+
### Deduplication and reference safety
|
|
806
|
+
|
|
807
|
+
Retention must cover the application's required deduplication window. Once all
|
|
808
|
+
durable evidence of a producer identity is removed, re-enqueue can create a new
|
|
809
|
+
job; no permanent deduplication guarantee is made. Receipts still needed for
|
|
810
|
+
reconciliation or idempotent response replay must protect associated jobs for
|
|
811
|
+
their required lifetime.
|
|
812
|
+
|
|
813
|
+
Coordinate eligibility checks, applicable expired-receipt removal, and terminal
|
|
814
|
+
job deletion atomically, with a specified lock order and structural referential
|
|
815
|
+
integrity. Do not check references separately and later delete without guarding
|
|
816
|
+
the intervening race. The current auth cleanup SQL is prior art and must be
|
|
817
|
+
adapted and qualified rather than blindly copied.
|
|
818
|
+
|
|
819
|
+
### Maintenance and qualification
|
|
820
|
+
|
|
821
|
+
Applications explicitly invoke cleanup, for example through a scheduled
|
|
822
|
+
maintenance program; imports and handler installation do not start deletion.
|
|
823
|
+
The operation is bounded by a caller-supplied limit. Proposed composition:
|
|
824
|
+
|
|
825
|
+
```ts
|
|
826
|
+
const maintenance = Effect.gen(function* () {
|
|
827
|
+
const cleanup = yield* JobCleanup
|
|
828
|
+
return yield* cleanup.cleanup({ limit: 100 })
|
|
829
|
+
})
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
Applications select the generic PostgreSQL implementation or their domain
|
|
833
|
+
implementation through ordinary Layer composition, not two independent cleanup
|
|
834
|
+
paths over the same protected jobs. Maintenance must use database time and the
|
|
835
|
+
configured connection infrastructure.
|
|
836
|
+
|
|
837
|
+
Before qualification, specify exact retention fields, deletion authority, lock
|
|
838
|
+
order, and adapter setup. Test cleanup racing with producer retries, receipt
|
|
839
|
+
protection/expiry, bounded deletion, payload-parent deletion, and exclusion of
|
|
840
|
+
Active/Pending/Isolated jobs. Acceptance closes the architecture decision, not
|
|
841
|
+
runtime qualification, and does not authorize implementation or agent dispatch.
|