@abloatai/transaction 0.52.0 → 0.54.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 (120) hide show
  1. package/dist/auth/apiKey.d.ts +0 -6
  2. package/dist/auth/apiKey.d.ts.map +1 -1
  3. package/dist/auth/apiKey.js +13 -60
  4. package/dist/auth/apiKey.js.map +1 -1
  5. package/dist/auth/baseUrl.d.ts +38 -0
  6. package/dist/auth/baseUrl.d.ts.map +1 -0
  7. package/dist/auth/baseUrl.js +126 -0
  8. package/dist/auth/baseUrl.js.map +1 -0
  9. package/dist/auth/capability.js +1 -1
  10. package/dist/auth/index.d.ts +5 -5
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/index.js +1 -1
  13. package/dist/auth/index.js.map +1 -1
  14. package/dist/branches.d.ts +31 -30
  15. package/dist/branches.d.ts.map +1 -1
  16. package/dist/branches.js +2 -4
  17. package/dist/branches.js.map +1 -1
  18. package/dist/errorCodes.d.ts +4 -1
  19. package/dist/errorCodes.d.ts.map +1 -1
  20. package/dist/errorCodes.js +4 -1
  21. package/dist/errorCodes.js.map +1 -1
  22. package/dist/errorObservation.d.ts.map +1 -1
  23. package/dist/errorObservation.js +9 -1
  24. package/dist/errorObservation.js.map +1 -1
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/resources/httpResources.d.ts +34 -2
  29. package/dist/resources/httpResources.d.ts.map +1 -1
  30. package/dist/resources/httpResources.js +10 -1
  31. package/dist/resources/httpResources.js.map +1 -1
  32. package/dist/resources/modelOperations.d.ts +12 -0
  33. package/dist/resources/modelOperations.d.ts.map +1 -1
  34. package/dist/resources/modelOperations.js.map +1 -1
  35. package/dist/resources/where.d.ts +67 -2
  36. package/dist/resources/where.d.ts.map +1 -1
  37. package/dist/resources/where.js +73 -0
  38. package/dist/resources/where.js.map +1 -1
  39. package/dist/schema/index.d.ts +1 -1
  40. package/dist/schema/index.d.ts.map +1 -1
  41. package/dist/schema/index.js +1 -1
  42. package/dist/schema/index.js.map +1 -1
  43. package/dist/schema/openapi.d.ts.map +1 -1
  44. package/dist/schema/openapi.js +59 -14
  45. package/dist/schema/openapi.js.map +1 -1
  46. package/dist/schema/schema.d.ts +1 -1
  47. package/dist/schema/schema.d.ts.map +1 -1
  48. package/dist/schema/schema.js +1 -1
  49. package/dist/schema/schema.js.map +1 -1
  50. package/dist/server/adapter.d.ts +2 -0
  51. package/dist/server/adapter.d.ts.map +1 -1
  52. package/dist/server/index.d.ts +3 -2
  53. package/dist/server/index.d.ts.map +1 -1
  54. package/dist/server/index.js.map +1 -1
  55. package/dist/server/readConfig.d.ts +109 -57
  56. package/dist/server/readConfig.d.ts.map +1 -1
  57. package/dist/server/readConfig.js +19 -5
  58. package/dist/server/readConfig.js.map +1 -1
  59. package/dist/source/adapters/kysely.d.ts.map +1 -1
  60. package/dist/source/adapters/kysely.js +64 -6
  61. package/dist/source/adapters/kysely.js.map +1 -1
  62. package/dist/source/contract.d.ts.map +1 -1
  63. package/dist/source/contract.js +2 -0
  64. package/dist/source/contract.js.map +1 -1
  65. package/dist/source/types.d.ts +21 -3
  66. package/dist/source/types.d.ts.map +1 -1
  67. package/dist/source/types.js.map +1 -1
  68. package/dist/transport/httpClient.d.ts +7 -2
  69. package/dist/transport/httpClient.d.ts.map +1 -1
  70. package/dist/transport/httpClient.js +4 -2
  71. package/dist/transport/httpClient.js.map +1 -1
  72. package/dist/transport/httpTransport.d.ts.map +1 -1
  73. package/dist/transport/httpTransport.js +29 -7
  74. package/dist/transport/httpTransport.js.map +1 -1
  75. package/dist/wire/accountResponses.d.ts +18 -13
  76. package/dist/wire/accountResponses.d.ts.map +1 -1
  77. package/dist/wire/accountResponses.js +3 -9
  78. package/dist/wire/accountResponses.js.map +1 -1
  79. package/dist/wire/auth.d.ts +1 -1
  80. package/dist/wire/auth.js +3 -3
  81. package/dist/wire/claims.d.ts +1 -0
  82. package/dist/wire/claims.d.ts.map +1 -1
  83. package/dist/wire/claims.js +7 -1
  84. package/dist/wire/claims.js.map +1 -1
  85. package/dist/wire/index.d.ts +1 -1
  86. package/dist/wire/index.d.ts.map +1 -1
  87. package/dist/wire/index.js +1 -1
  88. package/dist/wire/index.js.map +1 -1
  89. package/dist/wire/listEnvelope.d.ts +20 -0
  90. package/dist/wire/listEnvelope.d.ts.map +1 -1
  91. package/dist/wire/listEnvelope.js +20 -0
  92. package/dist/wire/listEnvelope.js.map +1 -1
  93. package/package.json +1 -1
  94. package/src/auth/apiKey.ts +13 -61
  95. package/src/auth/baseUrl.ts +144 -0
  96. package/src/auth/capability.ts +1 -1
  97. package/src/auth/index.ts +5 -5
  98. package/src/branches.ts +2 -4
  99. package/src/errorCodes.ts +19 -1
  100. package/src/errorObservation.ts +9 -1
  101. package/src/index.ts +2 -0
  102. package/src/resources/httpResources.ts +44 -2
  103. package/src/resources/modelOperations.ts +12 -0
  104. package/src/resources/where.ts +70 -4
  105. package/src/schema/index.ts +3 -0
  106. package/src/schema/openapi.ts +60 -14
  107. package/src/schema/schema.ts +3 -0
  108. package/src/server/adapter.ts +2 -0
  109. package/src/server/index.ts +8 -2
  110. package/src/server/readConfig.ts +111 -57
  111. package/src/source/adapters/kysely.ts +73 -8
  112. package/src/source/contract.ts +2 -0
  113. package/src/source/types.ts +21 -3
  114. package/src/transport/httpClient.ts +17 -4
  115. package/src/transport/httpTransport.ts +29 -7
  116. package/src/wire/accountResponses.ts +3 -9
  117. package/src/wire/auth.ts +3 -3
  118. package/src/wire/claims.ts +7 -1
  119. package/src/wire/index.ts +7 -1
  120. package/src/wire/listEnvelope.ts +23 -0
@@ -18,7 +18,13 @@ import { z } from 'zod';
18
18
  import { AbloValidationError } from '../errors.js';
19
19
 
20
20
  /** Primitive operand types allowed in a where clause. */
21
- export type WherePrimitive = string | number | boolean | null;
21
+ export const wherePrimitiveSchema = z.union([
22
+ z.string(),
23
+ z.number(),
24
+ z.boolean(),
25
+ z.null(),
26
+ ]);
27
+ export type WherePrimitive = z.infer<typeof wherePrimitiveSchema>;
22
28
 
23
29
  /**
24
30
  * The comparison operators a {@link WhereClause} may use: equality and
@@ -93,6 +99,22 @@ export const WHERE_LIKE_OPS: ReadonlySet<WhereOp> = new Set<WhereOp>([
93
99
  * Returns the classification the caller needs next, so the check and the branch
94
100
  * are the same statement rather than two that can disagree.
95
101
  */
102
+ /**
103
+ * Whether an operand is the array form the set operators take.
104
+ *
105
+ * `Array.isArray` answers this at runtime but not in the type system: its
106
+ * signature narrows to `any[]`, which drops both the `readonly` and the element
107
+ * type, so a compiler that reached for it lost the operand type it had just
108
+ * finished validating and handed `any` to everything downstream. Declared here
109
+ * because operand shape is this module's vocabulary — the same reason
110
+ * {@link classifyWhereOperand} lives here rather than in either plane.
111
+ */
112
+ export function isWhereOperandList(
113
+ value: WherePrimitive | readonly WherePrimitive[] | undefined,
114
+ ): value is readonly WherePrimitive[] {
115
+ return Array.isArray(value);
116
+ }
117
+
96
118
  export function classifyWhereOperand(
97
119
  op: WhereOp,
98
120
  value: unknown,
@@ -134,10 +156,27 @@ export function classifyWhereOperand(
134
156
  *
135
157
  * The value is a single primitive for scalar operators and an array of
136
158
  * primitives for IN/NOT IN.
159
+ *
160
+ * This is a schema rather than a bare type because the grammar crosses a
161
+ * boundary: a filtered read arrives at the collection route as text, and the
162
+ * route has to decide whether what it received is a clause before compiling it
163
+ * to SQL. Declaring the shape twice — once to check at the edge, once to type
164
+ * the interior — is how the two planes came to disagree about `IN`.
137
165
  */
138
- export type WhereClause =
139
- | readonly [col: string, value: WherePrimitive]
140
- | readonly [col: string, op: WhereOp, value: WherePrimitive | readonly WherePrimitive[]];
166
+ export const whereClauseSchema = z.union([
167
+ z.tuple([z.string(), wherePrimitiveSchema]).readonly(),
168
+ z
169
+ .tuple([
170
+ z.string(),
171
+ whereOpSchema,
172
+ z.union([wherePrimitiveSchema, z.array(wherePrimitiveSchema).readonly()]),
173
+ ])
174
+ .readonly(),
175
+ ]);
176
+ export type WhereClause = z.infer<typeof whereClauseSchema>;
177
+
178
+ /** A whole filter as it travels: the AND-combined list of conditions. */
179
+ export const whereClausesSchema = z.array(whereClauseSchema).readonly();
141
180
 
142
181
  /**
143
182
  * Client-facing where shape for `load({where})` and `deleteMany({where})`.
@@ -158,3 +197,30 @@ export type LoadWhere<T> =
158
197
  | Partial<T>
159
198
  | { [K in keyof T]?: T[K] | readonly T[K][] }
160
199
  | readonly WhereClause[];
200
+
201
+ /**
202
+ * Collapse either accepted {@link LoadWhere} form to the canonical clause list.
203
+ * Tuple input passes through; object input becomes one `['col', '=', val]` per
204
+ * key, or `['col', 'IN', vals]` where the value is an array.
205
+ *
206
+ * Detection: an array is tuple form, an object is object form.
207
+ *
208
+ * Every transport funnels through this. It used to live beside the WebSocket
209
+ * loader, which left the HTTP transport to walk `Object.entries` itself — and
210
+ * that copy dropped any value it found to be an object, so an `IN` filter and
211
+ * every tuple-form clause were discarded in silence and the read came back
212
+ * unfiltered.
213
+ */
214
+ export function normalizeWhere(where: unknown): readonly WhereClause[] {
215
+ if (where == null) return [];
216
+ // Tuple form — assumed to already use server-side column names.
217
+ if (Array.isArray(where)) return where as readonly WhereClause[];
218
+ if (typeof where === 'object') {
219
+ return Object.entries(where as Record<string, unknown>).map(([key, value]) =>
220
+ Array.isArray(value)
221
+ ? ([key, 'IN', value as readonly WherePrimitive[]] as WhereClause)
222
+ : ([key, value as WherePrimitive] as WhereClause),
223
+ );
224
+ }
225
+ return [];
226
+ }
@@ -177,6 +177,9 @@ export {
177
177
  extractIdentityIds,
178
178
  extractEntityIds,
179
179
  syncGroup,
180
+ identityAnchor,
181
+ IDENTITY_ANCHOR_KINDS,
182
+ type IdentityAnchorKind,
180
183
  syncGroupSchema,
181
184
  syncGroupRefSchema,
182
185
  syncGroupInputSchema,
@@ -36,6 +36,7 @@ import {
36
36
  commitRecordListSchema,
37
37
  commitRecordWhereSchema,
38
38
  } from '../wire/commit.js';
39
+ import { CURSOR_PARAM, CURSOR_PARAM_ALIAS } from '../wire/listEnvelope.js';
39
40
  import {
40
41
  claimRequestSchema,
41
42
  claimHeartbeatRequestSchema,
@@ -185,6 +186,43 @@ function fieldSchema(f: FieldMeta): Json {
185
186
 
186
187
  const pascal = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
187
188
  const idParam = (): Json => ({ name: 'id', in: 'path', required: true, schema: { type: 'string' } });
189
+ const idempotencyKeyParam = (): Json => ({
190
+ name: 'Idempotency-Key',
191
+ in: 'header',
192
+ schema: { type: 'string', maxLength: 255 },
193
+ description: 'Replay identity. Reuse the same key only for an identical request.',
194
+ });
195
+ const collectionPageParams = (): Json[] => [
196
+ {
197
+ name: 'limit',
198
+ in: 'query',
199
+ schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
200
+ },
201
+ cursorParam(),
202
+ retiredCursorParam(),
203
+ ];
204
+
205
+ /** The cursor parameter, named from the wire contract that issues its value. */
206
+ const cursorParam = (): Json => ({
207
+ name: CURSOR_PARAM,
208
+ in: 'query',
209
+ schema: { type: 'string' },
210
+ description: 'The opaque next_cursor returned by the preceding page.',
211
+ });
212
+
213
+ /**
214
+ * The retired spelling, documented as deprecated so a caller still sending it
215
+ * can see it is going. Declared once: `queryParams` reaches for it too, and a
216
+ * second copy is how the model list came to document the same parameter without
217
+ * the deprecation the collection routes carried.
218
+ */
219
+ const retiredCursorParam = (): Json => ({
220
+ name: CURSOR_PARAM_ALIAS,
221
+ in: 'query',
222
+ deprecated: true,
223
+ schema: { type: 'string' },
224
+ description: `Deprecated spelling of ${CURSOR_PARAM}, honoured until it is removed. Send ${CURSOR_PARAM}.`,
225
+ });
188
226
  const jsonBody = (schema: Json): Json => ({
189
227
  required: true,
190
228
  content: { 'application/json': { schema } },
@@ -313,16 +351,22 @@ function withGenericRows(derived: Json): Json {
313
351
  */
314
352
  function queryParams(schema: z.ZodType): Json[] {
315
353
  const props = (derive(schema, 'input').properties ?? {}) as Record<string, Json>;
316
- return Object.entries(props).map(([name, s]) => ({
317
- name,
318
- in: 'query',
319
- // Query strings arrive at the server as text, but OpenAPI describes the
320
- // caller-facing value before serialization. Generated clients should take
321
- // an integer here and encode it, not expose a stringly typed page size.
322
- schema: name === 'limit'
323
- ? { type: 'integer', minimum: 1 }
324
- : s,
325
- }));
354
+ return Object.entries(props).map(([name, s]) => {
355
+ // The retired cursor spelling is documented the same way wherever it
356
+ // appears. Derived from the schema it still lives in, so the flag cannot be
357
+ // present on one route's copy of the parameter and absent on another's.
358
+ if (name === CURSOR_PARAM_ALIAS) return retiredCursorParam();
359
+ return {
360
+ name,
361
+ in: 'query',
362
+ // Query strings arrive at the server as text, but OpenAPI describes the
363
+ // caller-facing value before serialization. Generated clients should take
364
+ // an integer here and encode it, not expose a stringly typed page size.
365
+ schema: name === 'limit'
366
+ ? { type: 'integer', minimum: 1 }
367
+ : s,
368
+ };
369
+ });
326
370
  }
327
371
 
328
372
  const commitRecordListQuerySchema = commitRecordWhereSchema.safeExtend({
@@ -492,8 +536,8 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
492
536
  parameters: [modelParam(), ...queryParams(listQuerySchema)],
493
537
  responses: {
494
538
  '200': namedResp(
495
- 'A page of rows. `next_cursor` feeds `starting_after` on the next ' +
496
- 'call; `stamp` is the watermark the page was read at.',
539
+ 'A page of rows. `next_cursor` feeds `cursor` on the next call; ' +
540
+ '`stamp` is the watermark the page was read at.',
497
541
  'ModelPage',
498
542
  ),
499
543
  },
@@ -599,6 +643,7 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
599
643
  get: {
600
644
  tags: ['branches'],
601
645
  summary: 'List transaction branches for the credential project',
646
+ parameters: collectionPageParams(),
602
647
  responses: {
603
648
  '200': jsonResp(
604
649
  'The root and every active child branch.',
@@ -611,6 +656,7 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
611
656
  summary: 'Create an isolated child branch',
612
657
  description:
613
658
  'The returned id is immutable; retain it for automation. The slug is a project-scoped human handle.',
659
+ parameters: [idempotencyKeyParam()],
614
660
  requestBody: jsonBody(derive(createBranchRequestSchema, 'input')),
615
661
  responses: {
616
662
  '201': jsonResp('The ready branch.', derive(branchResponseSchema, 'output')),
@@ -866,7 +912,7 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
866
912
  post: {
867
913
  tags: ['commits'],
868
914
  summary: 'Commit a batch of operations atomically, and/or register durable premises',
869
- parameters: [{ name: 'Idempotency-Key', in: 'header', schema: { type: 'string' }, description: 'Replay-safe key; the server returns the cached receipt on retry.' }],
915
+ parameters: [idempotencyKeyParam()],
870
916
  requestBody: commitBody(),
871
917
  responses: { '200': commitReceipt() },
872
918
  },
@@ -993,7 +1039,7 @@ export function schemaToOpenApi<S extends SchemaRecord>(
993
1039
  post: {
994
1040
  tags: ['commits'],
995
1041
  summary: 'Commit a batch of operations atomically, and/or register durable premises',
996
- parameters: [{ name: 'Idempotency-Key', in: 'header', schema: { type: 'string' }, description: 'Replay-safe key; the server returns the cached receipt on retry.' }],
1042
+ parameters: [idempotencyKeyParam()],
997
1043
  requestBody: commitBody(),
998
1044
  responses: { '200': commitReceipt() },
999
1045
  },
@@ -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,
@@ -61,6 +61,8 @@ export type ReadResult =
61
61
  | {
62
62
  readonly kind: 'query';
63
63
  readonly rows: readonly Row[];
64
+ /** Opaque upstream cursor when a source-backed list has another page. */
65
+ readonly nextCursor?: string;
64
66
  };
65
67
 
66
68
  // ── sync ─────────────────────────────────────────────────────────────────────
@@ -4,7 +4,8 @@
4
4
  * vocabulary ({@link Row}, {@link ReadRequest}, {@link ChangeSet}, and the rest),
5
5
  * the {@link CommitContext} and {@link CommitExecutionResult} commit types, the
6
6
  * {@link StorageMode} enumeration, and the per-model read configuration
7
- * ({@link BootstrapModel}, {@link ColumnOverride}). These are plain, driver-free
7
+ * ({@link ReadModelShape} and the {@link BootstrapModel} descriptor built on it,
8
+ * plus {@link ColumnOverride} and {@link ParentScope}). These are plain, driver-free
8
9
  * types; you supply the database code that fulfills them.
9
10
  */
10
11
  export type {
@@ -27,4 +28,9 @@ export {
27
28
  type CommitRecord,
28
29
  } from '../wire/commit.js';
29
30
  export { storageModeSchema, type StorageMode } from './storageMode.js';
30
- export type { ColumnOverride, BootstrapModel } from './readConfig.js';
31
+ export type {
32
+ ColumnOverride,
33
+ ParentScope,
34
+ ReadModelShape,
35
+ BootstrapModel,
36
+ } from './readConfig.js';
@@ -1,9 +1,23 @@
1
1
  /**
2
- * Canonical per-model read configuration consumed when a client bootstraps. Each
3
- * {@link BootstrapModel} maps a model to its physical table, its tenancy column, and
4
- * any parent scoping — plain data with no database driver. It feeds the read side of
5
- * the data adapter contract; your query builder reads it to load a model's initial
6
- * rows.
2
+ * Canonical per-model read configuration how one model's rows are located,
3
+ * scoped to a tenant, and decoded back into the types the schema declared.
4
+ *
5
+ * {@link ReadModelShape} is the definition site for those facts. Every read seam
6
+ * needs the same set, and each one that restated the set drifted from the
7
+ * others: `json` decoding reached three of them, `number` decoding reached none,
8
+ * and the where-clause `boolean` list reached exactly one — so a `bigint`-backed
9
+ * field left one seam as a decimal string and its `int4` neighbour left the next
10
+ * as a number. Adding a declared type is now one edit here rather than a
11
+ * remembered sweep.
12
+ *
13
+ * The descriptors below carry that shape plus the facts only they need. They
14
+ * disagree on one thing, deliberately: the model's own name. A bootstrap
15
+ * descriptor spells it `name`, the server's read-model descriptor spells it
16
+ * `typename`. `name` is part of the published data-adapter contract, so the two
17
+ * spellings stay and the shape they share owns everything else.
18
+ *
19
+ * Plain data, no database driver — it feeds the read side of the data adapter
20
+ * contract, and your query builder reads it to load a model's initial rows.
7
21
  */
8
22
 
9
23
  /** A mapping from a declared field to a physical column, with the alias to apply after a `SELECT *`. */
@@ -13,70 +27,110 @@ export interface ColumnOverride {
13
27
  readonly alias: string;
14
28
  }
15
29
 
16
- /** Read configuration for one model: how to locate its rows and scope them to a tenant. */
17
- export interface BootstrapModel {
18
- name: string;
30
+ /**
31
+ * Parent-table tenancy scoping for rows that have no `organization_id` column
32
+ * of their own, mirroring the schema's `scopedVia` option. A read carrying this
33
+ * adds:
34
+ *
35
+ * WHERE <table>.<localKey> IN
36
+ * (SELECT <parentKey> FROM <parentTable> WHERE <parentOrgColumn> = $1)
37
+ *
38
+ * It applies on top of whatever `orgScoped` dictates, so a table can carry its
39
+ * own `organization_id` and still narrow through a parent. The common case is
40
+ * `orgScoped: false` together with this on a table that lacks the column —
41
+ * without it, such a table reads every tenant's rows.
42
+ */
43
+ export interface ParentScope {
44
+ readonly localKey: string;
45
+ readonly parentTable: string;
46
+ readonly parentKey?: string;
47
+ readonly parentOrgColumn?: string;
48
+ }
49
+
50
+ /**
51
+ * Everything a read seam needs to know about one model except what it is called.
52
+ *
53
+ * Split from the descriptors that carry it because the two of them name the
54
+ * model differently and agree on all of this. A seam that only decodes, or only
55
+ * compiles SQL, projects the members it uses with `Pick` rather than declaring
56
+ * its own near-copy.
57
+ */
58
+ export interface ReadModelShape {
19
59
  /**
20
60
  * Extra names accepted when a request looks up or filters this model. When the
21
- * physical table name is canonical, `name` stays that table name and the aliases
22
- * cover generated compatibility names such as `WeatherReports` or `weatherReports`.
61
+ * physical table name is canonical, the descriptor's own name stays that table
62
+ * name and the aliases cover generated compatibility names such as
63
+ * `WeatherReports` or `weatherReports`.
23
64
  */
24
- aliases?: readonly string[];
65
+ readonly aliases?: readonly string[];
25
66
  /**
26
- * The schema key used by source endpoints. `name` stays the wire and result model
27
- * name (usually the typename), while source handlers are keyed by the developer's
28
- * schema key, such as `files` or `blocks`.
67
+ * The schema key used by source endpoints. The descriptor's own name stays the
68
+ * wire and result model name (usually the typename), while source handlers are
69
+ * keyed by the developer's schema key, such as `files` or `blocks`.
29
70
  */
30
- sourceModel?: string;
31
- table: string;
32
- syncGroups?: string[];
33
- enabled?: boolean;
34
- /** Max rows to return. Omit for unlimited. Maps to schema's bootstrapLimit. */
35
- limit?: number;
36
- /** SQL ORDER BY clause. Default: 'id'. Maps to schema's bootstrapOrderBy. */
37
- orderBy?: string;
71
+ readonly sourceModel?: string;
72
+ readonly table: string;
38
73
  /** Whether the table has organization_id. Default: true. */
39
- orgScoped?: boolean;
74
+ readonly orgScoped?: boolean;
40
75
  /**
41
76
  * The model derives its tenant from the connected data source (`policy:
42
- * { by: 'source' }`) rather than a row column. It carries no tenant predicate on a
43
- * log plane, so it is excluded from the bootstrap set entirely until the
44
- * write-through connect path can resolve the org from the source registration
45
- * serving it now would return every row to every tenant. Kept on the type as a
46
- * defense-in-depth marker so a source-scoped model that reaches `getBootstrapData`
47
- * fails closed rather than bootstrapping unscoped.
77
+ * { by: 'source' }`) rather than a row column the source registration is the
78
+ * tenant boundary. Distinct from `orgScoped: false` (a `none`/global model): a
79
+ * source-scoped model IS tenant-scoped, just not by a column. It carries no
80
+ * tenant predicate on a log plane, so every read and bootstrap site fails it
81
+ * closed with `source_tenancy_not_enforced` rather than treating it as global
82
+ * serving it now would return every row to every tenant. When the
83
+ * write-through connect path can resolve the org from the source registration,
84
+ * this same flag routes to that resolution.
48
85
  */
49
- sourceScoped?: boolean;
86
+ readonly sourceScoped?: boolean;
50
87
  /** Physical tenancy column (default `organization_id`, configurable per model). */
51
- orgColumn?: string;
52
- /**
53
- * Parent-table scoping for rows that have no `organization_id` column, mirroring
54
- * the schema's `scopedVia` option. When set, the bootstrap query adds:
55
- *
56
- * WHERE <table>.<localKey> IN
57
- * (SELECT <parentKey> FROM <parentTable> WHERE <parentOrgColumn> = $1)
58
- *
59
- * This applies on top of whatever `orgScoped` dictates, so a table can carry its
60
- * own `organization_id` and still narrow through a parent. The common case,
61
- * though, is `orgScoped: false` together with `scopedVia` on a table that lacks the
62
- * column.
63
- */
64
- scopedVia?: {
65
- localKey: string;
66
- parentTable: string;
67
- parentKey?: string;
68
- parentOrgColumn?: string;
69
- };
88
+ readonly orgColumn?: string;
89
+ /** Parent-table scoping for rows with no tenancy column of their own. */
90
+ readonly scopedVia?: ParentScope;
70
91
  /** Client-facing field name → physical DB column for declared fields. */
71
- fieldColumns?: Record<string, string>;
92
+ readonly fieldColumns?: Readonly<Record<string, string>>;
72
93
  /** Physical-column aliases needed after SELECT * for `.from(...)` fields. */
73
- columnOverrides?: readonly ColumnOverride[];
94
+ readonly columnOverrides?: readonly ColumnOverride[];
74
95
  /**
75
- * Physical columns the schema declares as JSON (via `field.json()`). A JSON field
76
- * stored in a text column comes back from `row_to_json` as a serialized string, so
77
- * the bootstrap reparses these columns to make the wire value the canonical object
78
- * regardless of the physical column type. A `jsonb` column already returns an
79
- * object, so reparsing it is a no-op.
96
+ * Physical columns the schema declares as JSON (via `field.json()`). A JSON
97
+ * field stored in a text column comes back serialized, so a read reparses these
98
+ * to make the wire value the canonical object regardless of the physical column
99
+ * type. A `jsonb` column already returns an object, so reparsing it is a no-op.
80
100
  */
81
- jsonColumns?: readonly string[];
101
+ readonly jsonColumns?: readonly string[];
102
+ /**
103
+ * Physical columns the schema declares as a boolean (via `field.boolean()`).
104
+ * Two seams need the list. A where-clause parameter is coerced to a real JS
105
+ * boolean before binding, because the driver's bool encoder is a strict
106
+ * `=== true` and a string `'t'` would silently bind as `'f'` — an INVERTED
107
+ * filter, not an error. A row read back from the log is coerced the same way,
108
+ * because a value echoed from a customer's database arrives as PG's text
109
+ * literal rather than as `true`.
110
+ */
111
+ readonly boolColumns?: readonly string[];
112
+ /**
113
+ * Physical columns the schema declares as a number (via `field.number()`).
114
+ * Postgres has four integer widths where JavaScript has one, so a column too
115
+ * wide to always fit a JS number comes back as decimal text. A read decodes
116
+ * these so a `bigint`-backed field and an `int4`-backed one reach the client as
117
+ * the same JS type.
118
+ */
119
+ readonly numberColumns?: readonly string[];
120
+ }
121
+
122
+ /**
123
+ * Read configuration for one model at bootstrap: {@link ReadModelShape} plus the
124
+ * facts only the initial payload needs — which models are in it, how much of
125
+ * each, and in what order.
126
+ */
127
+ export interface BootstrapModel extends ReadModelShape {
128
+ /** The wire and result model name. `ModelMeta.typename` is the same fact. */
129
+ readonly name: string;
130
+ readonly syncGroups?: readonly string[];
131
+ readonly enabled?: boolean;
132
+ /** Max rows to return. Omit for unlimited. Maps to schema's bootstrapLimit. */
133
+ readonly limit?: number;
134
+ /** SQL ORDER BY clause. Default: 'id'. Maps to schema's bootstrapOrderBy. */
135
+ readonly orderBy?: string;
82
136
  }
@@ -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
  /**