@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.
- package/dist/auth/apiKey.d.ts +0 -6
- package/dist/auth/apiKey.d.ts.map +1 -1
- package/dist/auth/apiKey.js +13 -60
- package/dist/auth/apiKey.js.map +1 -1
- package/dist/auth/baseUrl.d.ts +38 -0
- package/dist/auth/baseUrl.d.ts.map +1 -0
- package/dist/auth/baseUrl.js +126 -0
- package/dist/auth/baseUrl.js.map +1 -0
- package/dist/auth/capability.js +1 -1
- package/dist/auth/index.d.ts +5 -5
- 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/branches.d.ts +31 -30
- package/dist/branches.d.ts.map +1 -1
- package/dist/branches.js +2 -4
- package/dist/branches.js.map +1 -1
- package/dist/errorCodes.d.ts +4 -1
- package/dist/errorCodes.d.ts.map +1 -1
- package/dist/errorCodes.js +4 -1
- package/dist/errorCodes.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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/resources/httpResources.d.ts +34 -2
- package/dist/resources/httpResources.d.ts.map +1 -1
- package/dist/resources/httpResources.js +10 -1
- package/dist/resources/httpResources.js.map +1 -1
- package/dist/resources/modelOperations.d.ts +12 -0
- package/dist/resources/modelOperations.d.ts.map +1 -1
- package/dist/resources/modelOperations.js.map +1 -1
- package/dist/resources/where.d.ts +67 -2
- package/dist/resources/where.d.ts.map +1 -1
- package/dist/resources/where.js +73 -0
- package/dist/resources/where.js.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 +59 -14
- 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/server/adapter.d.ts +2 -0
- package/dist/server/adapter.d.ts.map +1 -1
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/readConfig.d.ts +109 -57
- package/dist/server/readConfig.d.ts.map +1 -1
- package/dist/server/readConfig.js +19 -5
- package/dist/server/readConfig.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/transport/httpClient.d.ts +7 -2
- package/dist/transport/httpClient.d.ts.map +1 -1
- package/dist/transport/httpClient.js +4 -2
- package/dist/transport/httpClient.js.map +1 -1
- package/dist/transport/httpTransport.d.ts.map +1 -1
- package/dist/transport/httpTransport.js +29 -7
- package/dist/transport/httpTransport.js.map +1 -1
- package/dist/wire/accountResponses.d.ts +18 -13
- package/dist/wire/accountResponses.d.ts.map +1 -1
- package/dist/wire/accountResponses.js +3 -9
- package/dist/wire/accountResponses.js.map +1 -1
- package/dist/wire/auth.d.ts +1 -1
- package/dist/wire/auth.js +3 -3
- package/dist/wire/claims.d.ts +1 -0
- package/dist/wire/claims.d.ts.map +1 -1
- package/dist/wire/claims.js +7 -1
- package/dist/wire/claims.js.map +1 -1
- package/dist/wire/index.d.ts +1 -1
- package/dist/wire/index.d.ts.map +1 -1
- package/dist/wire/index.js +1 -1
- package/dist/wire/index.js.map +1 -1
- package/dist/wire/listEnvelope.d.ts +20 -0
- package/dist/wire/listEnvelope.d.ts.map +1 -1
- package/dist/wire/listEnvelope.js +20 -0
- package/dist/wire/listEnvelope.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/apiKey.ts +13 -61
- package/src/auth/baseUrl.ts +144 -0
- package/src/auth/capability.ts +1 -1
- package/src/auth/index.ts +5 -5
- package/src/branches.ts +2 -4
- package/src/errorCodes.ts +19 -1
- package/src/errorObservation.ts +9 -1
- package/src/index.ts +2 -0
- package/src/resources/httpResources.ts +44 -2
- package/src/resources/modelOperations.ts +12 -0
- package/src/resources/where.ts +70 -4
- package/src/schema/index.ts +3 -0
- package/src/schema/openapi.ts +60 -14
- package/src/schema/schema.ts +3 -0
- package/src/server/adapter.ts +2 -0
- package/src/server/index.ts +8 -2
- package/src/server/readConfig.ts +111 -57
- package/src/source/adapters/kysely.ts +73 -8
- package/src/source/contract.ts +2 -0
- package/src/source/types.ts +21 -3
- package/src/transport/httpClient.ts +17 -4
- package/src/transport/httpTransport.ts +29 -7
- package/src/wire/accountResponses.ts +3 -9
- package/src/wire/auth.ts +3 -3
- package/src/wire/claims.ts +7 -1
- package/src/wire/index.ts +7 -1
- package/src/wire/listEnvelope.ts +23 -0
package/src/resources/where.ts
CHANGED
|
@@ -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
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
package/src/schema/index.ts
CHANGED
package/src/schema/openapi.ts
CHANGED
|
@@ -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
|
-
|
|
318
|
-
in
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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 `
|
|
496
|
-
'
|
|
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: [
|
|
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: [
|
|
1042
|
+
parameters: [idempotencyKeyParam()],
|
|
997
1043
|
requestBody: commitBody(),
|
|
998
1044
|
responses: { '200': commitReceipt() },
|
|
999
1045
|
},
|
package/src/schema/schema.ts
CHANGED
package/src/server/adapter.ts
CHANGED
|
@@ -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 ─────────────────────────────────────────────────────────────────────
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
31
|
+
export type {
|
|
32
|
+
ColumnOverride,
|
|
33
|
+
ParentScope,
|
|
34
|
+
ReadModelShape,
|
|
35
|
+
BootstrapModel,
|
|
36
|
+
} from './readConfig.js';
|
package/src/server/readConfig.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Canonical per-model read configuration
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
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,
|
|
22
|
-
* cover generated compatibility names such as
|
|
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.
|
|
27
|
-
* name (usually the typename), while source handlers are
|
|
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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
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
|
|
76
|
-
* stored in a text column comes back
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
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
|
/**
|