@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.
Files changed (54) hide show
  1. package/dist/auth/capability.js +1 -1
  2. package/dist/auth/index.d.ts +2 -16
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +0 -9
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/errorObservation.d.ts.map +1 -1
  7. package/dist/errorObservation.js +9 -1
  8. package/dist/errorObservation.js.map +1 -1
  9. package/dist/resources/httpResources.d.ts +2 -2
  10. package/dist/resources/httpResources.d.ts.map +1 -1
  11. package/dist/schema/index.d.ts +1 -1
  12. package/dist/schema/index.d.ts.map +1 -1
  13. package/dist/schema/index.js +1 -1
  14. package/dist/schema/index.js.map +1 -1
  15. package/dist/schema/openapi.d.ts.map +1 -1
  16. package/dist/schema/openapi.js +17 -0
  17. package/dist/schema/openapi.js.map +1 -1
  18. package/dist/schema/schema.d.ts +1 -1
  19. package/dist/schema/schema.d.ts.map +1 -1
  20. package/dist/schema/schema.js +1 -1
  21. package/dist/schema/schema.js.map +1 -1
  22. package/dist/source/adapters/kysely.d.ts.map +1 -1
  23. package/dist/source/adapters/kysely.js +64 -6
  24. package/dist/source/adapters/kysely.js.map +1 -1
  25. package/dist/source/contract.d.ts.map +1 -1
  26. package/dist/source/contract.js +2 -0
  27. package/dist/source/contract.js.map +1 -1
  28. package/dist/source/types.d.ts +21 -3
  29. package/dist/source/types.d.ts.map +1 -1
  30. package/dist/source/types.js.map +1 -1
  31. package/dist/wire/auth.d.ts +1 -1
  32. package/dist/wire/auth.js +3 -3
  33. package/dist/wire/deltaDelivery.d.ts +45 -0
  34. package/dist/wire/deltaDelivery.d.ts.map +1 -0
  35. package/dist/wire/deltaDelivery.js +43 -0
  36. package/dist/wire/deltaDelivery.js.map +1 -0
  37. package/dist/wire/index.d.ts +2 -0
  38. package/dist/wire/index.d.ts.map +1 -1
  39. package/dist/wire/index.js +3 -0
  40. package/dist/wire/index.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/auth/capability.ts +1 -1
  43. package/src/auth/index.ts +2 -19
  44. package/src/errorObservation.ts +9 -1
  45. package/src/resources/httpResources.ts +2 -2
  46. package/src/schema/index.ts +3 -0
  47. package/src/schema/openapi.ts +21 -0
  48. package/src/schema/schema.ts +3 -0
  49. package/src/source/adapters/kysely.ts +73 -8
  50. package/src/source/contract.ts +2 -0
  51. package/src/source/types.ts +21 -3
  52. package/src/wire/auth.ts +3 -3
  53. package/src/wire/deltaDelivery.ts +47 -0
  54. package/src/wire/index.ts +5 -0
@@ -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'],
@@ -51,6 +51,9 @@ export {
51
51
  composeEntitySyncGroups,
52
52
  intersectRequestedWithAllowed,
53
53
  syncGroup,
54
+ identityAnchor,
55
+ IDENTITY_ANCHOR_KINDS,
56
+ type IdentityAnchorKind,
54
57
  syncGroupSchema,
55
58
  syncGroupRefSchema,
56
59
  syncGroupInputSchema,
@@ -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
- for (const [index, operation] of request.operations.entries()) {
293
- const row = await core.applyOperation(transaction, operation);
294
- rows.push(row);
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
- postgresLogicalMarkerQuery(payload),
387
+ completeLedgerQuery(request.correlationId, rows),
323
388
  );
324
389
  }
325
390
  return { rows };
@@ -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. */
@@ -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's payload after the write. Pass `null` for a delete. When omitted,
187
- * the event carries no payload, which is valid but leaves less for clients to
188
- * hydrate from in realtime.
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
- * `ephemeral:mint-any-org` scope, which is the same privilege that allows
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
- * `ephemeral:mint-any-org` scope — without it a secret key can never mint a
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 `ephemeral:mint-any-org`.
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,