@abloatai/transaction 0.39.0 → 0.41.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 (110) hide show
  1. package/dist/auth/index.d.ts +12 -3
  2. package/dist/auth/index.d.ts.map +1 -1
  3. package/dist/auth/index.js +14 -6
  4. package/dist/auth/index.js.map +1 -1
  5. package/dist/auth/schemas.d.ts +1 -0
  6. package/dist/auth/schemas.d.ts.map +1 -1
  7. package/dist/auth/schemas.js +10 -2
  8. package/dist/auth/schemas.js.map +1 -1
  9. package/dist/coordination/events.d.ts +9 -0
  10. package/dist/coordination/events.d.ts.map +1 -1
  11. package/dist/coordination/index.d.ts +2 -2
  12. package/dist/coordination/index.d.ts.map +1 -1
  13. package/dist/coordination/index.js +1 -1
  14. package/dist/coordination/index.js.map +1 -1
  15. package/dist/coordination/schema.d.ts +226 -34
  16. package/dist/coordination/schema.d.ts.map +1 -1
  17. package/dist/coordination/schema.js +180 -48
  18. package/dist/coordination/schema.js.map +1 -1
  19. package/dist/coordination/trace.d.ts.map +1 -1
  20. package/dist/coordination/trace.js +9 -1
  21. package/dist/coordination/trace.js.map +1 -1
  22. package/dist/errorCodes.d.ts +5 -0
  23. package/dist/errorCodes.d.ts.map +1 -1
  24. package/dist/errorCodes.js +10 -1
  25. package/dist/errorCodes.js.map +1 -1
  26. package/dist/errors.d.ts.map +1 -1
  27. package/dist/errors.js +9 -2
  28. package/dist/errors.js.map +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/policy/types.d.ts +10 -2
  34. package/dist/policy/types.d.ts.map +1 -1
  35. package/dist/policy/types.js.map +1 -1
  36. package/dist/pricing.d.ts +194 -0
  37. package/dist/pricing.d.ts.map +1 -0
  38. package/dist/pricing.js +330 -0
  39. package/dist/pricing.js.map +1 -0
  40. package/dist/resources/functionalUpdate.d.ts.map +1 -1
  41. package/dist/resources/functionalUpdate.js +8 -8
  42. package/dist/resources/functionalUpdate.js.map +1 -1
  43. package/dist/schema/index.d.ts +1 -1
  44. package/dist/schema/index.d.ts.map +1 -1
  45. package/dist/schema/index.js +1 -1
  46. package/dist/schema/index.js.map +1 -1
  47. package/dist/schema/roles.d.ts +19 -0
  48. package/dist/schema/roles.d.ts.map +1 -1
  49. package/dist/schema/roles.js +23 -3
  50. package/dist/schema/roles.js.map +1 -1
  51. package/dist/schema/schema.d.ts +1 -1
  52. package/dist/schema/schema.d.ts.map +1 -1
  53. package/dist/schema/schema.js +1 -1
  54. package/dist/schema/schema.js.map +1 -1
  55. package/dist/schema/tenancy.d.ts +1 -1
  56. package/dist/transactions/settlement/commitEnvelope.d.ts +10 -2
  57. package/dist/transactions/settlement/commitEnvelope.d.ts.map +1 -1
  58. package/dist/transactions/settlement/pendingWrite.d.ts +10 -2
  59. package/dist/transactions/settlement/pendingWrite.d.ts.map +1 -1
  60. package/dist/transport/wsFrameHandlers.d.ts.map +1 -1
  61. package/dist/transport/wsFrameHandlers.js +9 -3
  62. package/dist/transport/wsFrameHandlers.js.map +1 -1
  63. package/dist/wire/accountResponses.d.ts +100 -0
  64. package/dist/wire/accountResponses.d.ts.map +1 -1
  65. package/dist/wire/accountResponses.js +70 -0
  66. package/dist/wire/accountResponses.js.map +1 -1
  67. package/dist/wire/auth.d.ts +1 -0
  68. package/dist/wire/auth.d.ts.map +1 -1
  69. package/dist/wire/auth.js +16 -4
  70. package/dist/wire/auth.js.map +1 -1
  71. package/dist/wire/commit.d.ts +708 -100
  72. package/dist/wire/commit.d.ts.map +1 -1
  73. package/dist/wire/dataSourceResponses.d.ts +171 -0
  74. package/dist/wire/dataSourceResponses.d.ts.map +1 -0
  75. package/dist/wire/dataSourceResponses.js +168 -0
  76. package/dist/wire/dataSourceResponses.js.map +1 -0
  77. package/dist/wire/frames.d.ts +20 -4
  78. package/dist/wire/frames.d.ts.map +1 -1
  79. package/dist/wire/inboundFrames.d.ts +102 -14
  80. package/dist/wire/inboundFrames.d.ts.map +1 -1
  81. package/dist/wire/index.d.ts +4 -2
  82. package/dist/wire/index.d.ts.map +1 -1
  83. package/dist/wire/index.js +5 -1
  84. package/dist/wire/index.js.map +1 -1
  85. package/dist/wire/pricing.d.ts +43 -0
  86. package/dist/wire/pricing.d.ts.map +1 -0
  87. package/dist/wire/pricing.js +36 -0
  88. package/dist/wire/pricing.js.map +1 -0
  89. package/package.json +6 -1
  90. package/src/auth/index.ts +26 -9
  91. package/src/auth/schemas.ts +14 -3
  92. package/src/coordination/events.ts +9 -0
  93. package/src/coordination/index.ts +8 -0
  94. package/src/coordination/schema.ts +200 -51
  95. package/src/coordination/trace.ts +9 -1
  96. package/src/errorCodes.ts +30 -1
  97. package/src/errors.ts +9 -2
  98. package/src/index.ts +4 -0
  99. package/src/policy/types.ts +10 -2
  100. package/src/pricing.ts +350 -0
  101. package/src/resources/functionalUpdate.ts +11 -11
  102. package/src/schema/index.ts +2 -0
  103. package/src/schema/roles.ts +26 -3
  104. package/src/schema/schema.ts +2 -0
  105. package/src/transport/wsFrameHandlers.ts +9 -3
  106. package/src/wire/accountResponses.ts +76 -0
  107. package/src/wire/auth.ts +19 -4
  108. package/src/wire/dataSourceResponses.ts +188 -0
  109. package/src/wire/index.ts +33 -0
  110. package/src/wire/pricing.ts +41 -0
package/src/errorCodes.ts CHANGED
@@ -139,11 +139,16 @@ const wire = (
139
139
  recovery?: RecoveryClass
140
140
  ): ErrorCodeSpec => ({ category, surface: 'wire', httpStatus, retryable, message, recovery });
141
141
 
142
- const client = (category: ErrorCategory, message: string): ErrorCodeSpec => ({
142
+ const client = (
143
+ category: ErrorCategory,
144
+ message: string,
145
+ recovery?: RecoveryClass
146
+ ): ErrorCodeSpec => ({
143
147
  category,
144
148
  surface: 'client',
145
149
  retryable: false,
146
150
  message,
151
+ ...(recovery !== undefined ? { recovery } : {}),
147
152
  });
148
153
 
149
154
  /**
@@ -575,6 +580,22 @@ export const ERROR_CODES = {
575
580
  'validation',
576
581
  'The CLI was invoked with an unknown flag or a malformed flag value.'
577
582
  ),
583
+ // recovery `none`: no credential EXISTS yet, so the auth-category default
584
+ // ('auth_blocked' — "the credential was rejected") would tell the reader to
585
+ // inspect a key they don't have. The message carries the actual next step.
586
+ cli_api_key_missing: client(
587
+ 'auth',
588
+ 'The command needs an API key and none was found on this machine. Run `ablo login` (or set ABLO_API_KEY), then re-run the command.',
589
+ 'none'
590
+ ),
591
+ cli_database_url_missing: client(
592
+ 'validation',
593
+ 'The command needs a database connection string and none was found — no DATABASE_URL in the process environment, .env.local, or .env, and no --url flag.'
594
+ ),
595
+ cli_database_unreachable: client(
596
+ 'transport',
597
+ 'The database named by the connection string could not be reached from this machine. The host, port, network, or credential refused the dial before any statement ran.'
598
+ ),
578
599
  commit_operation_required: wire(
579
600
  'validation',
580
601
  400,
@@ -1029,6 +1050,14 @@ export const ERROR_CODES = {
1029
1050
  'transport',
1030
1051
  'This environment provides no `fetch` implementation, so HTTP requests cannot be made. Run on a platform with `fetch` (Node 18+, modern browsers) or supply a polyfill.'
1031
1052
  ),
1053
+ api_unreachable: client(
1054
+ 'transport',
1055
+ 'The Ablo API could not be reached from this machine — the dial failed before any request arrived. Check the network, any proxy, and an ABLO_API_URL override, then retry.'
1056
+ ),
1057
+ response_unrecognized: client(
1058
+ 'transport',
1059
+ 'The server answered successfully, but with a body this client does not recognize. The client may be older than the server — update it and retry.'
1060
+ ),
1032
1061
  base_url_missing: client(
1033
1062
  'transport',
1034
1063
  'The client has no base URL configured, so it cannot address the server. Set the base URL when constructing the client.'
package/src/errors.ts CHANGED
@@ -810,10 +810,13 @@ const ENVELOPE_KEYS: ReadonlySet<string> = new Set([
810
810
  'request_id',
811
811
  // Read into typed properties below, so they are not detail as well.
812
812
  'error',
813
- 'reason',
814
813
  'requiredCapability',
815
814
  'claims',
816
815
  'heldByClaim',
816
+ // `reason` is deliberately NOT here: it is domain detail (a driver's words
817
+ // beside the server's message), not an envelope field — the canonical
818
+ // envelope schema has no such key — so it must survive into `details` for a
819
+ // consumer that renders it as a secondary line.
817
820
  ]);
818
821
 
819
822
  /** The non-envelope remainder of an error body — what `details` was before it
@@ -839,10 +842,14 @@ export function translateHttpError(
839
842
  : undefined;
840
843
  const flatError = typeof parsed.error === 'string' ? parsed.error : undefined;
841
844
  const code = parsed.code ?? nested?.code ?? flatError;
845
+ // The producer's `message` outranks a `reason`: the message is the sentence a
846
+ // server wrote for a person (what happened and the remedy), while `reason` is
847
+ // raw diagnostic text — a database driver's words — attached beside it. A body
848
+ // that carries only `reason` still reads it, as older endpoints did.
842
849
  const message =
843
850
  nested?.message ??
844
- parsed.reason ??
845
851
  parsed.message ??
852
+ parsed.reason ??
846
853
  flatError ??
847
854
  (typeof body === 'string' ? body : `HTTP ${status}`);
848
855
  const requiredCapability =
package/src/index.ts CHANGED
@@ -47,6 +47,10 @@ export type { ModelData } from './types/modelData.js';
47
47
  // The error hierarchy and its wire/recovery helpers.
48
48
  export * from './errors.js';
49
49
 
50
+ // The price of the product: tiers, rate card, and the bill arithmetic the
51
+ // published pricing page and the runtime caps both derive from.
52
+ export * from './pricing.js';
53
+
50
54
  // Pre-seam leaf surfaces, kept for compatibility with the first extraction.
51
55
  export * from './errorCodes.js';
52
56
  export * from './environment.js';
@@ -107,8 +107,16 @@ export type ConflictDecision =
107
107
  *
108
108
  * The monotonic `sync_id` landing order decides who yields: the stale
109
109
  * committer always recomputes against the newer value, an asymmetry that
110
- * prevents two notifying writers from looping against each other. Retries are
111
- * bounded by the client's reconciliation retry cap.
110
+ * prevents two notifying writers from looping against each other.
111
+ *
112
+ * That rules out livelock, and NOT starvation. Each round adopts a newer
113
+ * `observedSyncId`, so no baseline repeats — but a peer writing faster than
114
+ * the committer's read→decide→write gap keeps winning, and the rounds are
115
+ * unbounded because the engine does not re-issue: the actor does. Progress in
116
+ * the watermark is not progress in the work. The functional `update(id, fn)`
117
+ * bounds its own loop; a hand-rolled one must bound itself. To stop an actor
118
+ * writing at all while a belief it holds is stale, gate the belief —
119
+ * `track(..., { onStale: 'reject' })` — rather than the write.
112
120
  */
113
121
  | { readonly action: 'notify'; readonly reason?: string };
114
122
 
package/src/pricing.ts ADDED
@@ -0,0 +1,350 @@
1
+ /**
2
+ * What Ablo costs, expressed as arithmetic rather than as a table someone
3
+ * retypes. This module owns the price: the tiers, the rate card the meter is
4
+ * charged against, and the functions that turn a month of usage into a bill.
5
+ * The published pricing page, the plan seeds, and the runtime caps all derive
6
+ * from here; nothing restates it.
7
+ *
8
+ * The shape has three parts, and each one exists because a different cost does.
9
+ *
10
+ * - A monthly floor per tier. Serving an organization costs money before it
11
+ * sends a single request: a lane of the commit engine, a replication reader
12
+ * per registered source, and the log it appends to. The floor pays for that
13
+ * standing capacity, and usage is charged against it rather than added to
14
+ * it. An organization pays the greater of the two, never both.
15
+ * - A metered rate on `ops`, the one number that collapses commits, reads,
16
+ * and claim creates. Marginal service is cheap enough that a second axis
17
+ * would buy precision nobody wants: at the measured 79.5 microseconds of
18
+ * CPU per delta, a vCPU-hour serves roughly 45 million of them. The cost
19
+ * that scales is the log those operations leave behind, and it scales with
20
+ * all three of them together.
21
+ * - A concurrent-connection cap, which is a limit and not a meter. A held
22
+ * socket is a real reservation whether or not it is busy, so it is bounded
23
+ * rather than billed.
24
+ *
25
+ * The allowance a tier advertises is DERIVED, never stored: "the floor covers
26
+ * 49.5M operations" is {@link opsForUsd} applied to the floor. Storing it
27
+ * alongside the floor is how a published price silently stops matching the
28
+ * invoice, because raising one number does not fail anything that pins the
29
+ * other.
30
+ *
31
+ * Storage is deliberately absent from {@link billableAxisSchema}. It is a real
32
+ * recurring cost and it is quoted per tier as a ceiling, but no per-organization
33
+ * bytes sampler emits it yet, and pricing a dimension nothing measures would be
34
+ * a claim the invoice cannot support. Add the axis when the sampler ships.
35
+ */
36
+
37
+ import { z } from 'zod';
38
+ import { meterEventSchema, planTierSchema, rateBracketSchema } from './wire/pricing.js';
39
+ import type { MeterEvent, PlanTier, RateBracket } from './wire/pricing.js';
40
+
41
+ export { meterEventSchema, planTierSchema, rateBracketSchema };
42
+ export type { MeterEvent, PlanTier, RateBracket };
43
+
44
+ /**
45
+ * The version of the pricing contract: the tiers, the rate card, and the way a
46
+ * bill is computed from them. Date-based, and changed only when a customer
47
+ * could observe the difference. It is emitted into the generated pricing
48
+ * documentation so a stale copy is identifiable on sight.
49
+ */
50
+ export const PRICING_VERSION = '2026-07-27';
51
+
52
+ /**
53
+ * Resolve a stored plan string (`stripe_subscription.plan`) to a tier.
54
+ *
55
+ * Anything unrecognized resolves to `free` rather than throwing, so an unknown
56
+ * plan degrades to the safest caps instead of taking down the request path.
57
+ *
58
+ * There are deliberately no aliases. A name this enum does not carry belongs to
59
+ * some other product's tier vocabulary, and translating it here is how one
60
+ * product's subscription came to grant another product's entitlements. The
61
+ * caller filters to plans this contract names; see `resolveOrgTier`.
62
+ */
63
+ export function toPlanTier(plan: string | null | undefined): PlanTier {
64
+ const raw = (plan ?? 'free').trim().toLowerCase();
65
+ return planTierSchema.catch('free').parse(raw);
66
+ }
67
+
68
+ /**
69
+ * The axes a bill is actually computed from: `ops` is metered, `connections` is
70
+ * capped. Storage is quoted but not billed, so it is not here.
71
+ */
72
+ export const billableAxisSchema = z.enum(['ops', 'connections']);
73
+ export type BillableAxis = z.infer<typeof billableAxisSchema>;
74
+
75
+ /** Which billable axis each meter rolls into. `null` is recorded but unpriced. */
76
+ export const METER_EVENT_AXIS: Record<MeterEvent, BillableAxis | null> = {
77
+ 'api.commit_ops': 'ops',
78
+ 'api.model_reads': 'ops',
79
+ 'api.claim_creates': 'ops',
80
+ 'api.bootstraps': null,
81
+ };
82
+
83
+ /** How each meter reads on an invoice line and in the pricing table. */
84
+ export const METER_EVENT_LABEL: Record<MeterEvent, string> = {
85
+ 'api.commit_ops': 'commit operations',
86
+ 'api.model_reads': 'model reads',
87
+ 'api.claim_creates': 'claim creates',
88
+ 'api.bootstraps': 'bootstraps',
89
+ };
90
+
91
+ /** The meters a customer is charged for, in declaration order. */
92
+ export const BILLABLE_METER_EVENTS: readonly MeterEvent[] = (
93
+ meterEventSchema.options as readonly MeterEvent[]
94
+ ).filter((event) => METER_EVENT_AXIS[event] !== null);
95
+
96
+ export const planFeatureSchema = z.enum([
97
+ 'sso',
98
+ 'auditExport',
99
+ 'cmek',
100
+ 'privateNetworking',
101
+ 'customCaps',
102
+ ]);
103
+ export type PlanFeature = z.infer<typeof planFeatureSchema>;
104
+
105
+ /** How each feature reads on the pricing page. */
106
+ export const PLAN_FEATURE_LABEL: Record<PlanFeature, string> = {
107
+ sso: 'Single sign-on',
108
+ auditExport: 'Audit log export',
109
+ cmek: 'Customer-managed encryption keys',
110
+ privateNetworking: 'Private networking',
111
+ customCaps: 'Custom limits',
112
+ };
113
+
114
+ /**
115
+ * The rate card, validated as a card rather than as three unrelated objects:
116
+ * ceilings ascend, rates never increase as volume grows, and exactly one
117
+ * unbounded step closes it. A malformed card fails at module load, which is the
118
+ * only moment it can fail safely.
119
+ */
120
+ const rateCardSchema = z
121
+ .array(rateBracketSchema)
122
+ .min(1)
123
+ .superRefine((brackets, ctx) => {
124
+ let previousCeiling = 0;
125
+ let previousRate = Number.POSITIVE_INFINITY;
126
+ brackets.forEach((bracket, index) => {
127
+ const isLast = index === brackets.length - 1;
128
+ if (isLast !== (bracket.throughOps === null)) {
129
+ ctx.addIssue({
130
+ code: 'custom',
131
+ message: `bracket ${index}: only the final bracket may be unbounded`,
132
+ });
133
+ return;
134
+ }
135
+ if (bracket.throughOps !== null) {
136
+ if (bracket.throughOps <= previousCeiling) {
137
+ ctx.addIssue({
138
+ code: 'custom',
139
+ message: `bracket ${index}: ceiling ${bracket.throughOps} does not exceed ${previousCeiling}`,
140
+ });
141
+ }
142
+ previousCeiling = bracket.throughOps;
143
+ }
144
+ if (bracket.usdPerMillionOps > previousRate) {
145
+ ctx.addIssue({
146
+ code: 'custom',
147
+ message: `bracket ${index}: rate rises with volume`,
148
+ });
149
+ }
150
+ previousRate = bracket.usdPerMillionOps;
151
+ });
152
+ });
153
+
154
+ /**
155
+ * The published rate card for sync operations. These numbers are the tunable
156
+ * knob; the bracket structure is the contract.
157
+ */
158
+ export const OPS_RATE_CARD: readonly RateBracket[] = rateCardSchema.parse([
159
+ { throughOps: 50_000_000, usdPerMillionOps: 2.0 },
160
+ { throughOps: 500_000_000, usdPerMillionOps: 1.2 },
161
+ { throughOps: null, usdPerMillionOps: 0.6 },
162
+ ]);
163
+
164
+ /**
165
+ * A tier. `null` on a floor, a cap, or a quota means negotiated rather than
166
+ * unlimited, and the runtime treats it as "no typed default, use the operator's
167
+ * configured value".
168
+ */
169
+ export const planDefinitionSchema = z.object({
170
+ tier: planTierSchema,
171
+ label: z.string(),
172
+ /** One line on the pricing page saying who the tier is for. */
173
+ summary: z.string(),
174
+ /**
175
+ * The monthly floor in USD. Usage is charged against it, not added to it, so
176
+ * an organization pays this or its metered usage, whichever is greater.
177
+ * `null` means the floor is set in the contract.
178
+ */
179
+ monthlyMinimumUsd: z.number().nonnegative().nullable(),
180
+ /**
181
+ * A hard monthly stop on operations. Set only where the tier is not billed at
182
+ * all: Free is capped rather than metered, so exceeding it is refused instead
183
+ * of invoiced. `null` on every metered tier.
184
+ */
185
+ hardCapOps: z.number().int().positive().nullable(),
186
+ /**
187
+ * A hard daily stop, which is a burst guard rather than an allowance. A month
188
+ * of operations is a comfortable month of building and about ten minutes of
189
+ * one agent at full rate, so a monthly cap alone lets a runaway loop erase the
190
+ * whole month before anyone notices. The daily figure is what turns that into
191
+ * a floor on how fast the month can be spent. `null` on metered tiers, which
192
+ * are billed rather than stopped.
193
+ */
194
+ hardCapOpsPerDay: z.number().int().positive().nullable(),
195
+ /** Quoted ceiling on stored data, in GiB. Not billed. `null` is negotiated. */
196
+ storageGib: z.number().positive().nullable(),
197
+ /** Hard concurrent-connection cap, the real Free to Scale boundary. */
198
+ maxConcurrentConnections: z.number().int().positive().nullable(),
199
+ /** True when the terms are set in a contract rather than published in full. */
200
+ contractPriced: z.boolean(),
201
+ features: z.array(planFeatureSchema),
202
+ });
203
+ export type PlanDefinition = z.infer<typeof planDefinitionSchema>;
204
+
205
+ export const PLANS = z
206
+ .object({
207
+ free: planDefinitionSchema,
208
+ scale: planDefinitionSchema,
209
+ enterprise: planDefinitionSchema,
210
+ })
211
+ .parse({
212
+ free: {
213
+ tier: 'free',
214
+ label: 'Free',
215
+ summary: 'Build against the real engine without a card.',
216
+ monthlyMinimumUsd: 0,
217
+ hardCapOps: 1_000_000,
218
+ hardCapOpsPerDay: 100_000,
219
+ storageGib: 1,
220
+ maxConcurrentConnections: 25,
221
+ contractPriced: false,
222
+ features: [],
223
+ },
224
+ scale: {
225
+ tier: 'scale',
226
+ label: 'Scale',
227
+ summary: 'Production traffic, metered above a monthly floor.',
228
+ monthlyMinimumUsd: 99,
229
+ hardCapOps: null,
230
+ hardCapOpsPerDay: null,
231
+ storageGib: 50,
232
+ maxConcurrentConnections: 1_000,
233
+ contractPriced: false,
234
+ features: ['auditExport'],
235
+ },
236
+ enterprise: {
237
+ tier: 'enterprise',
238
+ label: 'Enterprise',
239
+ summary: 'Committed volume, private networking, and an uptime guarantee.',
240
+ monthlyMinimumUsd: 2_000,
241
+ hardCapOps: null,
242
+ hardCapOpsPerDay: null,
243
+ storageGib: null,
244
+ maxConcurrentConnections: null,
245
+ contractPriced: true,
246
+ features: ['sso', 'auditExport', 'cmek', 'privateNetworking', 'customCaps'],
247
+ },
248
+ }) satisfies Record<PlanTier, PlanDefinition>;
249
+
250
+ /** The tiers in the order they are presented, cheapest first. */
251
+ export const PLAN_ORDER: readonly PlanTier[] = ['free', 'scale', 'enterprise'];
252
+
253
+ /** Round to whole cents. Money that is not rounded at the boundary is money
254
+ * that disagrees with the invoice by a fraction nobody can explain. */
255
+ function roundUsd(usd: number): number {
256
+ return Math.round(usd * 100) / 100;
257
+ }
258
+
259
+ /**
260
+ * What a month of `ops` costs at the rate card, before any floor is applied.
261
+ * Marginal across brackets: the first 50 million are charged at the first rate
262
+ * whatever the total turns out to be.
263
+ */
264
+ export function usdForOps(ops: number): number {
265
+ if (!Number.isFinite(ops) || ops <= 0) return 0;
266
+ let remaining = ops;
267
+ let previousCeiling = 0;
268
+ let usd = 0;
269
+ for (const bracket of OPS_RATE_CARD) {
270
+ const span =
271
+ bracket.throughOps === null
272
+ ? Number.POSITIVE_INFINITY
273
+ : bracket.throughOps - previousCeiling;
274
+ const charged = Math.min(remaining, span);
275
+ usd += (charged / 1_000_000) * bracket.usdPerMillionOps;
276
+ remaining -= charged;
277
+ if (remaining <= 0) break;
278
+ if (bracket.throughOps !== null) previousCeiling = bracket.throughOps;
279
+ }
280
+ return roundUsd(usd);
281
+ }
282
+
283
+ /**
284
+ * The inverse: how many operations a given spend buys at the rate card. This is
285
+ * what turns a floor into the allowance a tier advertises, which is why the
286
+ * allowance can never drift from the floor.
287
+ */
288
+ export function opsForUsd(usd: number): number {
289
+ if (!Number.isFinite(usd) || usd <= 0) return 0;
290
+ let remaining = usd;
291
+ let previousCeiling = 0;
292
+ let ops = 0;
293
+ for (const bracket of OPS_RATE_CARD) {
294
+ const span =
295
+ bracket.throughOps === null
296
+ ? Number.POSITIVE_INFINITY
297
+ : bracket.throughOps - previousCeiling;
298
+ if (bracket.usdPerMillionOps === 0) return Number.POSITIVE_INFINITY;
299
+ const spanCost = (span / 1_000_000) * bracket.usdPerMillionOps;
300
+ if (remaining <= spanCost) {
301
+ return Math.floor(ops + (remaining / bracket.usdPerMillionOps) * 1_000_000);
302
+ }
303
+ ops += span;
304
+ remaining -= spanCost;
305
+ if (bracket.throughOps !== null) previousCeiling = bracket.throughOps;
306
+ }
307
+ return Math.floor(ops);
308
+ }
309
+
310
+ /**
311
+ * The operations a tier includes, as shown on the pricing page. A capped tier
312
+ * shows its cap; a metered tier shows what its floor covers, computed from the
313
+ * floor rather than stored beside it. `null` where the terms are contractual.
314
+ */
315
+ export function monthlyOpsAllowance(tier: PlanTier): number | null {
316
+ const plan = PLANS[tier];
317
+ if (plan.hardCapOps !== null) return plan.hardCapOps;
318
+ if (plan.contractPriced || plan.monthlyMinimumUsd === null) return null;
319
+ return opsForUsd(plan.monthlyMinimumUsd);
320
+ }
321
+
322
+ /**
323
+ * The invoice for a month: the floor, or the metered usage, whichever is
324
+ * greater. A capped tier is never billed above its floor because the cap is
325
+ * enforced before the usage exists. `null` where the terms are contractual.
326
+ */
327
+ export function monthlyBillUsd(tier: PlanTier, ops: number): number | null {
328
+ const plan = PLANS[tier];
329
+ if (plan.contractPriced || plan.monthlyMinimumUsd === null) return null;
330
+ if (plan.hardCapOps !== null) return plan.monthlyMinimumUsd;
331
+ return roundUsd(Math.max(plan.monthlyMinimumUsd, usdForOps(ops)));
332
+ }
333
+
334
+ /**
335
+ * The daily operations cap for a tier: the typed default the quota gate falls
336
+ * back to when no `plan_limit` row sets one. `null` means the tier is metered
337
+ * rather than stopped.
338
+ */
339
+ export function dailyOpsCapForTier(tier: PlanTier): number | null {
340
+ return PLANS[tier].hardCapOpsPerDay;
341
+ }
342
+
343
+ /**
344
+ * The concurrent-connection cap for a tier: the typed default the Hub's
345
+ * resolver falls back to when no `plan_limit` override row exists. `null` means
346
+ * negotiated, which the Hub reads as its configured floor.
347
+ */
348
+ export function connectionCapForTier(tier: PlanTier): number | null {
349
+ return PLANS[tier].maxConcurrentConnections;
350
+ }
@@ -53,6 +53,17 @@ export interface ContentionOptions {
53
53
  /** Reconcile rounds before a hot row is declared permanently contended. */
54
54
  export const DEFAULT_CONTENTION_RETRIES = 16;
55
55
 
56
+ const sleep = (ms: number): Promise<void> =>
57
+ new Promise((resolve) => setTimeout(resolve, ms));
58
+
59
+ /**
60
+ * Jittered backoff so N reconcilers retrying at once don't lock-step straight
61
+ * back into the same collision. Bounded; grows mildly with the attempt.
62
+ */
63
+ function backoffMs(attempt: number): number {
64
+ return 60 + attempt * 40 + Math.floor(Math.random() * 60);
65
+ }
66
+
56
67
  /**
57
68
  * Reports whether a thrown error means "another writer moved the row — re-read
58
69
  * and retry" rather than a genuine failure to surface. These are the
@@ -69,17 +80,6 @@ export function isReconcilableConflict(err: unknown): boolean {
69
80
  return false;
70
81
  }
71
82
 
72
- const sleep = (ms: number): Promise<void> =>
73
- new Promise((resolve) => setTimeout(resolve, ms));
74
-
75
- /**
76
- * Jittered backoff so N reconcilers retrying at once don't lock-step straight
77
- * back into the same collision. Bounded; grows mildly with the attempt.
78
- */
79
- function backoffMs(attempt: number): number {
80
- return 60 + attempt * 40 + Math.floor(Math.random() * 60);
81
- }
82
-
83
83
  /**
84
84
  * The transport-specific read and write that the shared loop drives. Each client
85
85
  * injects its own pair — the one thing that differs between the HTTP and
@@ -170,6 +170,7 @@ export {
170
170
  type RoleSource,
171
171
  type RoleContext,
172
172
  type SyncGroup,
173
+ type SyncGroupRef,
173
174
  type SyncGroupInput,
174
175
  identityRole,
175
176
  entityRole,
@@ -177,6 +178,7 @@ export {
177
178
  extractEntityIds,
178
179
  syncGroup,
179
180
  syncGroupSchema,
181
+ syncGroupRefSchema,
180
182
  syncGroupInputSchema,
181
183
  isSyncGroupInput,
182
184
  identityRoleSchema,
@@ -31,9 +31,32 @@ import { z } from 'zod';
31
31
  * {@link syncGroup}. Because the brand is an intersection it's still assignable
32
32
  * *to* `string`, so existing `string[]` plumbing keeps working unchanged.
33
33
  */
34
- export const syncGroupSchema = z
35
- .templateLiteral([z.string().regex(/^[a-z][a-z0-9_]*$/), ':', z.string().min(1)])
36
- .brand<'SyncGroup'>();
34
+ /**
35
+ * The `kind:id` shape itself, unbranded — what a CALLER writes.
36
+ *
37
+ * Input and output want different strictness from one format, so the format is
38
+ * declared once here and viewed two ways. This view rejects `'nonsense'` at
39
+ * compile time (no colon ⇒ matches no group) while still accepting
40
+ * `` `org:${orgId}` `` written inline, which is the whole point of
41
+ * {@link SyncGroupInput}. Requiring the constructor on an input field buys no
42
+ * safety this does not already give and costs every call site a helper import.
43
+ */
44
+ export const syncGroupRefSchema = z.templateLiteral([
45
+ z.string().regex(/^[a-z][a-z0-9_]*$/),
46
+ ':',
47
+ z.string().min(1),
48
+ ]);
49
+
50
+ export type SyncGroupRef = z.infer<typeof syncGroupRefSchema>;
51
+
52
+ /**
53
+ * The same format, branded — what the SERVER hands back.
54
+ *
55
+ * The brand earns its keep on the way out, where Ablo is the author: a consumer
56
+ * that receives a `SyncGroup` knows it was minted, and a raw string cannot
57
+ * masquerade as one.
58
+ */
59
+ export const syncGroupSchema = syncGroupRefSchema.brand<'SyncGroup'>();
37
60
 
38
61
  export type SyncGroup = z.infer<typeof syncGroupSchema>;
39
62
 
@@ -41,6 +41,7 @@ export {
41
41
  type RoleSource,
42
42
  type RoleContext,
43
43
  type SyncGroup,
44
+ type SyncGroupRef,
44
45
  type SyncGroupInput,
45
46
  identityRole,
46
47
  entityRole,
@@ -51,6 +52,7 @@ export {
51
52
  intersectRequestedWithAllowed,
52
53
  syncGroup,
53
54
  syncGroupSchema,
55
+ syncGroupRefSchema,
54
56
  syncGroupInputSchema,
55
57
  isSyncGroupInput,
56
58
  identityRoleSchema,
@@ -180,10 +180,16 @@ const handleMutationResult: WsFrameHandler = (session, message) => {
180
180
  const event = {
181
181
  clientTxId: txId,
182
182
  rows: receipt.notifications.map((n) => ({
183
- model: n.model,
184
- id: n.id,
185
- fields: n.conflictingFields,
183
+ // The row that moved, in both scopes. A group notification used to
184
+ // report its group key here, so the log named a row that does not
185
+ // exist; the premise it breached is reported as `group` instead.
186
+ model: n.target.model,
187
+ id: n.target.id,
188
+ fields: n.target.fields ?? [],
186
189
  writtenBy: n.writtenBy.kind,
190
+ ...(n.scope === 'group'
191
+ ? { group: n.group, ...(n.propagation ? { via: n.propagation.via } : {}) }
192
+ : {}),
187
193
  })),
188
194
  };
189
195
  const message = formatConflict(event);
@@ -19,6 +19,9 @@ import { z } from 'zod';
19
19
  // response exists to stop withholding.
20
20
  import { fieldMetaSchema, relationMetaSchema } from './modelShape.js';
21
21
  import { onStaleModeSchema } from '../coordination/schema.js';
22
+ // The price's own vocabulary. A dashboard that restated the tier names or the
23
+ // meter set would be a second definition of what the invoice is computed from.
24
+ import { meterEventSchema, planTierSchema, rateBracketSchema } from './pricing.js';
22
25
  import type { SyncDeltaAction } from './delta.js';
23
26
  import { deltaSchema } from './delta.js';
24
27
  // Kept for the {@link ListEnvelope} references below; `GET /v1/logs`'s own
@@ -325,3 +328,76 @@ export const usageReportResponseSchema = z.object({
325
328
  data_freshness_seconds: z.number(),
326
329
  });
327
330
  export type UsageReportResponse = z.infer<typeof usageReportResponseSchema>;
331
+
332
+ /**
333
+ * One meter's month-to-date usage, as a management surface shows it.
334
+ *
335
+ * `eventName` is the pricing contract's closed set rather than a free string,
336
+ * so a surface cannot render a meter the price does not recognise. `billable`
337
+ * says whether the meter reaches an invoice at all: bootstraps are recorded and
338
+ * gated but never charged, and a usage table that does not say so reads as a
339
+ * bill nobody sent.
340
+ */
341
+ export const meterUsageSchema = z.object({
342
+ eventName: meterEventSchema,
343
+ displayName: z.string(),
344
+ /** Operations counted this period. */
345
+ total: z.number(),
346
+ /** Requests behind that total. A commit of 500 operations counts once here. */
347
+ count: z.number(),
348
+ /** The plan's monthly allowance for this meter; null where it is ungated. */
349
+ monthlyMax: z.number().nullable(),
350
+ billable: z.boolean(),
351
+ });
352
+ export type MeterUsage = z.infer<typeof meterUsageSchema>;
353
+
354
+ /**
355
+ * What the period costs, computed once by the server from the pricing contract.
356
+ *
357
+ * The client renders these rather than recomputing them. A dashboard that does
358
+ * its own arithmetic is a second implementation of the price, and the one a
359
+ * customer reads before they are billed, so it is the copy that has to be
360
+ * right and the one nothing checks.
361
+ */
362
+ export const billingSummarySchema = z.object({
363
+ tier: planTierSchema,
364
+ /** The tier as it reads on the pricing page. */
365
+ label: z.string(),
366
+ /** The monthly floor in USD; null where terms are contractual. */
367
+ monthlyMinimumUsd: z.number().nullable(),
368
+ /** Operations the floor already covers, or the hard cap on a capped tier. */
369
+ includedOps: z.number().nullable(),
370
+ /** Where the tier stops rather than bills; null on every metered tier. */
371
+ hardCapOps: z.number().nullable(),
372
+ /** The daily burst guard; null on every metered tier. */
373
+ dailyCapOps: z.number().nullable(),
374
+ /** Every billable meter, collapsed into the one number the price uses. */
375
+ opsUsed: z.number(),
376
+ /** The floor or the metered usage, whichever is greater. null = contract. */
377
+ projectedBillUsd: z.number().nullable(),
378
+ /** True where the published card is a starting point, not the terms. */
379
+ contractPriced: z.boolean(),
380
+ /**
381
+ * The rate card in force, so a surface can show what the next operation
382
+ * costs without shipping a copy of the price to the browser.
383
+ */
384
+ rateCard: z.array(rateBracketSchema).readonly(),
385
+ });
386
+ export type BillingSummary = z.infer<typeof billingSummarySchema>;
387
+
388
+ /**
389
+ * `GET /v1/dashboard/usage-summary` — month-to-date usage and what it costs.
390
+ *
391
+ * Distinct from {@link usageReportResponseSchema}, which answers the public
392
+ * `GET /v1/usage` with daily buckets for a caller doing its own analysis. This
393
+ * one answers the question a person opens a dashboard to ask: what have I used,
394
+ * and what will it cost.
395
+ */
396
+ export const usageSummaryResponseSchema = z.object({
397
+ plan: planTierSchema,
398
+ /** Start of the billing period the numbers cover, ISO-8601. */
399
+ periodStart: z.string(),
400
+ meters: z.array(meterUsageSchema).readonly(),
401
+ billing: billingSummarySchema,
402
+ });
403
+ export type UsageSummaryResponse = z.infer<typeof usageSummaryResponseSchema>;