@abloatai/transaction 0.55.0 → 0.57.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/CONVENTIONS.md +34 -0
- package/dist/auth/hostedEndpoints.d.ts +21 -5
- package/dist/auth/hostedEndpoints.d.ts.map +1 -1
- package/dist/auth/hostedEndpoints.js +21 -5
- package/dist/auth/hostedEndpoints.js.map +1 -1
- package/dist/auth/index.d.ts +1 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.d.ts.map +1 -1
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/index.js.map +1 -1
- package/dist/coordination/schema.d.ts +0 -3
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +4 -5
- package/dist/coordination/schema.js.map +1 -1
- package/dist/errorCodes.d.ts +1 -0
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +1 -0
- package/dist/errorCodes.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +4 -1
- package/dist/errors.js.map +1 -1
- package/dist/log/syncDeltaRow.d.ts +3 -3
- package/dist/readSetContext.d.ts.map +1 -1
- package/dist/readSetContext.js +8 -3
- package/dist/readSetContext.js.map +1 -1
- package/dist/resources/httpResources.d.ts +43 -22
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/resources/httpResources.js +81 -4
- package/dist/resources/httpResources.js.map +1 -1
- package/dist/resources/modelCreate.d.ts +21 -0
- package/dist/resources/modelCreate.d.ts.map +1 -0
- package/dist/resources/modelCreate.js +49 -0
- package/dist/resources/modelCreate.js.map +1 -0
- package/dist/resources/modelOperations.d.ts +24 -2
- package/dist/resources/modelOperations.d.ts.map +1 -1
- package/dist/resources/modelOperations.js.map +1 -1
- package/dist/resources/writeOptionsSchema.d.ts +14 -0
- package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
- package/dist/resources/writeOptionsSchema.js +23 -2
- package/dist/resources/writeOptionsSchema.js.map +1 -1
- package/dist/schema/audit.d.ts +15 -0
- package/dist/schema/audit.d.ts.map +1 -0
- package/dist/schema/audit.js +90 -0
- package/dist/schema/audit.js.map +1 -0
- package/dist/schema/ddl.d.ts.map +1 -1
- package/dist/schema/ddl.js +68 -1
- package/dist/schema/ddl.js.map +1 -1
- package/dist/schema/index.d.ts +3 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +3 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/model.d.ts +14 -0
- package/dist/schema/model.d.ts.map +1 -1
- package/dist/schema/model.js +2 -0
- package/dist/schema/model.js.map +1 -1
- package/dist/schema/openapi.d.ts.map +1 -1
- package/dist/schema/openapi.js +104 -2
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/roles.d.ts +27 -0
- package/dist/schema/roles.d.ts.map +1 -1
- package/dist/schema/roles.js +40 -0
- package/dist/schema/roles.js.map +1 -1
- package/dist/schema/schema.d.ts +23 -4
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/schema.js +30 -1
- package/dist/schema/schema.js.map +1 -1
- package/dist/schema/serialize.d.ts +4 -1
- package/dist/schema/serialize.d.ts.map +1 -1
- package/dist/schema/serialize.js +5 -1
- package/dist/schema/serialize.js.map +1 -1
- package/dist/schema/subject.d.ts +18 -0
- package/dist/schema/subject.d.ts.map +1 -0
- package/dist/schema/subject.js +27 -0
- package/dist/schema/subject.js.map +1 -0
- package/dist/server/adapter.d.ts +2 -0
- package/dist/server/adapter.d.ts.map +1 -1
- package/dist/server/readConfig.d.ts +3 -0
- package/dist/server/readConfig.d.ts.map +1 -1
- package/dist/server/readConfig.js +0 -21
- package/dist/server/readConfig.js.map +1 -1
- package/dist/source/adapters/drizzle.d.ts.map +1 -1
- package/dist/source/adapters/drizzle.js +37 -6
- package/dist/source/adapters/drizzle.js.map +1 -1
- package/dist/source/adapters/kysely.d.ts +2 -0
- package/dist/source/adapters/kysely.d.ts.map +1 -1
- package/dist/source/adapters/kysely.js +33 -5
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
- package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
- package/dist/source/adapters/kyselyMutationCore.js +17 -8
- package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
- package/dist/source/adapters/memory.d.ts.map +1 -1
- package/dist/source/adapters/memory.js +1 -0
- package/dist/source/adapters/memory.js.map +1 -1
- package/dist/source/adapters/prisma.d.ts.map +1 -1
- package/dist/source/adapters/prisma.js +59 -7
- package/dist/source/adapters/prisma.js.map +1 -1
- package/dist/source/contract.d.ts +14 -0
- package/dist/source/contract.d.ts.map +1 -1
- package/dist/source/contract.js +10 -0
- package/dist/source/contract.js.map +1 -1
- package/dist/source/factory.d.ts +7 -1
- package/dist/source/factory.d.ts.map +1 -1
- package/dist/source/factory.js +116 -5
- package/dist/source/factory.js.map +1 -1
- package/dist/source/index.d.ts +2 -1
- package/dist/source/index.d.ts.map +1 -1
- package/dist/source/index.js +1 -0
- package/dist/source/index.js.map +1 -1
- package/dist/source/migrations.d.ts.map +1 -1
- package/dist/source/migrations.js +21 -0
- package/dist/source/migrations.js.map +1 -1
- package/dist/source/subjectAuthorization.d.ts +16 -0
- package/dist/source/subjectAuthorization.d.ts.map +1 -0
- package/dist/source/subjectAuthorization.js +126 -0
- package/dist/source/subjectAuthorization.js.map +1 -0
- package/dist/source/types.d.ts +52 -2
- package/dist/source/types.d.ts.map +1 -1
- package/dist/source/types.js +1 -0
- package/dist/source/types.js.map +1 -1
- package/dist/syncLog/contract.d.ts +45 -3
- package/dist/syncLog/contract.d.ts.map +1 -1
- package/dist/syncLog/contract.js +46 -4
- package/dist/syncLog/contract.js.map +1 -1
- package/dist/testing/fixtures/httpResponses.d.ts +6 -0
- package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
- package/dist/testing/fixtures/httpResponses.js +1 -0
- package/dist/testing/fixtures/httpResponses.js.map +1 -1
- package/dist/transport/httpClient.d.ts +8 -1
- package/dist/transport/httpClient.d.ts.map +1 -1
- package/dist/transport/httpClient.js +51 -20
- package/dist/transport/httpClient.js.map +1 -1
- package/dist/transport/httpCommitRequest.d.ts +45 -0
- package/dist/transport/httpCommitRequest.d.ts.map +1 -0
- package/dist/transport/httpCommitRequest.js +67 -0
- package/dist/transport/httpCommitRequest.js.map +1 -0
- package/dist/transport/httpTransport.d.ts.map +1 -1
- package/dist/transport/httpTransport.js +69 -106
- package/dist/transport/httpTransport.js.map +1 -1
- package/dist/transport/httpTransportHelpers.d.ts +10 -0
- package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
- package/dist/transport/httpTransportHelpers.js +46 -0
- package/dist/transport/httpTransportHelpers.js.map +1 -0
- package/dist/wire/apiLifecycle.d.ts +63 -0
- package/dist/wire/apiLifecycle.d.ts.map +1 -0
- package/dist/wire/apiLifecycle.js +89 -0
- package/dist/wire/apiLifecycle.js.map +1 -0
- package/dist/wire/auth.d.ts.map +1 -1
- package/dist/wire/auth.js +6 -3
- package/dist/wire/auth.js.map +1 -1
- package/dist/wire/commit.d.ts +74 -9
- package/dist/wire/commit.d.ts.map +1 -1
- package/dist/wire/commit.js +18 -5
- package/dist/wire/commit.js.map +1 -1
- package/dist/wire/index.d.ts +5 -2
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +12 -1
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/modelMutations.js +3 -2
- package/dist/wire/modelMutations.js.map +1 -1
- package/dist/wire/rateLimit.d.ts +82 -0
- package/dist/wire/rateLimit.d.ts.map +1 -0
- package/dist/wire/rateLimit.js +142 -0
- package/dist/wire/rateLimit.js.map +1 -0
- package/package.json +1 -1
- package/src/auth/hostedEndpoints.ts +23 -5
- package/src/auth/index.ts +2 -0
- package/src/coordination/index.ts +0 -2
- package/src/coordination/schema.ts +4 -7
- package/src/errorCodes.ts +6 -0
- package/src/errors.ts +4 -1
- package/src/readSetContext.ts +8 -2
- package/src/resources/httpResources.ts +116 -25
- package/src/resources/modelCreate.ts +73 -0
- package/src/resources/modelOperations.ts +29 -2
- package/src/resources/writeOptionsSchema.ts +30 -2
- package/src/schema/audit.ts +121 -0
- package/src/schema/ddl.ts +71 -1
- package/src/schema/index.ts +16 -0
- package/src/schema/model.ts +17 -0
- package/src/schema/openapi.ts +123 -2
- package/src/schema/roles.ts +53 -0
- package/src/schema/schema.ts +68 -3
- package/src/schema/serialize.ts +8 -1
- package/src/schema/subject.ts +43 -0
- package/src/server/adapter.ts +2 -0
- package/src/server/readConfig.ts +4 -0
- package/src/source/adapters/drizzle.ts +50 -6
- package/src/source/adapters/kysely.ts +47 -4
- package/src/source/adapters/kyselyMutationCore.ts +21 -9
- package/src/source/adapters/memory.ts +1 -0
- package/src/source/adapters/prisma.ts +70 -7
- package/src/source/contract.ts +11 -0
- package/src/source/factory.ts +141 -5
- package/src/source/index.ts +6 -0
- package/src/source/migrations.ts +21 -0
- package/src/source/subjectAuthorization.ts +182 -0
- package/src/source/types.ts +56 -2
- package/src/syncLog/contract.ts +47 -5
- package/src/testing/fixtures/httpResponses.ts +7 -0
- package/src/transport/httpClient.ts +85 -23
- package/src/transport/httpCommitRequest.ts +104 -0
- package/src/transport/httpTransport.ts +92 -142
- package/src/transport/httpTransportHelpers.ts +63 -0
- package/src/wire/apiLifecycle.ts +94 -0
- package/src/wire/auth.ts +6 -3
- package/src/wire/commit.ts +20 -5
- package/src/wire/index.ts +31 -0
- package/src/wire/modelMutations.ts +2 -2
- package/src/wire/rateLimit.ts +155 -0
package/src/source/types.ts
CHANGED
|
@@ -80,8 +80,9 @@ export type SourceListResult<Row> =
|
|
|
80
80
|
* see. Ablo attaches this so your `authorize` and model handlers can reject
|
|
81
81
|
* calls that fall outside the participant's permitted sync groups.
|
|
82
82
|
*
|
|
83
|
-
*
|
|
84
|
-
* handlers
|
|
83
|
+
* The ORM adapters enforce schema-declared subject rules from this trusted
|
|
84
|
+
* scope. Custom handlers receive the same signed facts; Ablo also checks their
|
|
85
|
+
* returned rows and preflights writes before forwarding the customer transaction.
|
|
85
86
|
*/
|
|
86
87
|
export interface SourceRequestContext {
|
|
87
88
|
readonly participantId?: string;
|
|
@@ -91,6 +92,14 @@ export interface SourceRequestContext {
|
|
|
91
92
|
readonly branchId?: string;
|
|
92
93
|
/** Trusted project selected by the authenticating credential. */
|
|
93
94
|
readonly projectId?: string;
|
|
95
|
+
readonly syncGroups?: readonly string[];
|
|
96
|
+
/**
|
|
97
|
+
* @deprecated Renamed to {@link SourceRequestContext.syncGroups}. Removed in
|
|
98
|
+
* 0.58.0. Ablo populates both spellings with the same value for this release,
|
|
99
|
+
* so an adapter still reading this one gets the groups rather than
|
|
100
|
+
* `undefined`, which on a routing field would read as "no groups" rather than
|
|
101
|
+
* as a missing field.
|
|
102
|
+
*/
|
|
94
103
|
readonly requiredSyncGroups?: readonly string[];
|
|
95
104
|
}
|
|
96
105
|
|
|
@@ -122,6 +131,8 @@ export interface SourceDelta {
|
|
|
122
131
|
readonly type: SourceOperation['type'];
|
|
123
132
|
/** The changed row, in the key shape {@link SourceEvent.data} defines. */
|
|
124
133
|
readonly data?: Record<string, unknown> | null;
|
|
134
|
+
/** Exact record routes derived inside the write transaction. */
|
|
135
|
+
readonly syncGroups?: readonly string[];
|
|
125
136
|
readonly transactionId?: string | null;
|
|
126
137
|
}
|
|
127
138
|
|
|
@@ -162,6 +173,8 @@ export interface SourceEvent {
|
|
|
162
173
|
* a tenant boundary.
|
|
163
174
|
*/
|
|
164
175
|
readonly data?: Record<string, unknown> | null;
|
|
176
|
+
/** Exact record routes captured while the changed row was transactionally visible. */
|
|
177
|
+
readonly syncGroups: readonly string[];
|
|
165
178
|
/**
|
|
166
179
|
* The tenant this event belongs to. Populate it from the row's organization
|
|
167
180
|
* column for multi-tenant data; a single-tenant source may omit it and let
|
|
@@ -206,6 +219,8 @@ export interface SourceEventForOperationOptions {
|
|
|
206
219
|
* valid but leaves less for clients to hydrate from in realtime.
|
|
207
220
|
*/
|
|
208
221
|
readonly data?: Record<string, unknown> | null;
|
|
222
|
+
/** Exact record routes derived inside the write transaction. */
|
|
223
|
+
readonly syncGroups: readonly string[];
|
|
209
224
|
/**
|
|
210
225
|
* @deprecated Legacy echo identity. Prefer the explicit correlation fields.
|
|
211
226
|
*/
|
|
@@ -243,6 +258,7 @@ export function sourceEventForOperation(
|
|
|
243
258
|
entityId,
|
|
244
259
|
type: options.operation.type,
|
|
245
260
|
...(options.data !== undefined ? { data: options.data } : {}),
|
|
261
|
+
syncGroups: options.syncGroups,
|
|
246
262
|
...(options.organizationId ? { organizationId: options.organizationId } : {}),
|
|
247
263
|
...(options.clientTxId ? { clientTxId: options.clientTxId } : {}),
|
|
248
264
|
...(options.correlationId ? { correlationId: options.correlationId } : {}),
|
|
@@ -423,6 +439,18 @@ export interface SourceModelHandlers<Row, CreateInput, TAuth = unknown> {
|
|
|
423
439
|
| Promise<SourceListResult<Row>>
|
|
424
440
|
| SourceListResult<Row>;
|
|
425
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Subject-aware list boundary. The handler MUST apply `subject.values` in
|
|
444
|
+
* its database predicate before ordering, cursoring, or limiting. A model
|
|
445
|
+
* declaring `subject` is served only through this hook; plain `list()` is
|
|
446
|
+
* deliberately not accepted because post-page filtering is incomplete.
|
|
447
|
+
*/
|
|
448
|
+
subjectList?(params: {
|
|
449
|
+
readonly query: SourceListQuery;
|
|
450
|
+
readonly subject: { readonly field: string; readonly values: readonly string[] };
|
|
451
|
+
readonly context: SourceHandlerContext<TAuth>;
|
|
452
|
+
}): Promise<SourceListResult<Row>> | SourceListResult<Row>;
|
|
453
|
+
|
|
426
454
|
/**
|
|
427
455
|
* Apply one or more operations for this model within your own database
|
|
428
456
|
* transaction. Your handler must be idempotent on the scoped server
|
|
@@ -446,6 +474,32 @@ export type SourceCommitHandler<TAuth = unknown> = (
|
|
|
446
474
|
params: SourceCommitParams<TAuth>,
|
|
447
475
|
) => Promise<SourceCommitResult> | SourceCommitResult;
|
|
448
476
|
|
|
477
|
+
export interface SourceSubjectTransactionBoundary {
|
|
478
|
+
/**
|
|
479
|
+
* Serialize a caller-selected CREATE id even when no row exists yet. This
|
|
480
|
+
* must be an absent-key-capable transaction lock (for example a Postgres
|
|
481
|
+
* transaction advisory lock), acquired on the SAME transaction as load and
|
|
482
|
+
* commit. It coordinates framework writers; `commit` must still reject a
|
|
483
|
+
* database uniqueness conflict as `entity_already_exists`, because external
|
|
484
|
+
* writers do not participate in advisory locking.
|
|
485
|
+
*/
|
|
486
|
+
readonly lockCreate: (operation: SourceOperation) => Promise<void>;
|
|
487
|
+
/** Lock and return the current row on the SAME transaction used by commit. */
|
|
488
|
+
readonly load: (operation: SourceOperation) => Promise<Record<string, unknown> | null>;
|
|
489
|
+
/** Apply the complete batch on that transaction after authorization succeeds. */
|
|
490
|
+
readonly commit: () => Promise<SourceCommitResult>;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Transaction owner for hand-written subject-scoped commits. Open one database
|
|
495
|
+
* transaction, then call `run` with load/commit functions bound to it. `run`
|
|
496
|
+
* performs authorization first and invokes commit only after it succeeds.
|
|
497
|
+
*/
|
|
498
|
+
export type SourceSubjectTransactionHandler<TAuth = unknown> = (
|
|
499
|
+
params: SourceCommitParams<TAuth>,
|
|
500
|
+
run: (boundary: SourceSubjectTransactionBoundary) => Promise<SourceCommitResult>,
|
|
501
|
+
) => Promise<SourceCommitResult>;
|
|
502
|
+
|
|
449
503
|
export type SourceApiKey =
|
|
450
504
|
| string
|
|
451
505
|
| ((context: SourceAuthorizeContext) => Promise<string> | string);
|
package/src/syncLog/contract.ts
CHANGED
|
@@ -1,9 +1,51 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Canonical sync-log identity and position contracts shared by client/server.
|
|
3
|
+
*
|
|
4
|
+
* ## A log position means ONE thing
|
|
5
|
+
*
|
|
6
|
+
* It is an ordinal in `sync_deltas`: a point in the single, totally ordered
|
|
7
|
+
* log. Nothing else. Every number in this engine that answers "how far along"
|
|
8
|
+
* is this type, and there is one definition of it, below.
|
|
9
|
+
*
|
|
10
|
+
* What varies is never the meaning. It is **who is making the claim**, and the
|
|
11
|
+
* owner belongs in the field name, never in a second type:
|
|
12
|
+
*
|
|
13
|
+
* | Owner | Claims |
|
|
14
|
+
* | ---------------------------------- | ----------------------------------------- |
|
|
15
|
+
* | `current_settled_sync_id()` | nothing at or below is still in flight |
|
|
16
|
+
* | `getCommittedSyncId` | committed, without waiting for the barrier|
|
|
17
|
+
* | `sync_broadcast_cursors.published_through` | the fan-out published through |
|
|
18
|
+
* | `Client.lastSentSyncId` | this recipient was offered through |
|
|
19
|
+
* | `Client.lastAckedSyncId` | this recipient confirmed through |
|
|
20
|
+
* | `LogPosition.applied` | the client processed on arrival through |
|
|
21
|
+
* | `LogPosition.persisted` | the client durably holds through, in
|
|
22
|
+
* DELIVERED order, not numeric order |
|
|
23
|
+
* | `RowWatermarks` | this one row reflects the log through |
|
|
24
|
+
* | `sync_state_checkpoint.up_to_sync_id` | the checkpoint folded through |
|
|
25
|
+
*
|
|
26
|
+
* ## The comparison that is not safe
|
|
27
|
+
*
|
|
28
|
+
* `current_settled_sync_id` takes a per-plane advisory lock but reads the
|
|
29
|
+
* GLOBAL sequence, so its value moves for writers in other planes. Every other
|
|
30
|
+
* owner above is scoped: to a plane, a project, a recipient, or a row. "The
|
|
31
|
+
* head of the log" and "how far I have been served" are therefore different
|
|
32
|
+
* questions, and a scoped cursor sitting below the global head is the normal
|
|
33
|
+
* resting state, not a gap.
|
|
34
|
+
*
|
|
35
|
+
* Treating one as the other is what left every client permanently short of its
|
|
36
|
+
* own head, re-taking the plane lock on every catch-up poll
|
|
37
|
+
* (docs/plans/delivery-verify-at-read.md). Before comparing two positions, say
|
|
38
|
+
* out loud which owner each belongs to.
|
|
39
|
+
*/
|
|
2
40
|
|
|
3
41
|
import { z } from 'zod';
|
|
4
42
|
|
|
5
|
-
|
|
6
|
-
|
|
43
|
+
/**
|
|
44
|
+
* A point in the sync log. The one definition; every other position name in
|
|
45
|
+
* the codebase derives from this one rather than restating the primitive.
|
|
46
|
+
*/
|
|
47
|
+
export const logPositionSchema = z.number().int().nonnegative();
|
|
48
|
+
export type LogPosition = z.infer<typeof logPositionSchema>;
|
|
7
49
|
|
|
8
50
|
export const clientMutationIdSchema = z.string().min(1).max(255);
|
|
9
51
|
export type ClientMutationId = z.infer<typeof clientMutationIdSchema>;
|
|
@@ -21,8 +63,8 @@ export const commitDispatchMarkerSchema = z.strictObject({
|
|
|
21
63
|
kind: z.literal('sync_deltas'),
|
|
22
64
|
organizationId: z.string().min(1),
|
|
23
65
|
branchId: z.string().min(1),
|
|
24
|
-
firstSyncId:
|
|
25
|
-
lastSyncId:
|
|
66
|
+
firstSyncId: logPositionSchema,
|
|
67
|
+
lastSyncId: logPositionSchema,
|
|
26
68
|
}).superRefine((value, ctx) => {
|
|
27
69
|
if (value.firstSyncId <= 0 || value.lastSyncId < value.firstSyncId) {
|
|
28
70
|
ctx.addIssue({ code: 'custom', message: 'invalid delta position range' });
|
|
@@ -53,6 +53,12 @@ export function confirmedCommitReceiptResponse(args: {
|
|
|
53
53
|
lastSyncId?: number;
|
|
54
54
|
serverTxId?: string;
|
|
55
55
|
ops?: number;
|
|
56
|
+
/** The exact rows the writing transaction returned, one per operation. */
|
|
57
|
+
operationResults?: {
|
|
58
|
+
transactionId: string;
|
|
59
|
+
outcome: 'created' | 'updated' | 'deleted' | 'archived' | 'unarchived';
|
|
60
|
+
row: Record<string, unknown>;
|
|
61
|
+
}[];
|
|
56
62
|
}): CommitReceiptWire {
|
|
57
63
|
const lastSyncId = args.lastSyncId ?? 0;
|
|
58
64
|
return {
|
|
@@ -65,6 +71,7 @@ export function confirmedCommitReceiptResponse(args: {
|
|
|
65
71
|
status: 'confirmed',
|
|
66
72
|
lastSyncId,
|
|
67
73
|
ops: args.ops ?? 1,
|
|
74
|
+
...(args.operationResults ? { operationResults: args.operationResults } : {}),
|
|
68
75
|
} satisfies CommitReceiptWire;
|
|
69
76
|
}
|
|
70
77
|
|
|
@@ -37,11 +37,14 @@ import type {
|
|
|
37
37
|
AbloSession,
|
|
38
38
|
SessionResource,
|
|
39
39
|
} from '../resources/httpResources.js';
|
|
40
|
-
import { modelList } from '../resources/httpResources.js';
|
|
40
|
+
import { collectModelList, modelList } from '../resources/httpResources.js';
|
|
41
|
+
import { resolveCreateId } from '../resources/modelCreate.js';
|
|
41
42
|
import type {
|
|
42
43
|
ModelCreateParams,
|
|
44
|
+
ModelCreateManyParams,
|
|
43
45
|
ModelDeleteParams,
|
|
44
46
|
ServerReadOptions,
|
|
47
|
+
ListAllOptions,
|
|
45
48
|
ModelRetrieveParams,
|
|
46
49
|
ModelTrackParams,
|
|
47
50
|
ModelTrackResult,
|
|
@@ -136,6 +139,8 @@ export interface HttpModelClient<T, C = T> {
|
|
|
136
139
|
* back as `cursor`, with the same `where` and `orderBy`, to walk it.
|
|
137
140
|
*/
|
|
138
141
|
list(options?: ServerReadOptions<T>): Promise<ModelList<CapturedRow<T>>>;
|
|
142
|
+
/** Reads every matching row, following cursors with a bounded traversal. */
|
|
143
|
+
listAll(options?: ListAllOptions<T>): Promise<CapturedRow<T>[]>;
|
|
139
144
|
/**
|
|
140
145
|
* Creates a row and returns it, including any framework-applied defaults.
|
|
141
146
|
* Passing an id that already exists is idempotent: the existing row is
|
|
@@ -148,6 +153,11 @@ export interface HttpModelClient<T, C = T> {
|
|
|
148
153
|
* of `update` and `delete`.
|
|
149
154
|
*/
|
|
150
155
|
create(params: HttpModelMutationParams<ModelCreateParams<T, C>>): Promise<T>;
|
|
156
|
+
/**
|
|
157
|
+
* Creates many rows as one atomic commit, and resolves to them in the order
|
|
158
|
+
* they were given. One rejected row declines the batch.
|
|
159
|
+
*/
|
|
160
|
+
create(params: HttpModelMutationParams<ModelCreateManyParams<C>>): Promise<T[]>;
|
|
151
161
|
update(params: HttpModelMutationParams<ModelUpdateParams<T, C>>): Promise<T>;
|
|
152
162
|
/**
|
|
153
163
|
* Updates a row with a function of its latest value — `update(id, current =>
|
|
@@ -434,6 +444,65 @@ function createHttpModelClient<T, C = T>(
|
|
|
434
444
|
return read.data as CapturedRow<T> | undefined;
|
|
435
445
|
};
|
|
436
446
|
|
|
447
|
+
// `create` is overloaded: one row, or a list of them as one atomic commit.
|
|
448
|
+
// A real overloaded function rather than a property arrow, so both public
|
|
449
|
+
// signatures survive — the same reason `updateModel` is written this way.
|
|
450
|
+
function createModel(
|
|
451
|
+
params: HttpModelMutationParams<ModelCreateParams<T, C>>,
|
|
452
|
+
): Promise<CapturedRow<T>>;
|
|
453
|
+
function createModel(
|
|
454
|
+
params: HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
455
|
+
): Promise<CapturedRow<T>[]>;
|
|
456
|
+
async function createModel(
|
|
457
|
+
params:
|
|
458
|
+
| HttpModelMutationParams<ModelCreateParams<T, C>>
|
|
459
|
+
| HttpModelMutationParams<ModelCreateManyParams<C>>,
|
|
460
|
+
): Promise<CapturedRow<T> | CapturedRow<T>[]> {
|
|
461
|
+
const prepared = preparedMutation(params);
|
|
462
|
+
try {
|
|
463
|
+
// The list form is one atomic commit through the protocol's batch door.
|
|
464
|
+
// Same verb because it is the same act; the argument says how many.
|
|
465
|
+
if (Array.isArray(params.data)) {
|
|
466
|
+
const rows = await protocol.createMany({
|
|
467
|
+
data: params.data as readonly Record<string, unknown>[],
|
|
468
|
+
...(prepared.options.idempotencyKey
|
|
469
|
+
? { idempotencyKey: prepared.options.idempotencyKey }
|
|
470
|
+
: {}),
|
|
471
|
+
...(prepared.options.reads
|
|
472
|
+
? { reads: [...prepared.options.reads] }
|
|
473
|
+
: {}),
|
|
474
|
+
...(prepared.options.track
|
|
475
|
+
? { track: [...prepared.options.track] }
|
|
476
|
+
: {}),
|
|
477
|
+
});
|
|
478
|
+
consumeReadSet(
|
|
479
|
+
readSetContext,
|
|
480
|
+
clientIdentity,
|
|
481
|
+
prepared.consumed,
|
|
482
|
+
prepared.automaticCommit,
|
|
483
|
+
);
|
|
484
|
+
return rows as CapturedRow<T>[];
|
|
485
|
+
}
|
|
486
|
+
const single = params as HttpModelMutationParams<ModelCreateParams<T, C>>;
|
|
487
|
+
const resolvedId = resolveCreateId(single.id, single.data);
|
|
488
|
+
const row = await protocol.create({
|
|
489
|
+
...prepared.options,
|
|
490
|
+
...(resolvedId !== undefined ? { id: resolvedId } : {}),
|
|
491
|
+
data: single.data as Record<string, unknown>,
|
|
492
|
+
});
|
|
493
|
+
consumeReadSet(
|
|
494
|
+
readSetContext,
|
|
495
|
+
clientIdentity,
|
|
496
|
+
prepared.consumed,
|
|
497
|
+
prepared.automaticCommit,
|
|
498
|
+
);
|
|
499
|
+
return row as CapturedRow<T>;
|
|
500
|
+
} catch (error) {
|
|
501
|
+
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
437
506
|
const list = async (
|
|
438
507
|
options?: ServerReadOptions<T>,
|
|
439
508
|
): Promise<ModelList<CapturedRow<T>>> => {
|
|
@@ -441,6 +510,10 @@ function createHttpModelClient<T, C = T>(
|
|
|
441
510
|
const page = modelList<CapturedRow<T>>(
|
|
442
511
|
snapshot.data as readonly CapturedRow<T>[],
|
|
443
512
|
snapshot,
|
|
513
|
+
// Following pages re-enter this same read, so each one is captured into
|
|
514
|
+
// the read set exactly as the first was. A `for await` over the result
|
|
515
|
+
// therefore carries the same evidence a manual cursor walk would.
|
|
516
|
+
(cursor) => list({ ...options, cursor }),
|
|
444
517
|
);
|
|
445
518
|
const registry = readSetContext?.getStore();
|
|
446
519
|
if (!registry) return page;
|
|
@@ -467,6 +540,15 @@ function createHttpModelClient<T, C = T>(
|
|
|
467
540
|
return page;
|
|
468
541
|
};
|
|
469
542
|
|
|
543
|
+
const listAll = async (
|
|
544
|
+
options: ListAllOptions<T> = {},
|
|
545
|
+
): Promise<CapturedRow<T>[]> => {
|
|
546
|
+
const { maxPages, signal, ...readOptions } = options;
|
|
547
|
+
signal?.throwIfAborted();
|
|
548
|
+
const first = await list(readOptions);
|
|
549
|
+
return collectModelList(first, { maxPages, signal });
|
|
550
|
+
};
|
|
551
|
+
|
|
470
552
|
// Claim acquisition performs its authoritative read only after the grant.
|
|
471
553
|
// Preserve that exact post-grant watermark while retaining the callable
|
|
472
554
|
// namespace and all of its state/queue/release members through the proxy.
|
|
@@ -493,28 +575,8 @@ function createHttpModelClient<T, C = T>(
|
|
|
493
575
|
get,
|
|
494
576
|
retrieve: get,
|
|
495
577
|
list,
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const prepared = preparedMutation(params);
|
|
499
|
-
let row: T;
|
|
500
|
-
try {
|
|
501
|
-
row = await protocol.create({
|
|
502
|
-
...prepared.options,
|
|
503
|
-
...(params.id !== undefined ? { id: params.id } : {}),
|
|
504
|
-
data: params.data as Record<string, unknown>,
|
|
505
|
-
});
|
|
506
|
-
} catch (error) {
|
|
507
|
-
abortReadSetCommit(readSetContext, prepared.automaticCommit);
|
|
508
|
-
throw error;
|
|
509
|
-
}
|
|
510
|
-
consumeReadSet(
|
|
511
|
-
readSetContext,
|
|
512
|
-
clientIdentity,
|
|
513
|
-
prepared.consumed,
|
|
514
|
-
prepared.automaticCommit,
|
|
515
|
-
);
|
|
516
|
-
return row;
|
|
517
|
-
},
|
|
578
|
+
listAll,
|
|
579
|
+
create: createModel,
|
|
518
580
|
update,
|
|
519
581
|
async delete(params): Promise<void> {
|
|
520
582
|
const prepared = preparedMutation(params);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HTTP commit as a VALUE: what it is keyed by, the operations it carries,
|
|
3
|
+
* the exact bytes it is replayed as, and the error when its echo never lands.
|
|
4
|
+
*
|
|
5
|
+
* None of this touches the transport's state. It was written inside
|
|
6
|
+
* `createHttpTransport` because that is where it was first needed, which made a
|
|
7
|
+
* two-thousand-line closure the only place the shape of a commit was stated.
|
|
8
|
+
* Read together here, the four say one thing: a commit is identified by its
|
|
9
|
+
* idempotency key, normalized once so every operation carries the same
|
|
10
|
+
* defaults, replayed byte for byte, and abandoned only when the source accepted
|
|
11
|
+
* it and the replication echo did not arrive.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { AbloConnectionError, AbloValidationError } from '../errors.js';
|
|
15
|
+
import type { BatchFence } from '../coordination/locator.js';
|
|
16
|
+
import { claimIdFor, fenceTokenFor } from '../coordination/locator.js';
|
|
17
|
+
import type { Claim } from '../types/streams.js';
|
|
18
|
+
import type { CommitCreateOptions, CommitOperationInput } from '../resources/httpResources.js';
|
|
19
|
+
import type { CommitReceiptWire } from '../wire/commit.js';
|
|
20
|
+
import type { DurableHttpCommitMethod } from '../transactions/confirmation/httpCommitEnvelope.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One commit, exactly as it will be sent and re-sent. A replay that changed any
|
|
24
|
+
* of these would be a different request wearing the same idempotency key.
|
|
25
|
+
*/
|
|
26
|
+
export interface ExactHttpCommitRequest {
|
|
27
|
+
readonly idempotencyKey: string;
|
|
28
|
+
readonly method: DurableHttpCommitMethod;
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly body: string;
|
|
31
|
+
readonly sealedProtocolVersion?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The key a commit is replayed under. A caller's idempotency key wins, because
|
|
36
|
+
* it is the caller's statement that two attempts are the same write.
|
|
37
|
+
*/
|
|
38
|
+
export function createClientTxId(idempotencyKey?: string | null): string {
|
|
39
|
+
if (idempotencyKey && idempotencyKey.length > 0) return idempotencyKey;
|
|
40
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
41
|
+
? crypto.randomUUID()
|
|
42
|
+
: `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One operation with the batch's defaults resolved onto it. */
|
|
46
|
+
export function normalizeCommitOperation(
|
|
47
|
+
op: CommitOperationInput,
|
|
48
|
+
defaults: Pick<CommitCreateOptions, 'readAt' | 'onStale'>,
|
|
49
|
+
fence: BatchFence | null,
|
|
50
|
+
claim: Claim | null,
|
|
51
|
+
): CommitOperationInput {
|
|
52
|
+
return {
|
|
53
|
+
action: op.action,
|
|
54
|
+
model: op.model,
|
|
55
|
+
id: op.id ?? null,
|
|
56
|
+
data: op.data ?? null,
|
|
57
|
+
where: op.where ?? null,
|
|
58
|
+
transactionId: op.transactionId ?? null,
|
|
59
|
+
claimId: op.claimId ?? claimIdFor(claim?.target, claim?.id, op.model, op.id ?? null),
|
|
60
|
+
readAt: op.readAt ?? defaults.readAt ?? null,
|
|
61
|
+
onStale: op.onStale ?? defaults.onStale ?? null,
|
|
62
|
+
fenceToken: op.fenceToken ?? fenceTokenFor(fence, op.model, op.id ?? null),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Every operation in a batch, normalized against the batch's own options. */
|
|
67
|
+
export function normalizeCommitOperations(
|
|
68
|
+
commitOptions: CommitCreateOptions,
|
|
69
|
+
fence: BatchFence | null,
|
|
70
|
+
): readonly CommitOperationInput[] {
|
|
71
|
+
if (commitOptions.operations.length === 0) {
|
|
72
|
+
throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
|
|
73
|
+
code: 'commit_operation_required',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return commitOptions.operations.map((op) =>
|
|
77
|
+
normalizeCommitOperation(op, commitOptions, fence, commitOptions.claim ?? null),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The source accepted the write and its replication echo did not arrive in
|
|
83
|
+
* time. `accepted: true` is the load-bearing detail: the row may well be
|
|
84
|
+
* written, so this is a confirmation timeout and never a reason to write again.
|
|
85
|
+
*/
|
|
86
|
+
export function replicationLagTimeout(
|
|
87
|
+
request: ExactHttpCommitRequest,
|
|
88
|
+
response: CommitReceiptWire,
|
|
89
|
+
requestTimeoutMs: number,
|
|
90
|
+
): AbloConnectionError {
|
|
91
|
+
return new AbloConnectionError(
|
|
92
|
+
`The source accepted commit ${request.idempotencyKey}, but its replication echo did not arrive within ${requestTimeoutMs}ms.`,
|
|
93
|
+
{
|
|
94
|
+
code: 'replication_lag_timeout',
|
|
95
|
+
httpStatus: 504,
|
|
96
|
+
details: {
|
|
97
|
+
clientTxId: request.idempotencyKey,
|
|
98
|
+
...(response.correlationId ? { correlationId: response.correlationId } : {}),
|
|
99
|
+
timeoutMs: requestTimeoutMs,
|
|
100
|
+
accepted: true,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
}
|