@abloatai/transaction 0.37.1 → 0.38.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 (148) hide show
  1. package/dist/ai-sdk/index.d.ts +3 -1
  2. package/dist/ai-sdk/index.d.ts.map +1 -1
  3. package/dist/ai-sdk/index.js +2 -1
  4. package/dist/ai-sdk/index.js.map +1 -1
  5. package/dist/ai-sdk/modelTools.d.ts +67 -0
  6. package/dist/ai-sdk/modelTools.d.ts.map +1 -0
  7. package/dist/ai-sdk/modelTools.js +89 -0
  8. package/dist/ai-sdk/modelTools.js.map +1 -0
  9. package/dist/ai-sdk/toolOptions.d.ts +8 -0
  10. package/dist/ai-sdk/toolOptions.d.ts.map +1 -0
  11. package/dist/ai-sdk/toolOptions.js +2 -0
  12. package/dist/ai-sdk/toolOptions.js.map +1 -0
  13. package/dist/ai-sdk/{coordinatedTool.d.ts → updateTool.d.ts} +21 -28
  14. package/dist/ai-sdk/updateTool.d.ts.map +1 -0
  15. package/dist/ai-sdk/{coordinatedTool.js → updateTool.js} +39 -50
  16. package/dist/ai-sdk/updateTool.js.map +1 -0
  17. package/dist/auth/capability.d.ts +6 -0
  18. package/dist/auth/capability.d.ts.map +1 -1
  19. package/dist/auth/capability.js +4 -0
  20. package/dist/auth/capability.js.map +1 -1
  21. package/dist/auth/capabilityLifecycle.d.ts +3 -0
  22. package/dist/auth/capabilityLifecycle.d.ts.map +1 -1
  23. package/dist/auth/identity.d.ts +6 -4
  24. package/dist/auth/identity.d.ts.map +1 -1
  25. package/dist/auth/identity.js +7 -11
  26. package/dist/auth/identity.js.map +1 -1
  27. package/dist/auth/schemas.d.ts +5 -5
  28. package/dist/auth/schemas.d.ts.map +1 -1
  29. package/dist/auth/schemas.js +5 -2
  30. package/dist/auth/schemas.js.map +1 -1
  31. package/dist/auth/sessionMint.d.ts.map +1 -1
  32. package/dist/auth/sessionMint.js +3 -0
  33. package/dist/auth/sessionMint.js.map +1 -1
  34. package/dist/branches.d.ts +251 -0
  35. package/dist/branches.d.ts.map +1 -0
  36. package/dist/branches.js +108 -0
  37. package/dist/branches.js.map +1 -0
  38. package/dist/coordination/awaitClaimGrant.d.ts +2 -0
  39. package/dist/coordination/awaitClaimGrant.d.ts.map +1 -1
  40. package/dist/coordination/awaitClaimGrant.js +15 -2
  41. package/dist/coordination/awaitClaimGrant.js.map +1 -1
  42. package/dist/coordination/schema.d.ts +2 -0
  43. package/dist/coordination/schema.d.ts.map +1 -1
  44. package/dist/coordination/schema.js +2 -0
  45. package/dist/coordination/schema.js.map +1 -1
  46. package/dist/docs/catalog.d.ts +5 -5
  47. package/dist/docs/catalog.d.ts.map +1 -1
  48. package/dist/docs/catalog.js +12 -12
  49. package/dist/docs/catalog.js.map +1 -1
  50. package/dist/footprint.d.ts +3 -5
  51. package/dist/footprint.d.ts.map +1 -1
  52. package/dist/footprint.js +0 -0
  53. package/dist/footprint.js.map +1 -1
  54. package/dist/index.d.ts +1 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/keys/index.d.ts +15 -7
  59. package/dist/keys/index.d.ts.map +1 -1
  60. package/dist/keys/index.js +45 -14
  61. package/dist/keys/index.js.map +1 -1
  62. package/dist/schema/openapi.d.ts +1 -1
  63. package/dist/schema/openapi.d.ts.map +1 -1
  64. package/dist/schema/openapi.js +308 -29
  65. package/dist/schema/openapi.js.map +1 -1
  66. package/dist/server/commit.d.ts +2 -15
  67. package/dist/server/commit.d.ts.map +1 -1
  68. package/dist/source/adapter.d.ts +7 -2
  69. package/dist/source/adapter.d.ts.map +1 -1
  70. package/dist/source/adapter.js +4 -2
  71. package/dist/source/adapter.js.map +1 -1
  72. package/dist/source/adapterFactory.d.ts +10 -0
  73. package/dist/source/adapterFactory.d.ts.map +1 -0
  74. package/dist/source/adapterFactory.js +33 -0
  75. package/dist/source/adapterFactory.js.map +1 -0
  76. package/dist/source/adapterProfile.d.ts +55 -0
  77. package/dist/source/adapterProfile.d.ts.map +1 -0
  78. package/dist/source/adapterProfile.js +54 -0
  79. package/dist/source/adapterProfile.js.map +1 -0
  80. package/dist/source/adapters/drizzle.d.ts.map +1 -1
  81. package/dist/source/adapters/drizzle.js +5 -2
  82. package/dist/source/adapters/drizzle.js.map +1 -1
  83. package/dist/source/adapters/kysely.d.ts.map +1 -1
  84. package/dist/source/adapters/kysely.js +7 -4
  85. package/dist/source/adapters/kysely.js.map +1 -1
  86. package/dist/source/adapters/memory.d.ts.map +1 -1
  87. package/dist/source/adapters/memory.js +5 -2
  88. package/dist/source/adapters/memory.js.map +1 -1
  89. package/dist/source/adapters/prisma.d.ts.map +1 -1
  90. package/dist/source/adapters/prisma.js +5 -2
  91. package/dist/source/adapters/prisma.js.map +1 -1
  92. package/dist/source/conformance.d.ts.map +1 -1
  93. package/dist/source/conformance.js +18 -0
  94. package/dist/source/conformance.js.map +1 -1
  95. package/dist/source/index.d.ts +2 -0
  96. package/dist/source/index.d.ts.map +1 -1
  97. package/dist/source/index.js +2 -0
  98. package/dist/source/index.js.map +1 -1
  99. package/dist/source/types.d.ts +3 -14
  100. package/dist/source/types.d.ts.map +1 -1
  101. package/dist/source/types.js.map +1 -1
  102. package/dist/syncLog/contract.d.ts +1 -1
  103. package/dist/syncLog/contract.d.ts.map +1 -1
  104. package/dist/syncLog/contract.js +1 -2
  105. package/dist/syncLog/contract.js.map +1 -1
  106. package/dist/wire/accountResponses.d.ts +5 -9
  107. package/dist/wire/accountResponses.d.ts.map +1 -1
  108. package/dist/wire/accountResponses.js +5 -6
  109. package/dist/wire/accountResponses.js.map +1 -1
  110. package/dist/wire/delta.d.ts +11 -0
  111. package/dist/wire/delta.d.ts.map +1 -1
  112. package/dist/wire/delta.js +11 -0
  113. package/dist/wire/delta.js.map +1 -1
  114. package/dist/wire/inboundFrames.d.ts +2 -0
  115. package/dist/wire/inboundFrames.d.ts.map +1 -1
  116. package/package.json +8 -2
  117. package/src/ai-sdk/index.ts +19 -6
  118. package/src/ai-sdk/modelTools.ts +165 -0
  119. package/src/ai-sdk/toolOptions.ts +18 -0
  120. package/src/ai-sdk/{coordinatedTool.ts → updateTool.ts} +59 -69
  121. package/src/auth/capability.ts +4 -0
  122. package/src/auth/identity.ts +16 -16
  123. package/src/auth/schemas.ts +5 -2
  124. package/src/auth/sessionMint.ts +3 -0
  125. package/src/branches.ts +134 -0
  126. package/src/coordination/awaitClaimGrant.ts +18 -2
  127. package/src/coordination/schema.ts +2 -0
  128. package/src/docs/catalog.ts +24 -12
  129. package/src/footprint.ts +0 -0
  130. package/src/index.ts +1 -0
  131. package/src/keys/index.ts +56 -17
  132. package/src/schema/openapi.ts +366 -38
  133. package/src/server/commit.ts +2 -15
  134. package/src/source/adapter.ts +7 -2
  135. package/src/source/adapterFactory.ts +60 -0
  136. package/src/source/adapterProfile.ts +75 -0
  137. package/src/source/adapters/drizzle.ts +5 -2
  138. package/src/source/adapters/kysely.ts +10 -4
  139. package/src/source/adapters/memory.ts +5 -2
  140. package/src/source/adapters/prisma.ts +5 -2
  141. package/src/source/conformance.ts +18 -0
  142. package/src/source/index.ts +14 -0
  143. package/src/source/types.ts +3 -14
  144. package/src/syncLog/contract.ts +1 -2
  145. package/src/wire/accountResponses.ts +5 -6
  146. package/src/wire/delta.ts +11 -0
  147. package/dist/ai-sdk/coordinatedTool.d.ts.map +0 -1
  148. package/dist/ai-sdk/coordinatedTool.js.map +0 -1
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Small AI SDK adapters for Ablo model resources.
3
+ *
4
+ * The AI SDK owns the agent loop. These helpers only expose Ablo's
5
+ * authoritative model operations as tools, preserving the same credential,
6
+ * validation, idempotency, claims, and settlement behavior as direct SDK use.
7
+ */
8
+
9
+ import { tool, type ToolExecutionOptions } from 'ai';
10
+ import type { z } from 'zod';
11
+ import type {
12
+ ClaimParams,
13
+ ModelCreateParams,
14
+ ModelDeleteParams,
15
+ ModelRetrieveParams,
16
+ } from '../resources/modelOperations.js';
17
+ import type { HeldClaim } from '../types/streams.js';
18
+ import type { ModelToolOptions } from './toolOptions.js';
19
+
20
+ export interface ToolModel<T, CreateInput = Partial<T>, Fields = T> {
21
+ get(params: ModelRetrieveParams): Promise<T | undefined>;
22
+ create(params: ModelCreateParams<T, CreateInput>): Promise<T>;
23
+ delete(params: ModelDeleteParams<T, Fields>): Promise<void>;
24
+ claim(
25
+ params: ClaimParams<Fields> & { queue: false },
26
+ ): Promise<HeldClaim<T> | null>;
27
+ claim(params: ClaimParams<Fields>): Promise<HeldClaim<T>>;
28
+ }
29
+
30
+ export interface ReadToolOptions<TInput, T>
31
+ extends ModelToolOptions<TInput, ReadToolResult<T>> {
32
+ readonly description: string;
33
+ readonly inputSchema: z.ZodType<TInput>;
34
+ readonly id: (input: TInput) => string;
35
+ }
36
+
37
+ export type ReadToolResult<T> =
38
+ | { readonly status: 'found'; readonly row: T }
39
+ | { readonly status: 'not_found'; readonly message: string };
40
+
41
+ export function readTool<TInput, T>(
42
+ model: Pick<ToolModel<T>, 'get'>,
43
+ options: ReadToolOptions<TInput, T>,
44
+ ) {
45
+ return tool<TInput, ReadToolResult<T>>({
46
+ description: options.description,
47
+ title: options.title,
48
+ inputSchema: options.inputSchema,
49
+ inputExamples: options.inputExamples,
50
+ needsApproval: options.needsApproval,
51
+ strict: options.strict,
52
+ outputSchema: options.outputSchema,
53
+ toModelOutput: options.toModelOutput,
54
+ execute: async (input) => {
55
+ const id = options.id(input);
56
+ const row = await model.get({ id });
57
+ return row === undefined
58
+ ? {
59
+ status: 'not_found',
60
+ message: `No accessible row exists with id ${id}.`,
61
+ }
62
+ : { status: 'found', row };
63
+ },
64
+ });
65
+ }
66
+
67
+ export interface CreateToolOptions<TInput, CreateInput, T>
68
+ extends ModelToolOptions<TInput, CreateToolResult<T>> {
69
+ readonly description: string;
70
+ readonly inputSchema: z.ZodType<TInput>;
71
+ readonly data: (input: TInput) => CreateInput;
72
+ /**
73
+ * Stable ids make retries idempotent. Omit only when the model or server
74
+ * generates the id and duplicate creation is acceptable to the application.
75
+ */
76
+ readonly id?: (input: TInput) => string | undefined;
77
+ }
78
+
79
+ export interface CreateToolResult<T> {
80
+ readonly status: 'created';
81
+ readonly row: T;
82
+ }
83
+
84
+ export function createTool<TInput, T, CreateInput>(
85
+ model: Pick<ToolModel<T, CreateInput>, 'create'>,
86
+ options: CreateToolOptions<TInput, CreateInput, T>,
87
+ ) {
88
+ return tool<TInput, CreateToolResult<T>>({
89
+ description: options.description,
90
+ title: options.title,
91
+ inputSchema: options.inputSchema,
92
+ inputExamples: options.inputExamples,
93
+ needsApproval: options.needsApproval,
94
+ strict: options.strict,
95
+ outputSchema: options.outputSchema,
96
+ toModelOutput: options.toModelOutput,
97
+ execute: async (input) => {
98
+ const id = options.id?.(input);
99
+ const row = await model.create({
100
+ data: options.data(input),
101
+ ...(id !== undefined ? { id } : {}),
102
+ wait: 'confirmed',
103
+ });
104
+ return { status: 'created', row };
105
+ },
106
+ });
107
+ }
108
+
109
+ export interface DeleteToolOptions<TInput>
110
+ extends ModelToolOptions<TInput, DeleteToolResult> {
111
+ readonly description: string;
112
+ readonly inputSchema: z.ZodType<TInput>;
113
+ readonly id: (input: TInput) => string;
114
+ /** Defaults to waiting in the server-owned FIFO claim queue. */
115
+ readonly strategy?: 'claim' | 'queue';
116
+ readonly claim?: { readonly description?: string };
117
+ }
118
+
119
+ export type DeleteToolResult =
120
+ | { readonly status: 'deleted'; readonly id: string }
121
+ | { readonly status: 'claimed'; readonly message: string };
122
+
123
+ export function deleteTool<TInput, T, Fields = T>(
124
+ model: Pick<ToolModel<T, Partial<T>, Fields>, 'claim' | 'delete'>,
125
+ options: DeleteToolOptions<TInput>,
126
+ ) {
127
+ return tool<TInput, DeleteToolResult>({
128
+ description: options.description,
129
+ title: options.title,
130
+ inputSchema: options.inputSchema,
131
+ inputExamples: options.inputExamples,
132
+ // Destructive tools require approval unless an application deliberately
133
+ // opts into autonomous deletion.
134
+ needsApproval: options.needsApproval ?? true,
135
+ strict: options.strict,
136
+ outputSchema: options.outputSchema,
137
+ toModelOutput: options.toModelOutput,
138
+ execute: async (
139
+ input: TInput,
140
+ execution: ToolExecutionOptions,
141
+ ): Promise<DeleteToolResult> => {
142
+ const id = options.id(input);
143
+ const queue = options.strategy !== 'claim';
144
+ const claim = await model.claim({
145
+ id,
146
+ queue,
147
+ description: options.claim?.description,
148
+ signal: execution.abortSignal,
149
+ });
150
+ if (!claim) {
151
+ return {
152
+ status: 'claimed',
153
+ message:
154
+ 'Another participant holds this row right now — it was not deleted.',
155
+ };
156
+ }
157
+ try {
158
+ await model.delete({ id, claim, wait: 'confirmed' });
159
+ return { status: 'deleted', id };
160
+ } finally {
161
+ await claim.release();
162
+ }
163
+ },
164
+ });
165
+ }
@@ -0,0 +1,18 @@
1
+ import type { Tool } from 'ai';
2
+
3
+ /**
4
+ * AI SDK-native presentation and execution policy shared by Ablo model tools.
5
+ * Ablo owns the data operation; AI SDK owns how the tool is presented,
6
+ * validated, approved, and returned to the model.
7
+ */
8
+ export type ModelToolOptions<TInput, TResult> = Partial<
9
+ Pick<
10
+ Tool<TInput, TResult>,
11
+ | 'title'
12
+ | 'inputExamples'
13
+ | 'needsApproval'
14
+ | 'strict'
15
+ | 'outputSchema'
16
+ | 'toModelOutput'
17
+ >
18
+ >;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Turns a write to one of your models into a Vercel AI SDK tool that handles
2
+ * Turns a write to one of your models into an AI SDK tool that handles
3
3
  * multi-agent coordination for you, so an agent can contribute to shared state
4
4
  * without silently overwriting another writer's concurrent change.
5
5
  *
@@ -11,10 +11,10 @@
11
11
  * write:
12
12
  *
13
13
  * ```ts
14
- * import { coordinatedTool } from '@abloatai/transaction/ai-sdk';
14
+ * import { updateTool } from '@abloatai/ablo/ai-sdk';
15
15
  * import { z } from 'zod';
16
16
  *
17
- * const saveSection = coordinatedTool(ablo.documents, {
17
+ * const saveSection = updateTool(ablo.documents, {
18
18
  * description: 'Save your section into the shared document.',
19
19
  * inputSchema: z.object({ text: z.string() }),
20
20
  * id: () => DOC_ID,
@@ -25,7 +25,7 @@
25
25
  * await streamText({ model, messages, tools: { saveSection } });
26
26
  * ```
27
27
  *
28
- * The {@link CoordinatedToolOptions.apply} function is the whole API: a pure
28
+ * The {@link UpdateToolOptions.apply} function is the whole API: a pure
29
29
  * function from the freshest row and the tool input to a patch, in the same
30
30
  * spirit as a functional state update. Everything beneath it — reading the latest
31
31
  * row, the compare-and-swap, backing off between retries, and releasing claims —
@@ -52,42 +52,39 @@
52
52
  * than a hidden wait when it might spend its turn on something else. Works under
53
53
  * any conflict policy.
54
54
  *
55
- * - `'queue'` serializes writers over stateless HTTP. The tool polls to acquire
56
- * the claim until it is granted or `poll.timeoutMs` elapses, so the model calls
57
- * once and the tool waits its turn. Ordering is approximate rather than strict
58
- * first-in-first-out, which would require a persistent connection.
55
+ * - `'queue'` joins Ablo's server-owned FIFO queue. The model calls once and
56
+ * the tool waits for the canonical claim grant; it does not recreate queueing
57
+ * with client-side polling.
59
58
  */
60
59
 
61
- import { tool } from 'ai';
60
+ import { tool, type ToolExecutionOptions } from 'ai';
62
61
  import type { z } from 'zod';
63
- import { AbloClaimedError, AbloNotFoundError } from '@abloatai/transaction/errors';
64
62
  import type {
65
63
  ClaimParams,
66
- ModelRetrieveParams,
67
64
  ModelUpdateParams,
68
65
  } from '../resources/modelOperations.js';
69
66
  import type { ModelUpdater, ContentionOptions } from '../resources/functionalUpdate.js';
70
67
  import type { HeldClaim } from '../types/streams.js';
68
+ import type { ModelToolOptions } from './toolOptions.js';
71
69
 
72
- export type CoordinationStrategy = 'merge' | 'claim' | 'queue';
70
+ export type UpdateStrategy = 'merge' | 'claim' | 'queue';
73
71
 
74
72
  /** The structured result the tool hands back to the model (or the caller). */
75
- export interface CoordinatedWriteResult<T> {
73
+ export interface UpdateToolResult<T> {
76
74
  /**
77
75
  * `'written'` means the change was saved. `'claimed'` means another participant
78
76
  * holds the row, so nothing was saved and the model should try again.
79
- * `'timeout'` means the `'queue'` strategy could not acquire the row within
80
- * `poll.timeoutMs`.
81
77
  */
82
- status: 'written' | 'claimed' | 'timeout';
78
+ status: 'written' | 'claimed';
83
79
  /** The reconciled row, on `'written'`. */
84
80
  row?: T;
85
81
  message?: string;
86
- /** On `'written'` via the `queue` strategy, how long the tool waited in line. */
82
+ /** On `'written'` via the `queue` strategy, how long the tool waited. */
87
83
  waitedMs?: number;
88
84
  }
89
85
 
90
- export interface CoordinatedToolOptions<TInput, T> {
86
+ export interface UpdateToolOptions<TInput, T>
87
+ extends ModelToolOptions<TInput, UpdateToolResult<T>> {
91
88
  /** Tool description shown to the model. */
92
89
  description: string;
93
90
  /** The schema of what the model may send, as a standard AI SDK / Zod input schema. */
@@ -103,45 +100,50 @@ export interface CoordinatedToolOptions<TInput, T> {
103
100
  */
104
101
  apply: (current: T, input: TInput) => Partial<T>;
105
102
  /** How concurrent writers relate. Defaults to `'merge'`. */
106
- strategy?: CoordinationStrategy;
103
+ strategy?: UpdateStrategy;
107
104
  /** Human-readable coordination metadata attached to the claim, used by the `'claim'` and `'queue'` strategies. */
108
105
  claim?: { description?: string };
109
106
  /** How many reconcile rounds `'merge'` may take before it gives up with `AbloContentionError`. */
110
107
  retries?: number;
111
- /** Poll interval and overall timeout for `'queue'`. Defaults to 250ms and 30s. */
112
- poll?: { intervalMs?: number; timeoutMs?: number };
113
108
  }
114
109
 
115
- const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
116
-
117
110
  /**
118
111
  * The small model port this helper needs. Both typed transports implement this
119
112
  * exact contract; local-cache methods, wire receipts, and transport lifecycle
120
113
  * deliberately stay out of an AI tool's dependency surface.
121
114
  */
122
- export interface CoordinatedModel<T> {
123
- get(params: ModelRetrieveParams): Promise<T | undefined>;
124
- /** @deprecated Use `get({ id })`. */
125
- retrieve?(params: ModelRetrieveParams): Promise<T | undefined>;
115
+ export interface UpdateToolModel<T> {
126
116
  update(params: ModelUpdateParams<T>): Promise<T>;
127
117
  update(
128
118
  id: string,
129
119
  updater: ModelUpdater<T>,
130
120
  options?: ContentionOptions,
131
121
  ): Promise<T | undefined>;
122
+ claim(
123
+ params: ClaimParams<T> & { queue: false },
124
+ ): Promise<HeldClaim<T> | null>;
132
125
  claim(params: ClaimParams<T>): Promise<HeldClaim<T>>;
133
126
  }
134
127
 
135
- export function coordinatedTool<
128
+ export function updateTool<
136
129
  TInput,
137
130
  T = Record<string, unknown>,
138
- >(model: CoordinatedModel<T>, options: CoordinatedToolOptions<TInput, T>) {
131
+ >(model: UpdateToolModel<T>, options: UpdateToolOptions<TInput, T>) {
139
132
  const strategy = options.strategy ?? 'merge';
140
133
 
141
- return tool<TInput, CoordinatedWriteResult<T>>({
134
+ return tool<TInput, UpdateToolResult<T>>({
142
135
  description: options.description,
136
+ title: options.title,
143
137
  inputSchema: options.inputSchema,
144
- execute: async (input: TInput): Promise<CoordinatedWriteResult<T>> => {
138
+ inputExamples: options.inputExamples,
139
+ needsApproval: options.needsApproval,
140
+ strict: options.strict,
141
+ outputSchema: options.outputSchema,
142
+ toModelOutput: options.toModelOutput,
143
+ execute: async (
144
+ input: TInput,
145
+ execution: ToolExecutionOptions,
146
+ ): Promise<UpdateToolResult<T>> => {
145
147
  const id = options.id(input);
146
148
 
147
149
  if (strategy === 'merge') {
@@ -150,29 +152,39 @@ export function coordinatedTool<
150
152
  // backoff. The model never sees a conflict.
151
153
  const row = await model.update(id, (current) => options.apply(current, input), {
152
154
  retries: options.retries,
155
+ signal: execution.abortSignal,
153
156
  });
154
157
  return { status: 'written', row: row ?? undefined };
155
158
  }
156
159
 
157
160
  // The 'claim' and 'queue' strategies both acquire a claim, write under it,
158
161
  // and release it. They differ only in what they do when the row is already
159
- // held: 'claim' returns a signal to the model, while 'queue' waits and
160
- // retries by polling to acquire.
161
- const acquireWriteRelease = async (): Promise<CoordinatedWriteResult<T>> => {
162
+ // held: 'claim' returns a signal to the model, while 'queue' waits for
163
+ // the server-owned FIFO grant.
164
+ const acquireWriteRelease = async (
165
+ queue: boolean,
166
+ ): Promise<UpdateToolResult<T>> => {
162
167
  const claim = await model.claim({
163
168
  id,
164
- queue: false,
169
+ queue,
165
170
  description: options.claim?.description,
171
+ signal: execution.abortSignal,
166
172
  });
173
+ if (!claim) {
174
+ return {
175
+ status: 'claimed',
176
+ message:
177
+ 'Another participant holds this row right now — it was not saved.',
178
+ };
179
+ }
167
180
  try {
168
- const current = await model.get({ id });
169
- if (current === undefined) {
170
- throw new AbloNotFoundError(
171
- `Cannot write ${id}: it does not exist (or is outside this credential's scope).`,
172
- [id],
173
- );
174
- }
175
- const row = await model.update({ id, data: options.apply(current, input), claim, wait: 'confirmed' });
181
+ const current = claim.data;
182
+ const row = await model.update({
183
+ id,
184
+ data: options.apply(current, input),
185
+ claim,
186
+ wait: 'confirmed',
187
+ });
176
188
  return { status: 'written', row };
177
189
  } finally {
178
190
  await claim.release();
@@ -180,35 +192,13 @@ export function coordinatedTool<
180
192
  };
181
193
 
182
194
  if (strategy === 'claim') {
183
- try {
184
- return await acquireWriteRelease();
185
- } catch (e) {
186
- if (e instanceof AbloClaimedError) {
187
- return { status: 'claimed', message: 'Another participant holds this row right now — it was NOT saved. Wait briefly and try again.' };
188
- }
189
- throw e;
190
- }
195
+ return acquireWriteRelease(false);
191
196
  }
192
197
 
193
- // strategy === 'queue': poll to acquire the claim over stateless HTTP.
194
- const interval = options.poll?.intervalMs ?? 250;
195
- const timeout = options.poll?.timeoutMs ?? 30_000;
198
+ // The transaction client owns the server FIFO and resolves once granted.
196
199
  const start = Date.now();
197
- for (;;) {
198
- try {
199
- const result = await acquireWriteRelease();
200
- return { ...result, waitedMs: Date.now() - start };
201
- } catch (e) {
202
- if (e instanceof AbloClaimedError) {
203
- if (Date.now() - start >= timeout) {
204
- return { status: 'timeout', message: `Could not acquire the row within ${timeout}ms.` };
205
- }
206
- await sleep(interval);
207
- continue;
208
- }
209
- throw e;
210
- }
211
- }
200
+ const result = await acquireWriteRelease(true);
201
+ return { ...result, waitedMs: Date.now() - start };
212
202
  },
213
203
  });
214
204
  }
@@ -239,6 +239,10 @@ export function grantedOperations(
239
239
  */
240
240
  export const capabilityScopeSchema = z.object({
241
241
  organizationId: z.string().min(1),
242
+ /** Credential target. Branch id is authoritative; null supports self-hosted identities. */
243
+ projectId: z.string().min(1).nullable().default(null),
244
+ branchId: z.string().min(1).nullable().default(null),
245
+ branchRoot: z.boolean().default(false),
242
246
  /**
243
247
  * The ROW axis — which sync groups this credential may act within. Read back
244
248
  * as plain strings rather than the branded form the request enforces: this is
@@ -42,7 +42,11 @@ export interface IdentityResolveInput {
42
42
  readonly agentId?: string;
43
43
  readonly syncGroups?: string[];
44
44
  };
45
- readonly internalOptions: { readonly organizationId?: string };
45
+ readonly internalOptions: {
46
+ readonly organizationId?: string;
47
+ readonly branchId?: string;
48
+ readonly branchRoot?: boolean;
49
+ };
46
50
  readonly url: string;
47
51
  readonly kind: ParticipantKind;
48
52
  readonly configuredApiKey: string | CredentialProvider | null;
@@ -57,10 +61,10 @@ export interface ResolvedIdentity {
57
61
  readonly accountScope: string;
58
62
  /** Concrete project selected by the credential. The org id is the default project. */
59
63
  readonly projectId: string | null;
60
- /** Data environment selected by the credential. Null for explicit/self-hosted identities. */
61
- readonly environment: 'sandbox' | 'production' | null;
62
- /** Concrete sandbox selected by the credential, when applicable. */
63
- readonly sandboxId: string | null;
64
+ /** Immutable transaction branch selected by the credential. */
65
+ readonly branchId: string | null;
66
+ /** True only when branchId is the project's production root. */
67
+ readonly branchRoot: boolean;
64
68
  readonly teamIds: string[] | undefined;
65
69
  readonly capabilityToken: string | undefined;
66
70
  readonly syncGroups: readonly string[] | undefined;
@@ -182,8 +186,8 @@ export async function resolveParticipantIdentity(
182
186
  userId,
183
187
  accountScope,
184
188
  projectId: accountScope,
185
- environment: null,
186
- sandboxId: null,
189
+ branchId: internalOptions.branchId ?? null,
190
+ branchRoot: internalOptions.branchRoot ?? false,
187
191
  teamIds: kind === 'user' ? options.user?.teamIds : undefined,
188
192
  capabilityToken: cred.getBearer,
189
193
  syncGroups: options.syncGroups,
@@ -232,8 +236,8 @@ async function resolveViaIdentity(
232
236
  userId: identity.participantId,
233
237
  accountScope: identity.accountScope,
234
238
  projectId: identity.projectId ?? identity.accountScope,
235
- environment: identity.environment ?? null,
236
- sandboxId: identity.sandboxId ?? null,
239
+ branchId: identity.branchId ?? null,
240
+ branchRoot: identity.branchRoot,
237
241
  teamIds: undefined,
238
242
  capabilityToken: bearer,
239
243
  syncGroups: mergedSyncGroups,
@@ -312,13 +316,9 @@ async function resolveHosted(input: HostedInput): Promise<ResolvedIdentity> {
312
316
  return {
313
317
  userId: exchange.scope.participantId,
314
318
  accountScope: exchange.scope.organizationId,
315
- // The legacy capability exchange response predates the explicit plane
316
- // fields returned by `/auth/identity`. A hosted secret-key client defaults
317
- // to the org-default project; browser sessions use resolveViaIdentity and
318
- // therefore receive the concrete project/environment/sandbox tuple.
319
- projectId: exchange.scope.organizationId,
320
- environment: null,
321
- sandboxId: null,
319
+ projectId: exchange.scope.projectId ?? exchange.scope.organizationId,
320
+ branchId: exchange.scope.branchId,
321
+ branchRoot: exchange.scope.branchRoot,
322
322
  // teamIds isn't needed because the server already encoded
323
323
  // team-level access into scope.syncGroups.
324
324
  teamIds: undefined,
@@ -21,8 +21,8 @@ export const IdentityResolveResponseSchema = z.object({
21
21
  // The rest of the resolved plane. The server always emits these keys; null
22
22
  // means the credential does not bind that axis.
23
23
  projectId: z.string().min(1).nullable(),
24
- environment: z.enum(['sandbox', 'production']).nullable(),
25
- sandboxId: z.string().min(1).nullable(),
24
+ branchId: z.string().min(1).nullable().default(null),
25
+ branchRoot: z.boolean().default(false),
26
26
  syncGroups: z.array(z.string()),
27
27
  userMeta: z.record(z.string(), z.unknown()),
28
28
  });
@@ -42,6 +42,9 @@ export const EphemeralKeyResponseSchema = z.object({
42
42
  expiresAt: z.string().min(1),
43
43
  organizationId: z.string().min(1),
44
44
  participantId: z.string().min(1),
45
+ projectId: z.string().min(1).nullable().default(null),
46
+ branchId: z.string().min(1).nullable().default(null),
47
+ branchRoot: z.boolean().default(false),
45
48
  syncGroups: z.array(z.string()),
46
49
  /** Effective operation grant stored on the credential. */
47
50
  operations: z.array(grantedOperationSchema).min(1),
@@ -91,6 +91,9 @@ export async function mintSession<S extends SchemaRecord>(
91
91
  // response into the session resource's scope block.
92
92
  scope: {
93
93
  organizationId: res.organizationId,
94
+ projectId: res.projectId,
95
+ branchId: res.branchId,
96
+ branchRoot: res.branchRoot,
94
97
  syncGroups: res.syncGroups,
95
98
  operations: res.operations,
96
99
  participantKind: 'user',
@@ -0,0 +1,134 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * A branch handle is for people and URLs. Durable routing always uses the
5
+ * immutable branch id returned by the server.
6
+ */
7
+ export const branchSlugSchema = z
8
+ .string()
9
+ .min(1)
10
+ // During the additive rollout this is also the legacy plane adapter, whose
11
+ // persisted environment contract is capped at 40 characters.
12
+ .max(40)
13
+ .regex(
14
+ /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/,
15
+ 'must be a lowercase slug containing only letters, numbers, and hyphens',
16
+ );
17
+
18
+ export const branchKindSchema = z.enum(['dev', 'preview', 'test', 'long_lived']);
19
+ export type BranchKind = z.infer<typeof branchKindSchema>;
20
+
21
+ export const branchStateSchema = z.enum([
22
+ 'provisioning',
23
+ 'ready',
24
+ 'failed',
25
+ 'deleting',
26
+ 'deleted',
27
+ ]);
28
+ export type BranchState = z.infer<typeof branchStateSchema>;
29
+
30
+ export const branchOriginSchema = z.enum(['empty', 'source_snapshot', 'coordinated_fork']);
31
+ export type BranchOrigin = z.infer<typeof branchOriginSchema>;
32
+
33
+ export const branchResponseSchema = z.object({
34
+ object: z.literal('branch'),
35
+ id: z.string(),
36
+ project_id: z.string(),
37
+ parent_branch_id: z.string().nullable(),
38
+ slug: branchSlugSchema,
39
+ name: z.string().nullable(),
40
+ kind: branchKindSchema,
41
+ state: branchStateSchema,
42
+ origin: branchOriginSchema,
43
+ root: z.boolean(),
44
+ expires_at: z.string().nullable(),
45
+ created_at: z.string(),
46
+ deleted_at: z.string().nullable(),
47
+ });
48
+ export type BranchResponse = z.infer<typeof branchResponseSchema>;
49
+
50
+ export const branchListResponseSchema = z.object({
51
+ object: z.literal('list'),
52
+ data: z.array(branchResponseSchema).readonly(),
53
+ });
54
+ export type BranchListResponse = z.infer<typeof branchListResponseSchema>;
55
+
56
+ export const createBranchRequestSchema = z.object({
57
+ slug: branchSlugSchema,
58
+ name: z.string().min(1).max(200).optional(),
59
+ parent_branch_id: z.string().optional(),
60
+ kind: branchKindSchema.optional(),
61
+ origin: branchOriginSchema.optional(),
62
+ expires_at: z.iso.datetime().optional(),
63
+ });
64
+ export type CreateBranchRequest = z.infer<typeof createBranchRequestSchema>;
65
+
66
+ export const branchCredentialRequestSchema = z
67
+ .object({
68
+ ttl_hours: z.number().int().min(1).max(168).optional(),
69
+ })
70
+ .strict();
71
+ export type BranchCredentialRequest = z.infer<typeof branchCredentialRequestSchema>;
72
+
73
+ export const branchCredentialResponseSchema = z.object({
74
+ object: z.literal('branch_credential'),
75
+ branch_id: z.string(),
76
+ api_key: z.string(),
77
+ expires_at: z.string(),
78
+ });
79
+ export type BranchCredentialResponse = z.infer<typeof branchCredentialResponseSchema>;
80
+
81
+ export const branchParentCompatibilitySchema = z.enum([
82
+ 'same',
83
+ 'compatible',
84
+ 'review',
85
+ 'blocked',
86
+ 'unknown',
87
+ ]);
88
+ export type BranchParentCompatibility = z.infer<typeof branchParentCompatibilitySchema>;
89
+
90
+ export const branchStatusSchemaSummarySchema = z
91
+ .object({
92
+ active: z.boolean(),
93
+ version: z.number().int().nullable(),
94
+ hash: z.string().nullable(),
95
+ parent_compatibility: branchParentCompatibilitySchema,
96
+ changes: z.number().int().nonnegative(),
97
+ warnings: z.number().int().nonnegative(),
98
+ blockers: z.number().int().nonnegative(),
99
+ })
100
+ .strict();
101
+
102
+ export const branchStatusDataSourceSchema = z
103
+ .object({
104
+ kind: z.enum(['hosted', 'direct', 'endpoint']),
105
+ status: z.enum(['unverified', 'active', 'rejected']).nullable(),
106
+ host: z.string().nullable(),
107
+ database: z.string().nullable(),
108
+ cursor: z.string().nullable(),
109
+ event_lag: z.number().int().nonnegative(),
110
+ retry_count: z.number().int().nonnegative(),
111
+ last_success_at: z.string().nullable(),
112
+ last_error: z.string().nullable(),
113
+ })
114
+ .strict();
115
+
116
+ export const branchStatusBlockerSchema = z
117
+ .object({
118
+ code: z.enum(['branch_not_ready', 'schema_missing', 'data_source_not_ready']),
119
+ problem: z.string(),
120
+ fix: z.string(),
121
+ })
122
+ .strict();
123
+
124
+ export const branchStatusResponseSchema = z
125
+ .object({
126
+ object: z.literal('branch_status'),
127
+ branch: branchResponseSchema,
128
+ ready: z.boolean(),
129
+ schema: branchStatusSchemaSummarySchema,
130
+ data_source: branchStatusDataSourceSchema,
131
+ blockers: z.array(branchStatusBlockerSchema).readonly(),
132
+ })
133
+ .strict();
134
+ export type BranchStatusResponse = z.infer<typeof branchStatusResponseSchema>;