@abloatai/transaction 0.56.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/CONVENTIONS.md +34 -0
  2. package/dist/coordination/index.d.ts +2 -2
  3. package/dist/coordination/index.d.ts.map +1 -1
  4. package/dist/coordination/index.js +1 -1
  5. package/dist/coordination/index.js.map +1 -1
  6. package/dist/coordination/schema.d.ts +0 -7
  7. package/dist/coordination/schema.d.ts.map +1 -1
  8. package/dist/coordination/schema.js +0 -4
  9. package/dist/coordination/schema.js.map +1 -1
  10. package/dist/errorCodes.d.ts +1 -0
  11. package/dist/errorCodes.d.ts.map +1 -1
  12. package/dist/errorCodes.js +1 -0
  13. package/dist/errorCodes.js.map +1 -1
  14. package/dist/readSetContext.d.ts.map +1 -1
  15. package/dist/readSetContext.js +8 -3
  16. package/dist/readSetContext.js.map +1 -1
  17. package/dist/resources/httpResources.d.ts +43 -22
  18. package/dist/resources/httpResources.d.ts.map +1 -1
  19. package/dist/resources/httpResources.js +81 -4
  20. package/dist/resources/httpResources.js.map +1 -1
  21. package/dist/resources/modelCreate.d.ts +21 -0
  22. package/dist/resources/modelCreate.d.ts.map +1 -0
  23. package/dist/resources/modelCreate.js +49 -0
  24. package/dist/resources/modelCreate.js.map +1 -0
  25. package/dist/resources/modelOperations.d.ts +24 -2
  26. package/dist/resources/modelOperations.d.ts.map +1 -1
  27. package/dist/resources/modelOperations.js.map +1 -1
  28. package/dist/resources/writeOptionsSchema.d.ts +14 -0
  29. package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
  30. package/dist/resources/writeOptionsSchema.js +20 -0
  31. package/dist/resources/writeOptionsSchema.js.map +1 -1
  32. package/dist/schema/audit.d.ts +15 -0
  33. package/dist/schema/audit.d.ts.map +1 -0
  34. package/dist/schema/audit.js +90 -0
  35. package/dist/schema/audit.js.map +1 -0
  36. package/dist/schema/ddl.d.ts.map +1 -1
  37. package/dist/schema/ddl.js +68 -1
  38. package/dist/schema/ddl.js.map +1 -1
  39. package/dist/schema/index.d.ts +3 -1
  40. package/dist/schema/index.d.ts.map +1 -1
  41. package/dist/schema/index.js +3 -1
  42. package/dist/schema/index.js.map +1 -1
  43. package/dist/schema/model.d.ts +14 -0
  44. package/dist/schema/model.d.ts.map +1 -1
  45. package/dist/schema/model.js +2 -0
  46. package/dist/schema/model.js.map +1 -1
  47. package/dist/schema/openapi.d.ts.map +1 -1
  48. package/dist/schema/openapi.js +104 -2
  49. package/dist/schema/openapi.js.map +1 -1
  50. package/dist/schema/roles.d.ts +27 -0
  51. package/dist/schema/roles.d.ts.map +1 -1
  52. package/dist/schema/roles.js +40 -0
  53. package/dist/schema/roles.js.map +1 -1
  54. package/dist/schema/schema.d.ts +23 -4
  55. package/dist/schema/schema.d.ts.map +1 -1
  56. package/dist/schema/schema.js +30 -1
  57. package/dist/schema/schema.js.map +1 -1
  58. package/dist/schema/serialize.d.ts +4 -1
  59. package/dist/schema/serialize.d.ts.map +1 -1
  60. package/dist/schema/serialize.js +5 -1
  61. package/dist/schema/serialize.js.map +1 -1
  62. package/dist/schema/subject.d.ts +18 -0
  63. package/dist/schema/subject.d.ts.map +1 -0
  64. package/dist/schema/subject.js +27 -0
  65. package/dist/schema/subject.js.map +1 -0
  66. package/dist/server/adapter.d.ts +2 -0
  67. package/dist/server/adapter.d.ts.map +1 -1
  68. package/dist/server/readConfig.d.ts +3 -0
  69. package/dist/server/readConfig.d.ts.map +1 -1
  70. package/dist/server/readConfig.js +0 -21
  71. package/dist/server/readConfig.js.map +1 -1
  72. package/dist/source/adapters/drizzle.d.ts.map +1 -1
  73. package/dist/source/adapters/drizzle.js +37 -6
  74. package/dist/source/adapters/drizzle.js.map +1 -1
  75. package/dist/source/adapters/kysely.d.ts +2 -0
  76. package/dist/source/adapters/kysely.d.ts.map +1 -1
  77. package/dist/source/adapters/kysely.js +33 -5
  78. package/dist/source/adapters/kysely.js.map +1 -1
  79. package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
  80. package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
  81. package/dist/source/adapters/kyselyMutationCore.js +17 -8
  82. package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
  83. package/dist/source/adapters/memory.d.ts.map +1 -1
  84. package/dist/source/adapters/memory.js +1 -0
  85. package/dist/source/adapters/memory.js.map +1 -1
  86. package/dist/source/adapters/prisma.d.ts.map +1 -1
  87. package/dist/source/adapters/prisma.js +59 -7
  88. package/dist/source/adapters/prisma.js.map +1 -1
  89. package/dist/source/contract.d.ts +14 -0
  90. package/dist/source/contract.d.ts.map +1 -1
  91. package/dist/source/contract.js +10 -0
  92. package/dist/source/contract.js.map +1 -1
  93. package/dist/source/factory.d.ts +7 -1
  94. package/dist/source/factory.d.ts.map +1 -1
  95. package/dist/source/factory.js +116 -5
  96. package/dist/source/factory.js.map +1 -1
  97. package/dist/source/index.d.ts +2 -1
  98. package/dist/source/index.d.ts.map +1 -1
  99. package/dist/source/index.js +1 -0
  100. package/dist/source/index.js.map +1 -1
  101. package/dist/source/migrations.d.ts.map +1 -1
  102. package/dist/source/migrations.js +21 -0
  103. package/dist/source/migrations.js.map +1 -1
  104. package/dist/source/subjectAuthorization.d.ts +16 -0
  105. package/dist/source/subjectAuthorization.d.ts.map +1 -0
  106. package/dist/source/subjectAuthorization.js +126 -0
  107. package/dist/source/subjectAuthorization.js.map +1 -0
  108. package/dist/source/types.d.ts +52 -2
  109. package/dist/source/types.d.ts.map +1 -1
  110. package/dist/source/types.js +1 -0
  111. package/dist/source/types.js.map +1 -1
  112. package/dist/syncLog/contract.d.ts +0 -6
  113. package/dist/syncLog/contract.d.ts.map +1 -1
  114. package/dist/syncLog/contract.js +0 -4
  115. package/dist/syncLog/contract.js.map +1 -1
  116. package/dist/testing/fixtures/httpResponses.d.ts +6 -0
  117. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  118. package/dist/testing/fixtures/httpResponses.js +1 -0
  119. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  120. package/dist/transport/httpClient.d.ts +8 -1
  121. package/dist/transport/httpClient.d.ts.map +1 -1
  122. package/dist/transport/httpClient.js +51 -20
  123. package/dist/transport/httpClient.js.map +1 -1
  124. package/dist/transport/httpCommitRequest.d.ts +45 -0
  125. package/dist/transport/httpCommitRequest.d.ts.map +1 -0
  126. package/dist/transport/httpCommitRequest.js +67 -0
  127. package/dist/transport/httpCommitRequest.js.map +1 -0
  128. package/dist/transport/httpTransport.d.ts.map +1 -1
  129. package/dist/transport/httpTransport.js +69 -106
  130. package/dist/transport/httpTransport.js.map +1 -1
  131. package/dist/transport/httpTransportHelpers.d.ts +10 -0
  132. package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
  133. package/dist/transport/httpTransportHelpers.js +46 -0
  134. package/dist/transport/httpTransportHelpers.js.map +1 -0
  135. package/dist/wire/apiLifecycle.d.ts +63 -0
  136. package/dist/wire/apiLifecycle.d.ts.map +1 -0
  137. package/dist/wire/apiLifecycle.js +89 -0
  138. package/dist/wire/apiLifecycle.js.map +1 -0
  139. package/dist/wire/commit.d.ts +74 -9
  140. package/dist/wire/commit.d.ts.map +1 -1
  141. package/dist/wire/commit.js +13 -1
  142. package/dist/wire/commit.js.map +1 -1
  143. package/dist/wire/index.d.ts +5 -2
  144. package/dist/wire/index.d.ts.map +1 -1
  145. package/dist/wire/index.js +12 -1
  146. package/dist/wire/index.js.map +1 -1
  147. package/dist/wire/rateLimit.d.ts +82 -0
  148. package/dist/wire/rateLimit.d.ts.map +1 -0
  149. package/dist/wire/rateLimit.js +142 -0
  150. package/dist/wire/rateLimit.js.map +1 -0
  151. package/package.json +1 -1
  152. package/src/coordination/index.ts +0 -2
  153. package/src/coordination/schema.ts +0 -7
  154. package/src/errorCodes.ts +6 -0
  155. package/src/readSetContext.ts +8 -2
  156. package/src/resources/httpResources.ts +116 -25
  157. package/src/resources/modelCreate.ts +73 -0
  158. package/src/resources/modelOperations.ts +29 -2
  159. package/src/resources/writeOptionsSchema.ts +28 -0
  160. package/src/schema/audit.ts +121 -0
  161. package/src/schema/ddl.ts +71 -1
  162. package/src/schema/index.ts +16 -0
  163. package/src/schema/model.ts +17 -0
  164. package/src/schema/openapi.ts +123 -2
  165. package/src/schema/roles.ts +53 -0
  166. package/src/schema/schema.ts +68 -3
  167. package/src/schema/serialize.ts +8 -1
  168. package/src/schema/subject.ts +43 -0
  169. package/src/server/adapter.ts +2 -0
  170. package/src/server/readConfig.ts +4 -0
  171. package/src/source/adapters/drizzle.ts +50 -6
  172. package/src/source/adapters/kysely.ts +47 -4
  173. package/src/source/adapters/kyselyMutationCore.ts +21 -9
  174. package/src/source/adapters/memory.ts +1 -0
  175. package/src/source/adapters/prisma.ts +70 -7
  176. package/src/source/contract.ts +11 -0
  177. package/src/source/factory.ts +141 -5
  178. package/src/source/index.ts +6 -0
  179. package/src/source/migrations.ts +21 -0
  180. package/src/source/subjectAuthorization.ts +182 -0
  181. package/src/source/types.ts +56 -2
  182. package/src/syncLog/contract.ts +0 -7
  183. package/src/testing/fixtures/httpResponses.ts +7 -0
  184. package/src/transport/httpClient.ts +85 -23
  185. package/src/transport/httpCommitRequest.ts +104 -0
  186. package/src/transport/httpTransport.ts +92 -142
  187. package/src/transport/httpTransportHelpers.ts +63 -0
  188. package/src/wire/apiLifecycle.ts +94 -0
  189. package/src/wire/commit.ts +16 -1
  190. package/src/wire/index.ts +31 -0
  191. package/src/wire/rateLimit.ts +155 -0
@@ -0,0 +1,182 @@
1
+ import { AbloPermissionError, AbloValidationError } from '../errors.js';
2
+ import {
3
+ composeEntitySyncGroups,
4
+ syncGroupsForRow,
5
+ InvalidRecordSubjectError,
6
+ scopeKindOf,
7
+ subjectAuthorized,
8
+ type Schema,
9
+ type SchemaRecord,
10
+ type SubjectRule,
11
+ } from '../schema/index.js';
12
+ import type { AdapterReadRequest, Row } from './adapter.js';
13
+ import type { ChangeSet, Operation } from './contract.js';
14
+
15
+ function sourceModelEntry<S extends SchemaRecord>(schema: Schema<S>, model: string) {
16
+ return Object.entries(schema.models).find(([key, def]) =>
17
+ key === model || key.toLowerCase() === model.toLowerCase() ||
18
+ def.typename === model || def.typename?.toLowerCase() === model.toLowerCase(),
19
+ );
20
+ }
21
+
22
+ function deny(): never {
23
+ throw new AbloPermissionError('The resolved scope does not cover the requested row.', {
24
+ code: 'capability_scope_denied',
25
+ httpStatus: 403,
26
+ });
27
+ }
28
+
29
+ export function sourceSubjectRule<S extends SchemaRecord>(
30
+ schema: Schema<S>,
31
+ model: string,
32
+ ): SubjectRule | undefined {
33
+ const entry = sourceModelEntry(schema, model);
34
+ return entry?.[1].subject;
35
+ }
36
+
37
+ /** Derive the durable record routes while the row is transactionally visible. */
38
+ export function sourceSyncGroups<S extends SchemaRecord>(
39
+ schema: Schema<S>,
40
+ model: string,
41
+ row: Row,
42
+ ): readonly string[] {
43
+ const entry = sourceModelEntry(schema, model);
44
+ if (!entry) return [];
45
+ const [key, definition] = entry;
46
+ const parents: { kind: string; field: string }[] = [];
47
+ const selfKind = scopeKindOf(definition, key);
48
+ for (const relation of Object.values(definition.relations ?? {})) {
49
+ if (relation.type !== 'belongsTo' || relation.options?.parent !== true) continue;
50
+ const target = schema.models[relation.target];
51
+ const kind = target && scopeKindOf(target, relation.target);
52
+ if (kind) parents.push({ kind, field: relation.foreignKey });
53
+ }
54
+ try {
55
+ return syncGroupsForRow(
56
+ {
57
+ selfKind,
58
+ parents,
59
+ ...(definition.subject
60
+ ? { subject: { kind: definition.subject.group, field: definition.subject.field } }
61
+ : {}),
62
+ },
63
+ row,
64
+ composeEntitySyncGroups(row, definition),
65
+ );
66
+ } catch (error) {
67
+ if (!(error instanceof InvalidRecordSubjectError)) throw error;
68
+ throw new AbloValidationError(
69
+ `Source row ${model}/${String(row.id ?? '')} lacks subject field "${error.field}".`,
70
+ { code: 'source_event_invalid', cause: error },
71
+ );
72
+ }
73
+ }
74
+
75
+ /** Subject values represented by a trusted group set, preserving group order. */
76
+ export function sourceSubjectValues(
77
+ rule: SubjectRule | undefined,
78
+ groups: readonly string[] | undefined,
79
+ ): readonly string[] | undefined {
80
+ if (!rule) return undefined;
81
+ const prefix = `${rule.group}:`;
82
+ return [...new Set(
83
+ (groups ?? [])
84
+ .filter((group) => group.startsWith(prefix))
85
+ .map((group) => group.slice(prefix.length))
86
+ .filter((value) => value.length > 0),
87
+ )];
88
+ }
89
+
90
+ /**
91
+ * Stable absent-row lock keys for caller-selected subject-scoped CREATE ids.
92
+ * Adapters acquire these in sorted order before loading current rows, so two
93
+ * authorized writers cannot both observe an absent id. Database uniqueness is
94
+ * still the final boundary against external writers that ignore this lock.
95
+ */
96
+ function sourceSubjectCreateLockOperations<S extends SchemaRecord>(
97
+ schema: Schema<S>,
98
+ change: ChangeSet,
99
+ ): readonly Operation[] {
100
+ return change.operations
101
+ .filter((operation) =>
102
+ operation.type === 'CREATE' &&
103
+ typeof operation.id === 'string' &&
104
+ operation.id.length > 0 &&
105
+ sourceSubjectRule(schema, operation.model) !== undefined)
106
+ .sort((left, right) =>
107
+ `${left.model.toLowerCase()}\u0000${left.id}`.localeCompare(
108
+ `${right.model.toLowerCase()}\u0000${right.id}`,
109
+ ));
110
+ }
111
+
112
+ export function sourceSubjectCreateLockKey(operation: Operation): string {
113
+ return `ablo:subject-create:${operation.model.toLowerCase()}:${operation.id ?? ''}`;
114
+ }
115
+
116
+ /** Acquire every absent-key CREATE lock in canonical order. */
117
+ export async function lockSourceSubjectCreates<S extends SchemaRecord>(
118
+ schema: Schema<S>,
119
+ change: ChangeSet,
120
+ acquire: (operation: Operation, key: string) => Promise<void>,
121
+ ): Promise<void> {
122
+ for (const operation of sourceSubjectCreateLockOperations(schema, change)) {
123
+ await acquire(operation, sourceSubjectCreateLockKey(operation));
124
+ }
125
+ }
126
+
127
+ function errorCode(error: unknown): unknown {
128
+ return error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined;
129
+ }
130
+
131
+ /** Translate ORM/driver primary-key conflicts without exposing the winning row. */
132
+ export function rethrowStrictCreateConflict(error: unknown, operation: Operation): never {
133
+ const code = errorCode(error) ?? errorCode(
134
+ error && typeof error === 'object' ? (error as { cause?: unknown }).cause : undefined,
135
+ );
136
+ if (code === '23505' || code === 'P2002') {
137
+ throw new AbloValidationError(
138
+ `A row already exists for ${operation.model}/${operation.id ?? ''}.`,
139
+ { code: 'entity_already_exists', httpStatus: 409, cause: error },
140
+ );
141
+ }
142
+ throw error;
143
+ }
144
+
145
+ export function authorizeSourceRead<S extends SchemaRecord>(
146
+ schema: Schema<S>,
147
+ req: AdapterReadRequest,
148
+ rows: readonly Row[],
149
+ ): readonly Row[] {
150
+ const rule = sourceSubjectRule(schema, req.model);
151
+ if (!rule) return rows;
152
+ const groups = req.scope?.syncGroups;
153
+ const authorized = rows.filter((row) => subjectAuthorized(rule, row, groups));
154
+ if (req.kind === 'load' && rows.length > 0 && authorized.length === 0) deny();
155
+ return authorized;
156
+ }
157
+
158
+ function authorizePayload(rule: SubjectRule, row: Row, groups: readonly string[] | undefined): void {
159
+ if (!subjectAuthorized(rule, row, groups)) deny();
160
+ }
161
+
162
+ export async function authorizeSourceChange<S extends SchemaRecord>(
163
+ schema: Schema<S>,
164
+ change: ChangeSet,
165
+ load: (operation: Operation) => Promise<Row | null>,
166
+ ): Promise<void> {
167
+ for (const operation of change.operations) {
168
+ const rule = sourceSubjectRule(schema, operation.model);
169
+ if (!rule) continue;
170
+ const current = operation.id ? await load(operation) : null;
171
+ if (current) authorizePayload(rule, current, change.scope?.syncGroups);
172
+ if (operation.type === 'CREATE') {
173
+ authorizePayload(rule, operation.input ?? {}, change.scope?.syncGroups);
174
+ } else if (!current) {
175
+ deny();
176
+ }
177
+ if (current && operation.input && Object.hasOwn(operation.input, rule.field) &&
178
+ !Object.is(current[rule.field], operation.input[rule.field])) {
179
+ deny();
180
+ }
181
+ }
182
+ }
@@ -80,8 +80,9 @@ export type SourceListResult<Row> =
80
80
  * see. Ablo attaches this so your `authorize` and model handlers can reject
81
81
  * calls that fall outside the participant's permitted sync groups.
82
82
  *
83
- * It is advisory. Because the canonical data lives in your database, your
84
- * handlers are the only place that can actually enforce these limits.
83
+ * The ORM adapters enforce schema-declared subject rules from this trusted
84
+ * scope. Custom handlers receive the same signed facts; Ablo also checks their
85
+ * returned rows and preflights writes before forwarding the customer transaction.
85
86
  */
86
87
  export interface SourceRequestContext {
87
88
  readonly participantId?: string;
@@ -91,6 +92,14 @@ export interface SourceRequestContext {
91
92
  readonly branchId?: string;
92
93
  /** Trusted project selected by the authenticating credential. */
93
94
  readonly projectId?: string;
95
+ readonly syncGroups?: readonly string[];
96
+ /**
97
+ * @deprecated Renamed to {@link SourceRequestContext.syncGroups}. Removed in
98
+ * 0.58.0. Ablo populates both spellings with the same value for this release,
99
+ * so an adapter still reading this one gets the groups rather than
100
+ * `undefined`, which on a routing field would read as "no groups" rather than
101
+ * as a missing field.
102
+ */
94
103
  readonly requiredSyncGroups?: readonly string[];
95
104
  }
96
105
 
@@ -122,6 +131,8 @@ export interface SourceDelta {
122
131
  readonly type: SourceOperation['type'];
123
132
  /** The changed row, in the key shape {@link SourceEvent.data} defines. */
124
133
  readonly data?: Record<string, unknown> | null;
134
+ /** Exact record routes derived inside the write transaction. */
135
+ readonly syncGroups?: readonly string[];
125
136
  readonly transactionId?: string | null;
126
137
  }
127
138
 
@@ -162,6 +173,8 @@ export interface SourceEvent {
162
173
  * a tenant boundary.
163
174
  */
164
175
  readonly data?: Record<string, unknown> | null;
176
+ /** Exact record routes captured while the changed row was transactionally visible. */
177
+ readonly syncGroups: readonly string[];
165
178
  /**
166
179
  * The tenant this event belongs to. Populate it from the row's organization
167
180
  * column for multi-tenant data; a single-tenant source may omit it and let
@@ -206,6 +219,8 @@ export interface SourceEventForOperationOptions {
206
219
  * valid but leaves less for clients to hydrate from in realtime.
207
220
  */
208
221
  readonly data?: Record<string, unknown> | null;
222
+ /** Exact record routes derived inside the write transaction. */
223
+ readonly syncGroups: readonly string[];
209
224
  /**
210
225
  * @deprecated Legacy echo identity. Prefer the explicit correlation fields.
211
226
  */
@@ -243,6 +258,7 @@ export function sourceEventForOperation(
243
258
  entityId,
244
259
  type: options.operation.type,
245
260
  ...(options.data !== undefined ? { data: options.data } : {}),
261
+ syncGroups: options.syncGroups,
246
262
  ...(options.organizationId ? { organizationId: options.organizationId } : {}),
247
263
  ...(options.clientTxId ? { clientTxId: options.clientTxId } : {}),
248
264
  ...(options.correlationId ? { correlationId: options.correlationId } : {}),
@@ -423,6 +439,18 @@ export interface SourceModelHandlers<Row, CreateInput, TAuth = unknown> {
423
439
  | Promise<SourceListResult<Row>>
424
440
  | SourceListResult<Row>;
425
441
 
442
+ /**
443
+ * Subject-aware list boundary. The handler MUST apply `subject.values` in
444
+ * its database predicate before ordering, cursoring, or limiting. A model
445
+ * declaring `subject` is served only through this hook; plain `list()` is
446
+ * deliberately not accepted because post-page filtering is incomplete.
447
+ */
448
+ subjectList?(params: {
449
+ readonly query: SourceListQuery;
450
+ readonly subject: { readonly field: string; readonly values: readonly string[] };
451
+ readonly context: SourceHandlerContext<TAuth>;
452
+ }): Promise<SourceListResult<Row>> | SourceListResult<Row>;
453
+
426
454
  /**
427
455
  * Apply one or more operations for this model within your own database
428
456
  * transaction. Your handler must be idempotent on the scoped server
@@ -446,6 +474,32 @@ export type SourceCommitHandler<TAuth = unknown> = (
446
474
  params: SourceCommitParams<TAuth>,
447
475
  ) => Promise<SourceCommitResult> | SourceCommitResult;
448
476
 
477
+ export interface SourceSubjectTransactionBoundary {
478
+ /**
479
+ * Serialize a caller-selected CREATE id even when no row exists yet. This
480
+ * must be an absent-key-capable transaction lock (for example a Postgres
481
+ * transaction advisory lock), acquired on the SAME transaction as load and
482
+ * commit. It coordinates framework writers; `commit` must still reject a
483
+ * database uniqueness conflict as `entity_already_exists`, because external
484
+ * writers do not participate in advisory locking.
485
+ */
486
+ readonly lockCreate: (operation: SourceOperation) => Promise<void>;
487
+ /** Lock and return the current row on the SAME transaction used by commit. */
488
+ readonly load: (operation: SourceOperation) => Promise<Record<string, unknown> | null>;
489
+ /** Apply the complete batch on that transaction after authorization succeeds. */
490
+ readonly commit: () => Promise<SourceCommitResult>;
491
+ }
492
+
493
+ /**
494
+ * Transaction owner for hand-written subject-scoped commits. Open one database
495
+ * transaction, then call `run` with load/commit functions bound to it. `run`
496
+ * performs authorization first and invokes commit only after it succeeds.
497
+ */
498
+ export type SourceSubjectTransactionHandler<TAuth = unknown> = (
499
+ params: SourceCommitParams<TAuth>,
500
+ run: (boundary: SourceSubjectTransactionBoundary) => Promise<SourceCommitResult>,
501
+ ) => Promise<SourceCommitResult>;
502
+
449
503
  export type SourceApiKey =
450
504
  | string
451
505
  | ((context: SourceAuthorizeContext) => Promise<string> | string);
@@ -47,13 +47,6 @@ import { z } from 'zod';
47
47
  export const logPositionSchema = z.number().int().nonnegative();
48
48
  export type LogPosition = z.infer<typeof logPositionSchema>;
49
49
 
50
- /** @deprecated One position type, one name: use {@link logPositionSchema}.
51
- * This spelling is removed in 0.57.0. It resolves to the definition above, so
52
- * the two cannot drift while both are exported. */
53
- export const deltaPositionSchema = logPositionSchema;
54
- /** @deprecated One position type, one name: use {@link LogPosition}. Removed in 0.57.0. */
55
- export type DeltaPosition = LogPosition;
56
-
57
50
  export const clientMutationIdSchema = z.string().min(1).max(255);
58
51
  export type ClientMutationId = z.infer<typeof clientMutationIdSchema>;
59
52
 
@@ -53,6 +53,12 @@ export function confirmedCommitReceiptResponse(args: {
53
53
  lastSyncId?: number;
54
54
  serverTxId?: string;
55
55
  ops?: number;
56
+ /** The exact rows the writing transaction returned, one per operation. */
57
+ operationResults?: {
58
+ transactionId: string;
59
+ outcome: 'created' | 'updated' | 'deleted' | 'archived' | 'unarchived';
60
+ row: Record<string, unknown>;
61
+ }[];
56
62
  }): CommitReceiptWire {
57
63
  const lastSyncId = args.lastSyncId ?? 0;
58
64
  return {
@@ -65,6 +71,7 @@ export function confirmedCommitReceiptResponse(args: {
65
71
  status: 'confirmed',
66
72
  lastSyncId,
67
73
  ops: args.ops ?? 1,
74
+ ...(args.operationResults ? { operationResults: args.operationResults } : {}),
68
75
  } satisfies CommitReceiptWire;
69
76
  }
70
77
 
@@ -37,11 +37,14 @@ import type {
37
37
  AbloSession,
38
38
  SessionResource,
39
39
  } from '../resources/httpResources.js';
40
- import { modelList } from '../resources/httpResources.js';
40
+ import { collectModelList, modelList } from '../resources/httpResources.js';
41
+ import { resolveCreateId } from '../resources/modelCreate.js';
41
42
  import type {
42
43
  ModelCreateParams,
44
+ ModelCreateManyParams,
43
45
  ModelDeleteParams,
44
46
  ServerReadOptions,
47
+ ListAllOptions,
45
48
  ModelRetrieveParams,
46
49
  ModelTrackParams,
47
50
  ModelTrackResult,
@@ -136,6 +139,8 @@ export interface HttpModelClient<T, C = T> {
136
139
  * back as `cursor`, with the same `where` and `orderBy`, to walk it.
137
140
  */
138
141
  list(options?: ServerReadOptions<T>): Promise<ModelList<CapturedRow<T>>>;
142
+ /** Reads every matching row, following cursors with a bounded traversal. */
143
+ listAll(options?: ListAllOptions<T>): Promise<CapturedRow<T>[]>;
139
144
  /**
140
145
  * Creates a row and returns it, including any framework-applied defaults.
141
146
  * Passing an id that already exists is idempotent: the existing row is
@@ -148,6 +153,11 @@ export interface HttpModelClient<T, C = T> {
148
153
  * of `update` and `delete`.
149
154
  */
150
155
  create(params: HttpModelMutationParams<ModelCreateParams<T, C>>): Promise<T>;
156
+ /**
157
+ * Creates many rows as one atomic commit, and resolves to them in the order
158
+ * they were given. One rejected row declines the batch.
159
+ */
160
+ create(params: HttpModelMutationParams<ModelCreateManyParams<C>>): Promise<T[]>;
151
161
  update(params: HttpModelMutationParams<ModelUpdateParams<T, C>>): Promise<T>;
152
162
  /**
153
163
  * Updates a row with a function of its latest value — `update(id, current =>
@@ -434,6 +444,65 @@ function createHttpModelClient<T, C = T>(
434
444
  return read.data as CapturedRow<T> | undefined;
435
445
  };
436
446
 
447
+ // `create` is overloaded: one row, or a list of them as one atomic commit.
448
+ // A real overloaded function rather than a property arrow, so both public
449
+ // signatures survive — the same reason `updateModel` is written this way.
450
+ function createModel(
451
+ params: HttpModelMutationParams<ModelCreateParams<T, C>>,
452
+ ): Promise<CapturedRow<T>>;
453
+ function createModel(
454
+ params: HttpModelMutationParams<ModelCreateManyParams<C>>,
455
+ ): Promise<CapturedRow<T>[]>;
456
+ async function createModel(
457
+ params:
458
+ | HttpModelMutationParams<ModelCreateParams<T, C>>
459
+ | HttpModelMutationParams<ModelCreateManyParams<C>>,
460
+ ): Promise<CapturedRow<T> | CapturedRow<T>[]> {
461
+ const prepared = preparedMutation(params);
462
+ try {
463
+ // The list form is one atomic commit through the protocol's batch door.
464
+ // Same verb because it is the same act; the argument says how many.
465
+ if (Array.isArray(params.data)) {
466
+ const rows = await protocol.createMany({
467
+ data: params.data as readonly Record<string, unknown>[],
468
+ ...(prepared.options.idempotencyKey
469
+ ? { idempotencyKey: prepared.options.idempotencyKey }
470
+ : {}),
471
+ ...(prepared.options.reads
472
+ ? { reads: [...prepared.options.reads] }
473
+ : {}),
474
+ ...(prepared.options.track
475
+ ? { track: [...prepared.options.track] }
476
+ : {}),
477
+ });
478
+ consumeReadSet(
479
+ readSetContext,
480
+ clientIdentity,
481
+ prepared.consumed,
482
+ prepared.automaticCommit,
483
+ );
484
+ return rows as CapturedRow<T>[];
485
+ }
486
+ const single = params as HttpModelMutationParams<ModelCreateParams<T, C>>;
487
+ const resolvedId = resolveCreateId(single.id, single.data);
488
+ const row = await protocol.create({
489
+ ...prepared.options,
490
+ ...(resolvedId !== undefined ? { id: resolvedId } : {}),
491
+ data: single.data as Record<string, unknown>,
492
+ });
493
+ consumeReadSet(
494
+ readSetContext,
495
+ clientIdentity,
496
+ prepared.consumed,
497
+ prepared.automaticCommit,
498
+ );
499
+ return row as CapturedRow<T>;
500
+ } catch (error) {
501
+ abortReadSetCommit(readSetContext, prepared.automaticCommit);
502
+ throw error;
503
+ }
504
+ }
505
+
437
506
  const list = async (
438
507
  options?: ServerReadOptions<T>,
439
508
  ): Promise<ModelList<CapturedRow<T>>> => {
@@ -441,6 +510,10 @@ function createHttpModelClient<T, C = T>(
441
510
  const page = modelList<CapturedRow<T>>(
442
511
  snapshot.data as readonly CapturedRow<T>[],
443
512
  snapshot,
513
+ // Following pages re-enter this same read, so each one is captured into
514
+ // the read set exactly as the first was. A `for await` over the result
515
+ // therefore carries the same evidence a manual cursor walk would.
516
+ (cursor) => list({ ...options, cursor }),
444
517
  );
445
518
  const registry = readSetContext?.getStore();
446
519
  if (!registry) return page;
@@ -467,6 +540,15 @@ function createHttpModelClient<T, C = T>(
467
540
  return page;
468
541
  };
469
542
 
543
+ const listAll = async (
544
+ options: ListAllOptions<T> = {},
545
+ ): Promise<CapturedRow<T>[]> => {
546
+ const { maxPages, signal, ...readOptions } = options;
547
+ signal?.throwIfAborted();
548
+ const first = await list(readOptions);
549
+ return collectModelList(first, { maxPages, signal });
550
+ };
551
+
470
552
  // Claim acquisition performs its authoritative read only after the grant.
471
553
  // Preserve that exact post-grant watermark while retaining the callable
472
554
  // namespace and all of its state/queue/release members through the proxy.
@@ -493,28 +575,8 @@ function createHttpModelClient<T, C = T>(
493
575
  get,
494
576
  retrieve: get,
495
577
  list,
496
- async create(params): Promise<T> {
497
- const id = params.id ?? '';
498
- const prepared = preparedMutation(params);
499
- let row: T;
500
- try {
501
- row = await protocol.create({
502
- ...prepared.options,
503
- ...(params.id !== undefined ? { id: params.id } : {}),
504
- data: params.data as Record<string, unknown>,
505
- });
506
- } catch (error) {
507
- abortReadSetCommit(readSetContext, prepared.automaticCommit);
508
- throw error;
509
- }
510
- consumeReadSet(
511
- readSetContext,
512
- clientIdentity,
513
- prepared.consumed,
514
- prepared.automaticCommit,
515
- );
516
- return row;
517
- },
578
+ listAll,
579
+ create: createModel,
518
580
  update,
519
581
  async delete(params): Promise<void> {
520
582
  const prepared = preparedMutation(params);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The HTTP commit as a VALUE: what it is keyed by, the operations it carries,
3
+ * the exact bytes it is replayed as, and the error when its echo never lands.
4
+ *
5
+ * None of this touches the transport's state. It was written inside
6
+ * `createHttpTransport` because that is where it was first needed, which made a
7
+ * two-thousand-line closure the only place the shape of a commit was stated.
8
+ * Read together here, the four say one thing: a commit is identified by its
9
+ * idempotency key, normalized once so every operation carries the same
10
+ * defaults, replayed byte for byte, and abandoned only when the source accepted
11
+ * it and the replication echo did not arrive.
12
+ */
13
+
14
+ import { AbloConnectionError, AbloValidationError } from '../errors.js';
15
+ import type { BatchFence } from '../coordination/locator.js';
16
+ import { claimIdFor, fenceTokenFor } from '../coordination/locator.js';
17
+ import type { Claim } from '../types/streams.js';
18
+ import type { CommitCreateOptions, CommitOperationInput } from '../resources/httpResources.js';
19
+ import type { CommitReceiptWire } from '../wire/commit.js';
20
+ import type { DurableHttpCommitMethod } from '../transactions/confirmation/httpCommitEnvelope.js';
21
+
22
+ /**
23
+ * One commit, exactly as it will be sent and re-sent. A replay that changed any
24
+ * of these would be a different request wearing the same idempotency key.
25
+ */
26
+ export interface ExactHttpCommitRequest {
27
+ readonly idempotencyKey: string;
28
+ readonly method: DurableHttpCommitMethod;
29
+ readonly path: string;
30
+ readonly body: string;
31
+ readonly sealedProtocolVersion?: number;
32
+ }
33
+
34
+ /**
35
+ * The key a commit is replayed under. A caller's idempotency key wins, because
36
+ * it is the caller's statement that two attempts are the same write.
37
+ */
38
+ export function createClientTxId(idempotencyKey?: string | null): string {
39
+ if (idempotencyKey && idempotencyKey.length > 0) return idempotencyKey;
40
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
41
+ ? crypto.randomUUID()
42
+ : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
43
+ }
44
+
45
+ /** One operation with the batch's defaults resolved onto it. */
46
+ export function normalizeCommitOperation(
47
+ op: CommitOperationInput,
48
+ defaults: Pick<CommitCreateOptions, 'readAt' | 'onStale'>,
49
+ fence: BatchFence | null,
50
+ claim: Claim | null,
51
+ ): CommitOperationInput {
52
+ return {
53
+ action: op.action,
54
+ model: op.model,
55
+ id: op.id ?? null,
56
+ data: op.data ?? null,
57
+ where: op.where ?? null,
58
+ transactionId: op.transactionId ?? null,
59
+ claimId: op.claimId ?? claimIdFor(claim?.target, claim?.id, op.model, op.id ?? null),
60
+ readAt: op.readAt ?? defaults.readAt ?? null,
61
+ onStale: op.onStale ?? defaults.onStale ?? null,
62
+ fenceToken: op.fenceToken ?? fenceTokenFor(fence, op.model, op.id ?? null),
63
+ };
64
+ }
65
+
66
+ /** Every operation in a batch, normalized against the batch's own options. */
67
+ export function normalizeCommitOperations(
68
+ commitOptions: CommitCreateOptions,
69
+ fence: BatchFence | null,
70
+ ): readonly CommitOperationInput[] {
71
+ if (commitOptions.operations.length === 0) {
72
+ throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
73
+ code: 'commit_operation_required',
74
+ });
75
+ }
76
+ return commitOptions.operations.map((op) =>
77
+ normalizeCommitOperation(op, commitOptions, fence, commitOptions.claim ?? null),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * The source accepted the write and its replication echo did not arrive in
83
+ * time. `accepted: true` is the load-bearing detail: the row may well be
84
+ * written, so this is a confirmation timeout and never a reason to write again.
85
+ */
86
+ export function replicationLagTimeout(
87
+ request: ExactHttpCommitRequest,
88
+ response: CommitReceiptWire,
89
+ requestTimeoutMs: number,
90
+ ): AbloConnectionError {
91
+ return new AbloConnectionError(
92
+ `The source accepted commit ${request.idempotencyKey}, but its replication echo did not arrive within ${requestTimeoutMs}ms.`,
93
+ {
94
+ code: 'replication_lag_timeout',
95
+ httpStatus: 504,
96
+ details: {
97
+ clientTxId: request.idempotencyKey,
98
+ ...(response.correlationId ? { correlationId: response.correlationId } : {}),
99
+ timeoutMs: requestTimeoutMs,
100
+ accepted: true,
101
+ },
102
+ },
103
+ );
104
+ }