@abloatai/transaction 0.53.0 → 0.55.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/dist/auth/capability.js +1 -1
- package/dist/auth/index.d.ts +2 -16
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +0 -9
- package/dist/auth/index.js.map +1 -1
- package/dist/errorObservation.d.ts.map +1 -1
- package/dist/errorObservation.js +9 -1
- package/dist/errorObservation.js.map +1 -1
- package/dist/resources/httpResources.d.ts +2 -2
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/openapi.d.ts.map +1 -1
- package/dist/schema/openapi.js +17 -0
- package/dist/schema/openapi.js.map +1 -1
- package/dist/schema/schema.d.ts +1 -1
- package/dist/schema/schema.d.ts.map +1 -1
- package/dist/schema/schema.js +1 -1
- package/dist/schema/schema.js.map +1 -1
- package/dist/source/adapters/kysely.d.ts.map +1 -1
- package/dist/source/adapters/kysely.js +64 -6
- package/dist/source/adapters/kysely.js.map +1 -1
- package/dist/source/contract.d.ts.map +1 -1
- package/dist/source/contract.js +2 -0
- package/dist/source/contract.js.map +1 -1
- package/dist/source/types.d.ts +21 -3
- package/dist/source/types.d.ts.map +1 -1
- package/dist/source/types.js.map +1 -1
- package/dist/wire/auth.d.ts +1 -1
- package/dist/wire/auth.js +3 -3
- package/dist/wire/deltaDelivery.d.ts +45 -0
- package/dist/wire/deltaDelivery.d.ts.map +1 -0
- package/dist/wire/deltaDelivery.js +43 -0
- package/dist/wire/deltaDelivery.js.map +1 -0
- package/dist/wire/index.d.ts +2 -0
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +3 -0
- package/dist/wire/index.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/capability.ts +1 -1
- package/src/auth/index.ts +2 -19
- package/src/errorObservation.ts +9 -1
- package/src/resources/httpResources.ts +2 -2
- package/src/schema/index.ts +3 -0
- package/src/schema/openapi.ts +21 -0
- package/src/schema/schema.ts +3 -0
- package/src/source/adapters/kysely.ts +73 -8
- package/src/source/contract.ts +2 -0
- package/src/source/types.ts +21 -3
- package/src/wire/auth.ts +3 -3
- package/src/wire/deltaDelivery.ts +47 -0
- package/src/wire/index.ts +5 -0
package/src/schema/openapi.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { errorEnvelopeSchema } from '../wire/errorEnvelope.js';
|
|
|
56
56
|
import { modelReadResponseSchema, modelListResponseSchema } from '../wire/modelResponses.js';
|
|
57
57
|
import { modelMutationRequestSchema } from '../wire/modelMutations.js';
|
|
58
58
|
import { logListResponseSchema, logQuerySchema } from '../wire/feedEvent.js';
|
|
59
|
+
import { logDeliveryResponseSchema } from '../wire/deltaDelivery.js';
|
|
59
60
|
import { schemaReadResponseSchema } from '../wire/accountResponses.js';
|
|
60
61
|
import {
|
|
61
62
|
ephemeralKeyRequestSchema,
|
|
@@ -161,6 +162,7 @@ const ABLO_OPERATION_IDS: Readonly<Record<string, string>> = {
|
|
|
161
162
|
'POST /v1/capabilities/{id}/rotate': 'rotateCapability',
|
|
162
163
|
'GET /v1/schema': 'getSchema',
|
|
163
164
|
'GET /v1/logs': 'listLogEntries',
|
|
165
|
+
'GET /v1/logs/delivery': 'getLogDelivery',
|
|
164
166
|
'GET /v1/commits': 'listCommits',
|
|
165
167
|
'POST /v1/commits': 'commit',
|
|
166
168
|
'GET /v1/commits/{id}': 'getCommit',
|
|
@@ -902,6 +904,25 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
|
|
|
902
904
|
},
|
|
903
905
|
},
|
|
904
906
|
},
|
|
907
|
+
'/v1/logs/delivery': {
|
|
908
|
+
get: {
|
|
909
|
+
tags: ['logs'],
|
|
910
|
+
summary: 'How much of what was recorded could reach anyone',
|
|
911
|
+
description:
|
|
912
|
+
'The fan-out verdict for your plane over a recent window. `recorded` ' +
|
|
913
|
+
'counts the changes the log accepted; `unroutable` counts the ones ' +
|
|
914
|
+
'excluded from delivery because they carried no sync group, which is ' +
|
|
915
|
+
'the state a row reaches when it was written into your database ' +
|
|
916
|
+
'outside Ablo and so carries no tenancy value. Any number above zero ' +
|
|
917
|
+
'means writes are landing that no subscriber is told about.',
|
|
918
|
+
responses: {
|
|
919
|
+
'200': jsonResp(
|
|
920
|
+
'The counts, plus the most recent undeliverable change when there is one.',
|
|
921
|
+
derive(logDeliveryResponseSchema, 'output'),
|
|
922
|
+
),
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
},
|
|
905
926
|
'/v1/commits': {
|
|
906
927
|
get: {
|
|
907
928
|
tags: ['commits'],
|
package/src/schema/schema.ts
CHANGED
|
@@ -120,6 +120,44 @@ function postgresLogicalMarkerQuery(payload: string): KyselyCompiledQuery {
|
|
|
120
120
|
);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The two closing statements of a WAL-echo commit as ONE round trip.
|
|
125
|
+
*
|
|
126
|
+
* Neither result is read: the ledger update records the response, the marker
|
|
127
|
+
* lets the replication echo correlate itself. Sending them separately costs a
|
|
128
|
+
* second full round trip to the customer's database, which is ~6ms when Ablo
|
|
129
|
+
* and that database are neighbours and ~110ms when they are on different
|
|
130
|
+
* continents — paid on every write.
|
|
131
|
+
*
|
|
132
|
+
* A data-modifying `WITH` is the right shape rather than a coincidence: Postgres
|
|
133
|
+
* runs it exactly once and to completion whether or not the primary query reads
|
|
134
|
+
* its output, so the ledger update is not conditional on the marker. Ordering is
|
|
135
|
+
* preserved too — the CTE executes before the main query, the same order the two
|
|
136
|
+
* statements had — and both remain inside the caller's transaction, so the
|
|
137
|
+
* marker is still atomic with the write it describes.
|
|
138
|
+
*/
|
|
139
|
+
function completeLedgerWithMarkerQuery(
|
|
140
|
+
correlationId: string,
|
|
141
|
+
rows: readonly Row[],
|
|
142
|
+
payload: string,
|
|
143
|
+
): KyselyCompiledQuery {
|
|
144
|
+
return rawQuery(
|
|
145
|
+
'ablo-idempotency-complete-with-marker',
|
|
146
|
+
`WITH complete AS (
|
|
147
|
+
UPDATE ablo_idempotency
|
|
148
|
+
SET response = $2::jsonb
|
|
149
|
+
WHERE client_tx_id = $1
|
|
150
|
+
)
|
|
151
|
+
SELECT pg_logical_emit_message(true, $3::text, $4::text)`,
|
|
152
|
+
[
|
|
153
|
+
correlationId,
|
|
154
|
+
JSON.stringify(rows),
|
|
155
|
+
ABLO_POSTGRES_COMMIT_ECHO_PREFIX,
|
|
156
|
+
payload,
|
|
157
|
+
],
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
123
161
|
function parseCachedRows(response: unknown): Row[] {
|
|
124
162
|
const parsed = typeof response === 'string' ? (JSON.parse(response) as unknown) : response;
|
|
125
163
|
if (!Array.isArray(parsed)) {
|
|
@@ -288,12 +326,37 @@ export function createKyselyMutationAdapter(
|
|
|
288
326
|
return { rows: parseCachedRows(cachedRow.response) };
|
|
289
327
|
}
|
|
290
328
|
|
|
329
|
+
// Direct mode dispatches every operation before awaiting any of them, so
|
|
330
|
+
// the driver pipelines them into one trip instead of paying a full
|
|
331
|
+
// round trip per row. Postgres still executes them IN ORDER on the
|
|
332
|
+
// connection, so nothing about their semantics moves: a later operation
|
|
333
|
+
// still sees an earlier one's write, and two operations on the same row
|
|
334
|
+
// remain well-defined last-write-wins — which a multi-CTE batch would
|
|
335
|
+
// have made undefined. Each statement also keeps its own error, so
|
|
336
|
+
// "which operation failed" survives.
|
|
337
|
+
//
|
|
338
|
+
// Endpoint mode stays sequential: its outbox row is built FROM the
|
|
339
|
+
// returned row, so operation i+1's write cannot be dispatched before
|
|
340
|
+
// operation i has answered.
|
|
291
341
|
const rows: Row[] = [];
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
342
|
+
if (mode === 'direct') {
|
|
343
|
+
const dispatched = request.operations.map((operation) =>
|
|
344
|
+
core.applyOperation(transaction, operation),
|
|
345
|
+
);
|
|
346
|
+
// Settle every dispatch before inspecting, so a later rejection is
|
|
347
|
+
// never an unhandled rejection, then surface the FIRST failure in
|
|
348
|
+
// operation order — the one the caller's request actually tripped on.
|
|
349
|
+
const settled = await Promise.allSettled(dispatched);
|
|
350
|
+
const failure = settled.find((outcome) => outcome.status === 'rejected');
|
|
351
|
+
if (failure && failure.status === 'rejected') throw failure.reason;
|
|
352
|
+
for (const outcome of settled) {
|
|
353
|
+
if (outcome.status === 'fulfilled') rows.push(outcome.value);
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
for (const [index, operation] of request.operations.entries()) {
|
|
357
|
+
const row = await core.applyOperation(transaction, operation);
|
|
358
|
+
rows.push(row);
|
|
295
359
|
|
|
296
|
-
if (mode === 'endpoint') {
|
|
297
360
|
const entityId = String(row.id ?? kyselyOperationRowId(operation));
|
|
298
361
|
await transaction
|
|
299
362
|
.insertInto('ablo_outbox')
|
|
@@ -311,15 +374,17 @@ export function createKyselyMutationAdapter(
|
|
|
311
374
|
}
|
|
312
375
|
}
|
|
313
376
|
|
|
314
|
-
await transaction.executeQuery(
|
|
315
|
-
completeLedgerQuery(request.correlationId, rows),
|
|
316
|
-
);
|
|
317
377
|
if (request.echo?.kind === 'postgres-wal') {
|
|
318
378
|
const payload = echoIntent
|
|
319
379
|
? JSON.stringify(resolveEchoMarker(echoIntent, rows))
|
|
320
380
|
: request.echo.payload;
|
|
381
|
+
// One round trip, not two — see `completeLedgerWithMarkerQuery`.
|
|
382
|
+
await transaction.executeQuery(
|
|
383
|
+
completeLedgerWithMarkerQuery(request.correlationId, rows, payload),
|
|
384
|
+
);
|
|
385
|
+
} else {
|
|
321
386
|
await transaction.executeQuery(
|
|
322
|
-
|
|
387
|
+
completeLedgerQuery(request.correlationId, rows),
|
|
323
388
|
);
|
|
324
389
|
}
|
|
325
390
|
return { rows };
|
package/src/source/contract.ts
CHANGED
|
@@ -165,6 +165,8 @@ export const outboxEventSchema = z.object({
|
|
|
165
165
|
model: z.string().min(1),
|
|
166
166
|
entityId: z.string().min(1),
|
|
167
167
|
type: operationTypeSchema,
|
|
168
|
+
/** The changed row, in the key shape `SourceEvent.data` defines: the model's
|
|
169
|
+
* declared schema fields, never the table's physical column names. */
|
|
168
170
|
data: jsonObject.nullish(),
|
|
169
171
|
organizationId: z.string().nullish(),
|
|
170
172
|
/** Legacy source transaction id. Never use this field to settle a queued commit. */
|
package/src/source/types.ts
CHANGED
|
@@ -120,6 +120,7 @@ export interface SourceDelta {
|
|
|
120
120
|
readonly model: string;
|
|
121
121
|
readonly id: string;
|
|
122
122
|
readonly type: SourceOperation['type'];
|
|
123
|
+
/** The changed row, in the key shape {@link SourceEvent.data} defines. */
|
|
123
124
|
readonly data?: Record<string, unknown> | null;
|
|
124
125
|
readonly transactionId?: string | null;
|
|
125
126
|
}
|
|
@@ -144,6 +145,22 @@ export interface SourceEvent {
|
|
|
144
145
|
readonly model: string;
|
|
145
146
|
readonly entityId: string;
|
|
146
147
|
readonly type: SourceOperation['type'];
|
|
148
|
+
/**
|
|
149
|
+
* The row after the change, keyed by the model's DECLARED SCHEMA FIELDS.
|
|
150
|
+
*
|
|
151
|
+
* A field named `reviewStatus` arrives as `reviewStatus`, even though the
|
|
152
|
+
* column it was read from is `review_status`. Ablo's own adapters rename the
|
|
153
|
+
* row before writing the outbox, so a source built on one of them is already
|
|
154
|
+
* in this shape; a hand-written `events` handler has to do the same. Pass
|
|
155
|
+
* `null` for a delete.
|
|
156
|
+
*
|
|
157
|
+
* Ablo reads exactly this spelling and never falls back to the physical one.
|
|
158
|
+
* Two namespaces that can collide have no safe merge: a model whose field `a`
|
|
159
|
+
* maps to column `b` while another field maps to column `a` would route the
|
|
160
|
+
* change into a different scope root's group. A key Ablo does not recognise
|
|
161
|
+
* costs a subscriber an update; a key it recognises as the wrong field crosses
|
|
162
|
+
* a tenant boundary.
|
|
163
|
+
*/
|
|
147
164
|
readonly data?: Record<string, unknown> | null;
|
|
148
165
|
/**
|
|
149
166
|
* The tenant this event belongs to. Populate it from the row's organization
|
|
@@ -183,9 +200,10 @@ export interface SourceEventForOperationOptions {
|
|
|
183
200
|
*/
|
|
184
201
|
readonly entityId?: string;
|
|
185
202
|
/**
|
|
186
|
-
* The row
|
|
187
|
-
* the
|
|
188
|
-
*
|
|
203
|
+
* The row after the write, in the key shape {@link SourceEvent.data} defines:
|
|
204
|
+
* the model's declared schema fields, not your table's column names. Pass
|
|
205
|
+
* `null` for a delete. When omitted, the event carries no payload, which is
|
|
206
|
+
* valid but leaves less for clients to hydrate from in realtime.
|
|
189
207
|
*/
|
|
190
208
|
readonly data?: Record<string, unknown> | null;
|
|
191
209
|
/**
|
package/src/wire/auth.ts
CHANGED
|
@@ -27,7 +27,7 @@ export type EphemeralKeyUser = z.infer<typeof ephemeralKeyUserSchema>;
|
|
|
27
27
|
* Only a secret key (`sk_`) may call this: a session cannot mint itself. By
|
|
28
28
|
* default the key mints into the caller's own organization; naming a different
|
|
29
29
|
* `organizationId` is the multi-tenant case and requires the
|
|
30
|
-
* `
|
|
30
|
+
* `organization:act-as` scope, which is the same privilege that allows
|
|
31
31
|
* binding the session's schema to another organization's project.
|
|
32
32
|
*/
|
|
33
33
|
export const ephemeralKeyRequestSchema = z.object({
|
|
@@ -35,7 +35,7 @@ export const ephemeralKeyRequestSchema = z.object({
|
|
|
35
35
|
user: ephemeralKeyUserSchema,
|
|
36
36
|
/**
|
|
37
37
|
* Mint into this organization instead of the caller's own. Requires the
|
|
38
|
-
* `
|
|
38
|
+
* `organization:act-as` scope — without it a secret key can never mint a
|
|
39
39
|
* session into another tenant.
|
|
40
40
|
*/
|
|
41
41
|
organizationId: z.string().min(1).optional(),
|
|
@@ -43,7 +43,7 @@ export const ephemeralKeyRequestSchema = z.object({
|
|
|
43
43
|
* Override the shared project used to resolve this session's schema while
|
|
44
44
|
* its data stays scoped to the target organization. A cross-organization
|
|
45
45
|
* mint defaults both coordinates to the caller key's own project. Both
|
|
46
|
-
* fields are set together, and both require `
|
|
46
|
+
* fields are set together, and both require `organization:act-as`.
|
|
47
47
|
*/
|
|
48
48
|
schemaProjectId: z.string().min(1).optional(),
|
|
49
49
|
schemaOwnerOrgId: z.string().min(1).optional(),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `GET /v1/logs/delivery` answers: of the changes this plane recorded,
|
|
3
|
+
* how many could reach anyone.
|
|
4
|
+
*
|
|
5
|
+
* The engine already knows. A delta whose `sync_groups` is empty is an upstream
|
|
6
|
+
* invariant violation — `buildDeltaSyncGroups` guarantees at least one group —
|
|
7
|
+
* so the fan-out excludes it from delivery, counts it, and warns. All three land
|
|
8
|
+
* on Ablo's side of the boundary. The row is in the customer's database, the
|
|
9
|
+
* commit confirmed, every configuration check green, and nobody is told. This
|
|
10
|
+
* response moves that fact to the side the person debugging it is standing on.
|
|
11
|
+
*
|
|
12
|
+
* Counts and one sample, never row data: `model` and `id` are what the warning
|
|
13
|
+
* already names, and they are what turns "realtime is broken" into a row to look
|
|
14
|
+
* at.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
/** The model and row of one undeliverable change — enough to find it, no more. */
|
|
20
|
+
export const deliverySampleSchema = z.object({
|
|
21
|
+
model: z.string(),
|
|
22
|
+
id: z.string(),
|
|
23
|
+
at: z.string(),
|
|
24
|
+
});
|
|
25
|
+
export type DeliverySample = z.infer<typeof deliverySampleSchema>;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `GET /v1/logs/delivery` — the plane's fan-out verdict over a fixed recent
|
|
29
|
+
* window.
|
|
30
|
+
*
|
|
31
|
+
* `recorded` and `unroutable` are the verdict and always present. `sample` is
|
|
32
|
+
* optional in the reader's stance the rest of this surface takes: a deployment
|
|
33
|
+
* that omits it must still be readable, because refusing to parse would refuse
|
|
34
|
+
* the diagnosis along with it.
|
|
35
|
+
*/
|
|
36
|
+
export const logDeliveryResponseSchema = z.object({
|
|
37
|
+
object: z.literal('log_delivery').optional(),
|
|
38
|
+
/** The window the counts cover, so a reader never hardcodes it. */
|
|
39
|
+
window_seconds: z.number(),
|
|
40
|
+
/** Deltas this plane recorded in the window. */
|
|
41
|
+
recorded: z.number(),
|
|
42
|
+
/** Of those, the ones excluded from delivery for having no sync group. */
|
|
43
|
+
unroutable: z.number(),
|
|
44
|
+
/** The most recent undeliverable change, when there is one. */
|
|
45
|
+
sample: deliverySampleSchema.nullable().optional(),
|
|
46
|
+
});
|
|
47
|
+
export type LogDeliveryResponse = z.infer<typeof logDeliveryResponseSchema>;
|
package/src/wire/index.ts
CHANGED
|
@@ -41,6 +41,11 @@ export {
|
|
|
41
41
|
logQuerySchema,
|
|
42
42
|
} from './feedEvent.js';
|
|
43
43
|
export type { FeedEvent, LogListResponse, LogQuery } from './feedEvent.js';
|
|
44
|
+
|
|
45
|
+
// The same feed's delivery verdict — how much of what it recorded could reach
|
|
46
|
+
// anyone. Read by `ablo doctor`.
|
|
47
|
+
export { logDeliveryResponseSchema, deliverySampleSchema } from './deltaDelivery.js';
|
|
48
|
+
export type { LogDeliveryResponse, DeliverySample } from './deltaDelivery.js';
|
|
44
49
|
export {
|
|
45
50
|
feedCursorSchema,
|
|
46
51
|
parseFeedCursor,
|