@abloatai/transaction 0.55.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 (213) hide show
  1. package/CONVENTIONS.md +34 -0
  2. package/dist/auth/hostedEndpoints.d.ts +21 -5
  3. package/dist/auth/hostedEndpoints.d.ts.map +1 -1
  4. package/dist/auth/hostedEndpoints.js +21 -5
  5. package/dist/auth/hostedEndpoints.js.map +1 -1
  6. package/dist/auth/index.d.ts +1 -1
  7. package/dist/auth/index.d.ts.map +1 -1
  8. package/dist/auth/index.js +1 -1
  9. package/dist/auth/index.js.map +1 -1
  10. package/dist/coordination/index.d.ts +2 -2
  11. package/dist/coordination/index.d.ts.map +1 -1
  12. package/dist/coordination/index.js +1 -1
  13. package/dist/coordination/index.js.map +1 -1
  14. package/dist/coordination/schema.d.ts +0 -3
  15. package/dist/coordination/schema.d.ts.map +1 -1
  16. package/dist/coordination/schema.js +4 -5
  17. package/dist/coordination/schema.js.map +1 -1
  18. package/dist/errorCodes.d.ts +1 -0
  19. package/dist/errorCodes.d.ts.map +1 -1
  20. package/dist/errorCodes.js +1 -0
  21. package/dist/errorCodes.js.map +1 -1
  22. package/dist/errors.d.ts.map +1 -1
  23. package/dist/errors.js +4 -1
  24. package/dist/errors.js.map +1 -1
  25. package/dist/log/syncDeltaRow.d.ts +3 -3
  26. package/dist/readSetContext.d.ts.map +1 -1
  27. package/dist/readSetContext.js +8 -3
  28. package/dist/readSetContext.js.map +1 -1
  29. package/dist/resources/httpResources.d.ts +43 -22
  30. package/dist/resources/httpResources.d.ts.map +1 -1
  31. package/dist/resources/httpResources.js +81 -4
  32. package/dist/resources/httpResources.js.map +1 -1
  33. package/dist/resources/modelCreate.d.ts +21 -0
  34. package/dist/resources/modelCreate.d.ts.map +1 -0
  35. package/dist/resources/modelCreate.js +49 -0
  36. package/dist/resources/modelCreate.js.map +1 -0
  37. package/dist/resources/modelOperations.d.ts +24 -2
  38. package/dist/resources/modelOperations.d.ts.map +1 -1
  39. package/dist/resources/modelOperations.js.map +1 -1
  40. package/dist/resources/writeOptionsSchema.d.ts +14 -0
  41. package/dist/resources/writeOptionsSchema.d.ts.map +1 -1
  42. package/dist/resources/writeOptionsSchema.js +23 -2
  43. package/dist/resources/writeOptionsSchema.js.map +1 -1
  44. package/dist/schema/audit.d.ts +15 -0
  45. package/dist/schema/audit.d.ts.map +1 -0
  46. package/dist/schema/audit.js +90 -0
  47. package/dist/schema/audit.js.map +1 -0
  48. package/dist/schema/ddl.d.ts.map +1 -1
  49. package/dist/schema/ddl.js +68 -1
  50. package/dist/schema/ddl.js.map +1 -1
  51. package/dist/schema/index.d.ts +3 -1
  52. package/dist/schema/index.d.ts.map +1 -1
  53. package/dist/schema/index.js +3 -1
  54. package/dist/schema/index.js.map +1 -1
  55. package/dist/schema/model.d.ts +14 -0
  56. package/dist/schema/model.d.ts.map +1 -1
  57. package/dist/schema/model.js +2 -0
  58. package/dist/schema/model.js.map +1 -1
  59. package/dist/schema/openapi.d.ts.map +1 -1
  60. package/dist/schema/openapi.js +104 -2
  61. package/dist/schema/openapi.js.map +1 -1
  62. package/dist/schema/roles.d.ts +27 -0
  63. package/dist/schema/roles.d.ts.map +1 -1
  64. package/dist/schema/roles.js +40 -0
  65. package/dist/schema/roles.js.map +1 -1
  66. package/dist/schema/schema.d.ts +23 -4
  67. package/dist/schema/schema.d.ts.map +1 -1
  68. package/dist/schema/schema.js +30 -1
  69. package/dist/schema/schema.js.map +1 -1
  70. package/dist/schema/serialize.d.ts +4 -1
  71. package/dist/schema/serialize.d.ts.map +1 -1
  72. package/dist/schema/serialize.js +5 -1
  73. package/dist/schema/serialize.js.map +1 -1
  74. package/dist/schema/subject.d.ts +18 -0
  75. package/dist/schema/subject.d.ts.map +1 -0
  76. package/dist/schema/subject.js +27 -0
  77. package/dist/schema/subject.js.map +1 -0
  78. package/dist/server/adapter.d.ts +2 -0
  79. package/dist/server/adapter.d.ts.map +1 -1
  80. package/dist/server/readConfig.d.ts +3 -0
  81. package/dist/server/readConfig.d.ts.map +1 -1
  82. package/dist/server/readConfig.js +0 -21
  83. package/dist/server/readConfig.js.map +1 -1
  84. package/dist/source/adapters/drizzle.d.ts.map +1 -1
  85. package/dist/source/adapters/drizzle.js +37 -6
  86. package/dist/source/adapters/drizzle.js.map +1 -1
  87. package/dist/source/adapters/kysely.d.ts +2 -0
  88. package/dist/source/adapters/kysely.d.ts.map +1 -1
  89. package/dist/source/adapters/kysely.js +33 -5
  90. package/dist/source/adapters/kysely.js.map +1 -1
  91. package/dist/source/adapters/kyselyMutationCore.d.ts +4 -1
  92. package/dist/source/adapters/kyselyMutationCore.d.ts.map +1 -1
  93. package/dist/source/adapters/kyselyMutationCore.js +17 -8
  94. package/dist/source/adapters/kyselyMutationCore.js.map +1 -1
  95. package/dist/source/adapters/memory.d.ts.map +1 -1
  96. package/dist/source/adapters/memory.js +1 -0
  97. package/dist/source/adapters/memory.js.map +1 -1
  98. package/dist/source/adapters/prisma.d.ts.map +1 -1
  99. package/dist/source/adapters/prisma.js +59 -7
  100. package/dist/source/adapters/prisma.js.map +1 -1
  101. package/dist/source/contract.d.ts +14 -0
  102. package/dist/source/contract.d.ts.map +1 -1
  103. package/dist/source/contract.js +10 -0
  104. package/dist/source/contract.js.map +1 -1
  105. package/dist/source/factory.d.ts +7 -1
  106. package/dist/source/factory.d.ts.map +1 -1
  107. package/dist/source/factory.js +116 -5
  108. package/dist/source/factory.js.map +1 -1
  109. package/dist/source/index.d.ts +2 -1
  110. package/dist/source/index.d.ts.map +1 -1
  111. package/dist/source/index.js +1 -0
  112. package/dist/source/index.js.map +1 -1
  113. package/dist/source/migrations.d.ts.map +1 -1
  114. package/dist/source/migrations.js +21 -0
  115. package/dist/source/migrations.js.map +1 -1
  116. package/dist/source/subjectAuthorization.d.ts +16 -0
  117. package/dist/source/subjectAuthorization.d.ts.map +1 -0
  118. package/dist/source/subjectAuthorization.js +126 -0
  119. package/dist/source/subjectAuthorization.js.map +1 -0
  120. package/dist/source/types.d.ts +52 -2
  121. package/dist/source/types.d.ts.map +1 -1
  122. package/dist/source/types.js +1 -0
  123. package/dist/source/types.js.map +1 -1
  124. package/dist/syncLog/contract.d.ts +45 -3
  125. package/dist/syncLog/contract.d.ts.map +1 -1
  126. package/dist/syncLog/contract.js +46 -4
  127. package/dist/syncLog/contract.js.map +1 -1
  128. package/dist/testing/fixtures/httpResponses.d.ts +6 -0
  129. package/dist/testing/fixtures/httpResponses.d.ts.map +1 -1
  130. package/dist/testing/fixtures/httpResponses.js +1 -0
  131. package/dist/testing/fixtures/httpResponses.js.map +1 -1
  132. package/dist/transport/httpClient.d.ts +8 -1
  133. package/dist/transport/httpClient.d.ts.map +1 -1
  134. package/dist/transport/httpClient.js +51 -20
  135. package/dist/transport/httpClient.js.map +1 -1
  136. package/dist/transport/httpCommitRequest.d.ts +45 -0
  137. package/dist/transport/httpCommitRequest.d.ts.map +1 -0
  138. package/dist/transport/httpCommitRequest.js +67 -0
  139. package/dist/transport/httpCommitRequest.js.map +1 -0
  140. package/dist/transport/httpTransport.d.ts.map +1 -1
  141. package/dist/transport/httpTransport.js +69 -106
  142. package/dist/transport/httpTransport.js.map +1 -1
  143. package/dist/transport/httpTransportHelpers.d.ts +10 -0
  144. package/dist/transport/httpTransportHelpers.d.ts.map +1 -0
  145. package/dist/transport/httpTransportHelpers.js +46 -0
  146. package/dist/transport/httpTransportHelpers.js.map +1 -0
  147. package/dist/wire/apiLifecycle.d.ts +63 -0
  148. package/dist/wire/apiLifecycle.d.ts.map +1 -0
  149. package/dist/wire/apiLifecycle.js +89 -0
  150. package/dist/wire/apiLifecycle.js.map +1 -0
  151. package/dist/wire/auth.d.ts.map +1 -1
  152. package/dist/wire/auth.js +6 -3
  153. package/dist/wire/auth.js.map +1 -1
  154. package/dist/wire/commit.d.ts +74 -9
  155. package/dist/wire/commit.d.ts.map +1 -1
  156. package/dist/wire/commit.js +18 -5
  157. package/dist/wire/commit.js.map +1 -1
  158. package/dist/wire/index.d.ts +5 -2
  159. package/dist/wire/index.d.ts.map +1 -1
  160. package/dist/wire/index.js +12 -1
  161. package/dist/wire/index.js.map +1 -1
  162. package/dist/wire/modelMutations.js +3 -2
  163. package/dist/wire/modelMutations.js.map +1 -1
  164. package/dist/wire/rateLimit.d.ts +82 -0
  165. package/dist/wire/rateLimit.d.ts.map +1 -0
  166. package/dist/wire/rateLimit.js +142 -0
  167. package/dist/wire/rateLimit.js.map +1 -0
  168. package/package.json +1 -1
  169. package/src/auth/hostedEndpoints.ts +23 -5
  170. package/src/auth/index.ts +2 -0
  171. package/src/coordination/index.ts +0 -2
  172. package/src/coordination/schema.ts +4 -7
  173. package/src/errorCodes.ts +6 -0
  174. package/src/errors.ts +4 -1
  175. package/src/readSetContext.ts +8 -2
  176. package/src/resources/httpResources.ts +116 -25
  177. package/src/resources/modelCreate.ts +73 -0
  178. package/src/resources/modelOperations.ts +29 -2
  179. package/src/resources/writeOptionsSchema.ts +30 -2
  180. package/src/schema/audit.ts +121 -0
  181. package/src/schema/ddl.ts +71 -1
  182. package/src/schema/index.ts +16 -0
  183. package/src/schema/model.ts +17 -0
  184. package/src/schema/openapi.ts +123 -2
  185. package/src/schema/roles.ts +53 -0
  186. package/src/schema/schema.ts +68 -3
  187. package/src/schema/serialize.ts +8 -1
  188. package/src/schema/subject.ts +43 -0
  189. package/src/server/adapter.ts +2 -0
  190. package/src/server/readConfig.ts +4 -0
  191. package/src/source/adapters/drizzle.ts +50 -6
  192. package/src/source/adapters/kysely.ts +47 -4
  193. package/src/source/adapters/kyselyMutationCore.ts +21 -9
  194. package/src/source/adapters/memory.ts +1 -0
  195. package/src/source/adapters/prisma.ts +70 -7
  196. package/src/source/contract.ts +11 -0
  197. package/src/source/factory.ts +141 -5
  198. package/src/source/index.ts +6 -0
  199. package/src/source/migrations.ts +21 -0
  200. package/src/source/subjectAuthorization.ts +182 -0
  201. package/src/source/types.ts +56 -2
  202. package/src/syncLog/contract.ts +47 -5
  203. package/src/testing/fixtures/httpResponses.ts +7 -0
  204. package/src/transport/httpClient.ts +85 -23
  205. package/src/transport/httpCommitRequest.ts +104 -0
  206. package/src/transport/httpTransport.ts +92 -142
  207. package/src/transport/httpTransportHelpers.ts +63 -0
  208. package/src/wire/apiLifecycle.ts +94 -0
  209. package/src/wire/auth.ts +6 -3
  210. package/src/wire/commit.ts +20 -5
  211. package/src/wire/index.ts +31 -0
  212. package/src/wire/modelMutations.ts +2 -2
  213. package/src/wire/rateLimit.ts +155 -0
@@ -17,7 +17,6 @@ import {
17
17
  claimedError,
18
18
  translateHttpError,
19
19
  } from '../errors.js';
20
- import { v5 as uuidv5 } from 'uuid';
21
20
  import { z } from 'zod';
22
21
  import {
23
22
  reconcileFunctionalUpdate,
@@ -37,7 +36,6 @@ import {
37
36
  } from '../auth/apiKey.js';
38
37
  import { PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER } from '../wire/protocolVersion.js';
39
38
  import {
40
- commitReceiptSchema,
41
39
  commitRecordSchema,
42
40
  commitRecordListSchema,
43
41
  commitRecordListOptionsSchema,
@@ -99,25 +97,11 @@ import {
99
97
  import { parseIdentityResolveResponse } from '../auth/schemas.js';
100
98
  import type { EffectiveAuthority } from '../auth/capability.js';
101
99
 
102
- /**
103
- * Interpret a heartbeat reply for a lease this handle HOLDS: anything other
104
- * than `held` means the lease is no longer ours (a holder cannot be `queued`;
105
- * `lost` rides a 409 that the wire error mapping already surfaces as
106
- * AbloClaimedError before reaching here). The thrown loss is the definitive
107
- * signal that stops the auto-heartbeat loop.
108
- */
109
- function heldHeartbeatReply(reply: ClaimHeartbeatReply, label: string): ClaimHeartbeat {
110
- if (reply.status === 'held' && typeof reply.expiresAt === 'number') {
111
- return {
112
- expiresAt: reply.expiresAt,
113
- ...(reply.queueDepth !== undefined ? { queueDepth: reply.queueDepth } : {}),
114
- };
115
- }
116
- throw new AbloClaimedError(
117
- `The lease behind ${label} is no longer held — it expired or was granted onward. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`,
118
- { code: 'claim_lost' }
119
- );
120
- }
100
+ import {
101
+ claimFromModelClaim,
102
+ heldHeartbeatReply,
103
+ parseSuccessfulCommitResponse,
104
+ } from './httpTransportHelpers.js';
121
105
  import type { SchemaRecord } from '../schema/schema.js';
122
106
  import type {
123
107
  ClaimLookupParams,
@@ -127,6 +111,7 @@ import type {
127
111
  ClaimReorderParams,
128
112
  ModelTrackParams,
129
113
  ModelTrackResult,
114
+ ModelCreateManyParams,
130
115
  ServerReadOptions,
131
116
  ResolvedClaimContentionOptions,
132
117
  ClaimQueueView,
@@ -151,7 +136,15 @@ import {
151
136
  import { declaredMeta, wireMeta } from '../coordination/claimMeta.js';
152
137
  import type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim } from '../types/streams.js';
153
138
  import type { CoordinationObservability } from '../observability.js';
154
- import { assertWriteOptions } from '../resources/writeOptionsSchema.js';
139
+ import {
140
+ assertWriteOptions,
141
+ assertWriteTarget,
142
+ } from '../resources/writeOptionsSchema.js';
143
+ import {
144
+ createModelId,
145
+ resolveCreatedRows,
146
+ resolveCreateId,
147
+ } from '../resources/modelCreate.js';
155
148
  import { normalizeWhere } from '../resources/where.js';
156
149
  import {
157
150
  createDurableHttpCommitEnvelope,
@@ -163,6 +156,12 @@ import {
163
156
  type DurableHttpCommitMethod,
164
157
  } from '../transactions/confirmation/httpCommitEnvelope.js';
165
158
  import type { CommitOutboxScope } from '../transactions/confirmation/commitEnvelope.js';
159
+ import {
160
+ createClientTxId,
161
+ normalizeCommitOperations,
162
+ replicationLagTimeout,
163
+ type ExactHttpCommitRequest,
164
+ } from './httpCommitRequest.js';
166
165
  import { resolveDurableWrites } from '../durableWrites.js';
167
166
 
168
167
  /** @internal Private options for the schema-agnostic HTTP protocol transport. */
@@ -239,46 +238,6 @@ export interface HttpTransport {
239
238
 
240
239
  type CommitResponse = CommitReceiptWire;
241
240
 
242
- function parseSuccessfulCommitResponse(value: unknown, idempotencyKey: string): CommitResponse {
243
- const parsed = commitReceiptSchema.safeParse(value);
244
- if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
245
- throw new AbloConnectionError(
246
- 'The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.',
247
- {
248
- code: 'commit_no_result',
249
- cause: parsed.success
250
- ? new Error('Commit receipt clientTxId did not match its idempotency key')
251
- : parsed.error,
252
- }
253
- );
254
- }
255
- return parsed.data;
256
- }
257
-
258
- /** Decode the HTTP claim DTO into the one public Claim shape. */
259
- function claimFromModelClaim(claim: ModelClaim): Claim {
260
- // The handle a caller reads back is a public claim, so its `meta` is the
261
- // declared shape; the rest of the sub-entity locator crosses whole rather
262
- // than member by member, which is how `fields` used to die on this hop.
263
- const { meta, ...details } = subTarget(claim.target);
264
- return {
265
- object: 'claim',
266
- id: claim.id,
267
- ...(claim.status ? { status: claim.status } : {}),
268
- // The server always stamps a description; default only for total safety.
269
- description: claim.description ?? 'editing',
270
- heldBy: claim.actor,
271
- participantKind: claim.participantKind,
272
- expiresAt: claim.expiresAt,
273
- ...(claim.position !== undefined ? { position: claim.position } : {}),
274
- target: {
275
- ...streamTarget(claim.target),
276
- ...details,
277
- ...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
278
- },
279
- };
280
- }
281
-
282
241
  /** @internal Constructed only by the typed HTTP facade. */
283
242
  export function createHttpTransport(options: HttpTransportOptions): HttpTransport {
284
243
  const env = readProcessEnv();
@@ -703,14 +662,6 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
703
662
  readonly response: CommitResponse;
704
663
  }
705
664
 
706
- interface ExactHttpCommitRequest {
707
- readonly idempotencyKey: string;
708
- readonly method: DurableHttpCommitMethod;
709
- readonly path: string;
710
- readonly body: string;
711
- readonly sealedProtocolVersion?: number;
712
- }
713
-
714
665
  function observeCommitReceipt(
715
666
  request: ExactHttpCommitRequest,
716
667
  receipt: CommitResponse,
@@ -738,25 +689,6 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
738
689
  }
739
690
  }
740
691
 
741
- function replicationLagTimeout(
742
- request: ExactHttpCommitRequest,
743
- response: CommitResponse
744
- ): AbloConnectionError {
745
- return new AbloConnectionError(
746
- `The source accepted commit ${request.idempotencyKey}, but its replication echo did not arrive within ${requestTimeoutMs}ms.`,
747
- {
748
- code: 'replication_lag_timeout',
749
- httpStatus: 504,
750
- details: {
751
- clientTxId: request.idempotencyKey,
752
- ...(response.correlationId ? { correlationId: response.correlationId } : {}),
753
- timeoutMs: requestTimeoutMs,
754
- accepted: true,
755
- },
756
- }
757
- );
758
- }
759
-
760
692
  /**
761
693
  * Replays one byte-identical, idempotent HTTP commit until mutation-log
762
694
  * replay reports the source echo as confirmed. `queued` is acceptance only:
@@ -779,7 +711,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
779
711
  while (current.status === 'queued') {
780
712
  const remaining = deadlineAt === null ? null : deadlineAt - Date.now();
781
713
  if (remaining !== null && remaining <= 0) {
782
- throw replicationLagTimeout(request, current);
714
+ throw replicationLagTimeout(request, current, requestTimeoutMs);
783
715
  }
784
716
 
785
717
  const confirmationController = new AbortController();
@@ -817,7 +749,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
817
749
  confirmationController.signal.aborted ||
818
750
  (deadlineAt !== null && Date.now() >= deadlineAt)
819
751
  ) {
820
- throw replicationLagTimeout(request, current);
752
+ throw replicationLagTimeout(request, current, requestTimeoutMs);
821
753
  }
822
754
  throw error;
823
755
  } finally {
@@ -1128,57 +1060,6 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1128
1060
  });
1129
1061
  }
1130
1062
 
1131
- function createClientTxId(idempotencyKey?: string | null): string {
1132
- if (idempotencyKey && idempotencyKey.length > 0) return idempotencyKey;
1133
- return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
1134
- ? crypto.randomUUID()
1135
- : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1136
- }
1137
-
1138
- function createModelId(modelName: string, idempotencyKey?: string | null): string {
1139
- if (idempotencyKey) {
1140
- return uuidv5(`${modelName}:${idempotencyKey}`, 'aa4ba6d4-bf0b-5b38-9c45-116f79a6e548');
1141
- }
1142
- return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
1143
- ? crypto.randomUUID()
1144
- : `id_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
1145
- }
1146
-
1147
- function normalizeCommitOperation(
1148
- op: CommitOperationInput,
1149
- defaults: Pick<CommitCreateOptions, 'readAt' | 'onStale'>,
1150
- fence: BatchFence | null,
1151
- claim: Claim | null,
1152
- ): CommitOperationInput {
1153
- return {
1154
- action: op.action,
1155
- model: op.model,
1156
- id: op.id ?? null,
1157
- data: op.data ?? null,
1158
- where: op.where ?? null,
1159
- transactionId: op.transactionId ?? null,
1160
- claimId:
1161
- op.claimId ?? claimIdFor(claim?.target, claim?.id, op.model, op.id ?? null),
1162
- readAt: op.readAt ?? defaults.readAt ?? null,
1163
- onStale: op.onStale ?? defaults.onStale ?? null,
1164
- fenceToken: op.fenceToken ?? fenceTokenFor(fence, op.model, op.id ?? null),
1165
- };
1166
- }
1167
-
1168
- function normalizeCommitOperations(
1169
- commitOptions: CommitCreateOptions,
1170
- fence: BatchFence | null
1171
- ): readonly CommitOperationInput[] {
1172
- if (commitOptions.operations.length === 0) {
1173
- throw new AbloValidationError('Commit requires a non-empty `operations` array.', {
1174
- code: 'commit_operation_required',
1175
- });
1176
- }
1177
- return commitOptions.operations.map((op) =>
1178
- normalizeCommitOperation(op, commitOptions, fence, commitOptions.claim ?? null)
1179
- );
1180
- }
1181
-
1182
1063
  async function listClaimState(
1183
1064
  target?: Partial<ModelTarget>
1184
1065
  ): Promise<{ active: readonly ModelClaim[]; queue: readonly ModelClaim[] }> {
@@ -1607,6 +1488,7 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1607
1488
  },
1608
1489
  `${modelName} ${action}`
1609
1490
  );
1491
+ if (action !== 'create') assertWriteTarget(action, modelName, id);
1610
1492
  const clientTxId = createClientTxId(options?.idempotencyKey);
1611
1493
  const encModel = encodeURIComponent(modelName);
1612
1494
  const path =
@@ -1945,12 +1827,26 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1945
1827
  if (!claimInput) return run(input);
1946
1828
 
1947
1829
  if (isClaimHandle(claimInput)) {
1830
+ // The lease's watermark travels with it. Unwrapping the handle to a
1831
+ // `claimRef` is what put this here: `mutateModel` reads `readAt` off a
1832
+ // handle on `claim`, and this sets `claim: undefined` before it looks,
1833
+ // so the stale guard was silently dropped on every write that named a
1834
+ // handle. The result was mutual exclusion without lost-update
1835
+ // detection, on the transport agents run, under code that reads as
1836
+ // though it had both. The reactive client binds the same three values
1837
+ // from the claim it holds; a caller's own `readAt`/`onStale` win.
1948
1838
  return run({
1949
1839
  ...input,
1950
1840
  claimRef: { id: claimInput.id },
1951
1841
  ...(claimInput.fenceToken !== undefined
1952
1842
  ? { fenceToken: claimInput.fenceToken }
1953
1843
  : {}),
1844
+ ...(input?.readAt === undefined && claimInput.readAt !== undefined
1845
+ ? { readAt: claimInput.readAt }
1846
+ : {}),
1847
+ ...(input?.onStale === undefined && claimInput.readAt !== undefined
1848
+ ? { onStale: 'reject' as const }
1849
+ : {}),
1954
1850
  claim: undefined,
1955
1851
  });
1956
1852
  }
@@ -2079,6 +1975,60 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
2079
1975
  return created;
2080
1976
  });
2081
1977
  },
1978
+ /**
1979
+ * Creates many rows as ONE commit, and returns them.
1980
+ *
1981
+ * Atomic on purpose: a batch that half-lands leaves the caller to work
1982
+ * out which half, and every other write door here declines whole. One
1983
+ * rejected row declines the batch.
1984
+ *
1985
+ * A fresh receipt returns the authoritative rows directly from the
1986
+ * writing transaction. A durable idempotency replay deliberately redacts
1987
+ * those rows, so that path verifies every deterministic id with a point
1988
+ * read and fails loudly if policy prevents a complete answer. Neither
1989
+ * path can report a partial collection as success.
1990
+ *
1991
+ * Ids are resolved here rather than left to the server, so the results
1992
+ * can be returned in the caller's own order rather than the order the
1993
+ * transaction happened to settle them.
1994
+ */
1995
+ async createMany(
1996
+ params: ModelCreateManyParams<Record<string, unknown>>
1997
+ ): Promise<T[]> {
1998
+ if (params.data.length === 0) return [];
1999
+
2000
+ // A claim/stale guard addresses one existing row, so the canonical
2001
+ // batch-create options do not admit those fields at all.
2002
+ const { data, ...options } = params;
2003
+ const ids = data.map(
2004
+ (row, index) =>
2005
+ resolveCreateId(undefined, row) ??
2006
+ createModelId(
2007
+ name,
2008
+ params.idempotencyKey ? `${params.idempotencyKey}:${index}` : null
2009
+ )
2010
+ );
2011
+ const receipt = await commits.create({
2012
+ ...options,
2013
+ operations: data.map((row, index) => ({
2014
+ action: 'create' as const,
2015
+ model: name,
2016
+ id: ids[index] ?? null,
2017
+ data: row,
2018
+ })),
2019
+ wait: 'confirmed',
2020
+ });
2021
+
2022
+ return resolveCreatedRows<T>({
2023
+ modelName: name,
2024
+ ids,
2025
+ operationResults: receipt.operationResults,
2026
+ readRow: async (id) => {
2027
+ const read = await retrieveModel<T>(name, { id });
2028
+ return read.data;
2029
+ },
2030
+ });
2031
+ },
2082
2032
  update: updateModel,
2083
2033
  async delete(params: ModelMutationOptions & { readonly id: string }): Promise<CommitReceipt> {
2084
2034
  return withMutationClaim(params.id, params, async (options) => {
@@ -0,0 +1,63 @@
1
+ import { AbloClaimedError, AbloConnectionError } from '../errors.js';
2
+ import { commitReceiptSchema, type CommitReceiptWire } from '../wire/commit.js';
3
+ import type { ClaimHeartbeatReply } from '../wire/claims.js';
4
+ import type { ModelClaim } from '../resources/httpResources.js';
5
+ import { subTarget, streamTarget } from '../coordination/locator.js';
6
+ import { declaredMeta } from '../coordination/claimMeta.js';
7
+ import type { Claim, ClaimHeartbeat } from '../types/streams.js';
8
+
9
+ /** Interpret a heartbeat reply for a lease this handle currently holds. */
10
+ export function heldHeartbeatReply(
11
+ reply: ClaimHeartbeatReply,
12
+ label: string,
13
+ ): ClaimHeartbeat {
14
+ if (reply.status === 'held' && typeof reply.expiresAt === 'number') {
15
+ return {
16
+ expiresAt: reply.expiresAt,
17
+ ...(reply.queueDepth !== undefined ? { queueDepth: reply.queueDepth } : {}),
18
+ };
19
+ }
20
+ throw new AbloClaimedError(
21
+ `The lease behind ${label} is no longer held — it expired or was granted onward. Re-acquire the claim and retry; a write attempted under the old lease is rejected by its \`readAt\` guard.`,
22
+ { code: 'claim_lost' },
23
+ );
24
+ }
25
+
26
+ export function parseSuccessfulCommitResponse(
27
+ value: unknown,
28
+ idempotencyKey: string,
29
+ ): CommitReceiptWire {
30
+ const parsed = commitReceiptSchema.safeParse(value);
31
+ if (!parsed.success || parsed.data.clientTxId !== idempotencyKey) {
32
+ throw new AbloConnectionError(
33
+ 'The commit endpoint returned an invalid success receipt; its outcome remains pending and is safe to retry.',
34
+ {
35
+ code: 'commit_no_result',
36
+ cause: parsed.success
37
+ ? new Error('Commit receipt clientTxId did not match its idempotency key')
38
+ : parsed.error,
39
+ },
40
+ );
41
+ }
42
+ return parsed.data;
43
+ }
44
+
45
+ /** Decode the HTTP claim DTO into the public Claim shape. */
46
+ export function claimFromModelClaim(claim: ModelClaim): Claim {
47
+ const { meta, ...details } = subTarget(claim.target);
48
+ return {
49
+ object: 'claim',
50
+ id: claim.id,
51
+ ...(claim.status ? { status: claim.status } : {}),
52
+ description: claim.description ?? 'editing',
53
+ heldBy: claim.actor,
54
+ participantKind: claim.participantKind,
55
+ expiresAt: claim.expiresAt,
56
+ ...(claim.position !== undefined ? { position: claim.position } : {}),
57
+ target: {
58
+ ...streamTarget(claim.target),
59
+ ...details,
60
+ ...(meta !== undefined ? { meta: declaredMeta(meta) } : {}),
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What a caller can rely on about the HTTP surface not moving under it: where
3
+ * the version is written, what a removal looks like before it happens, and how
4
+ * much warning it comes with.
5
+ *
6
+ * An agent decides whether to integrate against an API partly on whether the
7
+ * API can change without telling it. That decision needs facts, not a promise,
8
+ * so each of these is a value some other surface derives from — the path
9
+ * segment every route is mounted under, the header every response carries, and
10
+ * the two standard fields a withdrawal is announced on. {@link API_LIFECYCLE}
11
+ * is the same set written as prose, and it is what the published OpenAPI
12
+ * document's description carries: the policy and the routes it governs are then
13
+ * one artifact, and a reader who has the spec has the policy.
14
+ *
15
+ * Two axes, deliberately separate:
16
+ *
17
+ * - The PATH version ({@link API_PATH_VERSION}) is the breaking axis. It
18
+ * changes only when a shape a caller sends or reads changes incompatibly,
19
+ * and the old segment keeps serving through the notice window.
20
+ * - The CONTRACT date on {@link API_VERSION_HEADER} is the additive axis. It
21
+ * moves when something observable is added — a new error code, a new field
22
+ * — and a caller pinned to an older reading keeps working. Its value is
23
+ * `ERROR_CONTRACT_VERSION`, stamped by the server on every response.
24
+ */
25
+
26
+ /**
27
+ * The path segment every route is mounted under: `/api/v1/...`. Callers address
28
+ * this literally, and the OpenAPI document's paths are written from it, so the
29
+ * spec cannot advertise a version the server does not serve.
30
+ */
31
+ export const API_PATH_VERSION = 'v1';
32
+
33
+ /**
34
+ * Carries the date-stamped contract version on every response. A client that
35
+ * records the value it was built against can tell that the server has moved
36
+ * without waiting for a field it does not recognize to break something.
37
+ */
38
+ export const API_VERSION_HEADER = 'Ablo-Version';
39
+
40
+ /**
41
+ * Announces that a route will be withdrawn, as an sf-Date of the moment the
42
+ * deprecation took effect (RFC 9745): `Deprecation: @1774483200`. It appears
43
+ * while the route still works — a deprecated route answers normally.
44
+ */
45
+ export const API_DEPRECATION_HEADER = 'Deprecation';
46
+
47
+ /**
48
+ * The moment the route stops answering, as an HTTP-date (RFC 8594):
49
+ * `Sunset: Tue, 08 Sep 2026 00:00:00 GMT`. Paired with
50
+ * {@link API_DEPRECATION_HEADER}, the two bound the window a caller has.
51
+ */
52
+ export const API_SUNSET_HEADER = 'Sunset';
53
+
54
+ /**
55
+ * The minimum gap between the two headers above. A number rather than a
56
+ * sentence because it is the part a caller plans against, and because the
57
+ * published policy renders it rather than restating it.
58
+ */
59
+ export const API_DEPRECATION_NOTICE_DAYS = 180;
60
+
61
+ /**
62
+ * The policy, in the words it is published in.
63
+ *
64
+ * Written here rather than in the document that shows it because two documents
65
+ * show it — the OpenAPI description and the site's API page — and a policy
66
+ * stated twice is a policy that will eventually say two things.
67
+ */
68
+ export const API_LIFECYCLE = `## Versioning
69
+
70
+ Every route is mounted under \`/${API_PATH_VERSION}\`, and that segment is part
71
+ of the address you call. A change that would break a caller — a field removed, a
72
+ type narrowed, a status changed — arrives as a new segment beside this one, never
73
+ as a change to this one. Additive changes do land here: a new field on a
74
+ response, a new optional parameter, a new error code. Ignore what you do not
75
+ recognize and you will not be broken by them.
76
+
77
+ Every response carries \`${API_VERSION_HEADER}\`, a date stamp for the contract
78
+ the server is serving. Record the value your integration was built against and
79
+ compare it if behavior surprises you.
80
+
81
+ ## Deprecation
82
+
83
+ A route being withdrawn says so on itself, for at least
84
+ ${API_DEPRECATION_NOTICE_DAYS} days before it stops answering:
85
+
86
+ - \`${API_DEPRECATION_HEADER}\` — an sf-Date of when the deprecation took effect
87
+ (RFC 9745). The route still answers normally while this is present.
88
+ - \`${API_SUNSET_HEADER}\` — an HTTP-date of when it stops answering (RFC 8594).
89
+ - \`Link\` with \`rel="deprecation"\` pointing at what to read, and
90
+ \`rel="successor-version"\` at what to call instead when there is one.
91
+
92
+ The same operations are marked \`deprecated: true\` in this document. Treating
93
+ either signal as a build failure is the intended use; nothing is removed without
94
+ both.`;
package/src/wire/auth.ts CHANGED
@@ -34,9 +34,12 @@ export const ephemeralKeyRequestSchema = z.object({
34
34
  /** The participant this session acts as. */
35
35
  user: ephemeralKeyUserSchema,
36
36
  /**
37
- * Mint into this organization instead of the caller's own. Requires the
38
- * `organization:act-as` scope without it a secret key can never mint a
39
- * session into another tenant.
37
+ * Open the session in a DIFFERENT organization, one the caller does not
38
+ * belong to. Requires the `organization:act-as` scope. This is the identity
39
+ * provider case, where each organization is an account in its own right.
40
+ *
41
+ * A platform serving its own customers does not use this: its customers are
42
+ * rows in its own schema, and a session reaches them through `syncGroups`.
40
43
  */
41
44
  organizationId: z.string().min(1).optional(),
42
45
  /**
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { z } from 'zod';
21
+ import { logPositionSchema } from '../syncLog/contract.js';
21
22
  import {
22
23
  onStaleModeSchema,
23
24
  MAX_READ_SET_ENTRIES,
@@ -25,7 +26,6 @@ import {
25
26
  readSetRowTargetSchema,
26
27
  readSetProjectionEntryCount,
27
28
  readSetSchema,
28
- readSetWatermarkSchema,
29
29
  participantKindSchema,
30
30
  staleNotificationSchema,
31
31
  trackDependencyListSchema,
@@ -61,7 +61,7 @@ export const confirmedCommitStatusSchema = z
61
61
  .strictObject({
62
62
  status: confirmedStatusSchema,
63
63
  statusAt: commitTimestampSchema,
64
- lastSyncId: readSetWatermarkSchema,
64
+ lastSyncId: logPositionSchema,
65
65
  correlationId: correlationIdSchema.optional(),
66
66
  })
67
67
  .refine(({ correlationId, lastSyncId }) => correlationId === undefined || lastSyncId > 0, {
@@ -375,7 +375,7 @@ export type MutationCommitResult = z.infer<typeof mutationCommitResultSchema>;
375
375
  export const clientCommitReceiptSchema = z.strictObject({
376
376
  id: z.string().min(1),
377
377
  status: z.union([queuedStatusSchema, confirmedStatusSchema]),
378
- lastSyncId: readSetWatermarkSchema.optional(),
378
+ lastSyncId: logPositionSchema.optional(),
379
379
  notifications: notificationsSchema.optional(),
380
380
  missingIds: missingIdsSchema.optional(),
381
381
  operationResults: operationResultsSchema.optional(),
@@ -400,15 +400,30 @@ export const commitOperationControlShape = {
400
400
  id: z.string().nullish(),
401
401
  transactionId: z.string().nullish(),
402
402
  claimId: z.string().min(1).nullish(),
403
- readAt: readSetWatermarkSchema.nullish(),
403
+ readAt: logPositionSchema.nullish(),
404
404
  onStale: onStaleModeSchema.nullish(),
405
405
  fenceToken: z.number().nullish(),
406
406
  };
407
407
 
408
+ /** Canonical public spelling of a model operation. */
409
+ export const modelOperationActionSchema = z.enum([
410
+ 'create',
411
+ 'update',
412
+ 'delete',
413
+ 'archive',
414
+ 'unarchive',
415
+ ]);
416
+ export type ModelOperationAction = z.infer<typeof modelOperationActionSchema>;
417
+
418
+ /** Convert a storage operation verb into the canonical lowercase commit action. */
419
+ export function normalizeStorageOperationAction(value: string): ModelOperationAction {
420
+ return modelOperationActionSchema.parse(value.toLowerCase());
421
+ }
422
+
408
423
  /** One write inside a commit, in the canonical spelling. */
409
424
  export const commitOperationBodySchema = z.object({
410
425
  ...commitOperationControlShape,
411
- action: z.string(),
426
+ action: modelOperationActionSchema,
412
427
  model: z.string(),
413
428
  data: z.record(z.string(), z.unknown()).nullish(),
414
429
  where: z.record(z.string().min(1), z.unknown()).nullish(),
package/src/wire/index.ts CHANGED
@@ -120,6 +120,8 @@ export {
120
120
  // The request side of the same boundary — one definition, which the published
121
121
  // OpenAPI reference derives from rather than describing separately.
122
122
  commitOperationControlShape,
123
+ modelOperationActionSchema,
124
+ normalizeStorageOperationAction,
123
125
  commitOperationBodySchema,
124
126
  commitActorSchema,
125
127
  commitAttemptSchema,
@@ -133,6 +135,7 @@ export {
133
135
  commitRecordListSchema,
134
136
  } from './commit.js';
135
137
  export type {
138
+ ModelOperationAction,
136
139
  CommitOperationBody,
137
140
  CommitOperationResult,
138
141
  CommitActor,
@@ -408,3 +411,31 @@ export type {
408
411
  CapabilityRequest,
409
412
  CapabilityMintResponse,
410
413
  } from './auth.js';
414
+
415
+ // How a rate limit is stated on a response, so a caller can pace itself rather
416
+ // than discover the ceiling by hitting it. The field names and their Structured
417
+ // Fields spellings live in one module because every producer — the engine's
418
+ // per-key limiter and the public docs surfaces both — has to spell them the
419
+ // same way for a client to read either.
420
+ export {
421
+ RATE_LIMIT_HEADER,
422
+ RATE_LIMIT_POLICY_HEADER,
423
+ RETRY_AFTER_HEADER,
424
+ rateLimitField,
425
+ rateLimitPolicyField,
426
+ rateLimitHeaders,
427
+ } from './rateLimit.js';
428
+ export type { QuotaPolicy, ServiceLimit, RateLimitSignal } from './rateLimit.js';
429
+
430
+ // What a caller can rely on about this surface not moving under it: the path
431
+ // segment the routes live under, the header carrying the contract date, and the
432
+ // two standard fields a withdrawal is announced on. The published OpenAPI
433
+ // description renders `API_LIFECYCLE` rather than restating it.
434
+ export {
435
+ API_PATH_VERSION,
436
+ API_VERSION_HEADER,
437
+ API_DEPRECATION_HEADER,
438
+ API_SUNSET_HEADER,
439
+ API_DEPRECATION_NOTICE_DAYS,
440
+ API_LIFECYCLE,
441
+ } from './apiLifecycle.js';
@@ -16,12 +16,12 @@
16
16
  */
17
17
 
18
18
  import { z } from 'zod';
19
+ import { logPositionSchema } from '../syncLog/contract.js';
19
20
  import {
20
21
  onStaleModeSchema,
21
22
  MAX_READ_SET_ENTRIES,
22
23
  readDependencyListSchema,
23
24
  readSetProjectionEntryCount,
24
- readSetWatermarkSchema,
25
25
  trackDependencyListSchema,
26
26
  } from '../coordination/schema.js';
27
27
 
@@ -50,7 +50,7 @@ export const modelMutationRequestSchema = z.object({
50
50
  * the read. Ablo rejects the write if the row moved in between, which is
51
51
  * what makes read → decide → write safe without a lock across the deciding.
52
52
  */
53
- readAt: readSetWatermarkSchema.nullish(),
53
+ readAt: logPositionSchema.nullish(),
54
54
  /** Commit-lifetime dependencies checked with the single model operation. */
55
55
  reads: readDependencyListSchema.nullish(),
56
56
  /** Durable dependencies registered with the single model operation. */