@abloatai/transaction 0.52.0 → 0.53.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 (89) hide show
  1. package/dist/auth/apiKey.d.ts +0 -6
  2. package/dist/auth/apiKey.d.ts.map +1 -1
  3. package/dist/auth/apiKey.js +13 -60
  4. package/dist/auth/apiKey.js.map +1 -1
  5. package/dist/auth/baseUrl.d.ts +38 -0
  6. package/dist/auth/baseUrl.d.ts.map +1 -0
  7. package/dist/auth/baseUrl.js +126 -0
  8. package/dist/auth/baseUrl.js.map +1 -0
  9. package/dist/auth/index.d.ts +9 -1
  10. package/dist/auth/index.d.ts.map +1 -1
  11. package/dist/auth/index.js +10 -1
  12. package/dist/auth/index.js.map +1 -1
  13. package/dist/branches.d.ts +31 -30
  14. package/dist/branches.d.ts.map +1 -1
  15. package/dist/branches.js +2 -4
  16. package/dist/branches.js.map +1 -1
  17. package/dist/errorCodes.d.ts +4 -1
  18. package/dist/errorCodes.d.ts.map +1 -1
  19. package/dist/errorCodes.js +4 -1
  20. package/dist/errorCodes.js.map +1 -1
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/resources/httpResources.d.ts +32 -0
  25. package/dist/resources/httpResources.d.ts.map +1 -1
  26. package/dist/resources/httpResources.js +10 -1
  27. package/dist/resources/httpResources.js.map +1 -1
  28. package/dist/resources/modelOperations.d.ts +12 -0
  29. package/dist/resources/modelOperations.d.ts.map +1 -1
  30. package/dist/resources/modelOperations.js.map +1 -1
  31. package/dist/resources/where.d.ts +67 -2
  32. package/dist/resources/where.d.ts.map +1 -1
  33. package/dist/resources/where.js +73 -0
  34. package/dist/resources/where.js.map +1 -1
  35. package/dist/schema/openapi.d.ts.map +1 -1
  36. package/dist/schema/openapi.js +59 -14
  37. package/dist/schema/openapi.js.map +1 -1
  38. package/dist/server/adapter.d.ts +2 -0
  39. package/dist/server/adapter.d.ts.map +1 -1
  40. package/dist/server/index.d.ts +3 -2
  41. package/dist/server/index.d.ts.map +1 -1
  42. package/dist/server/index.js.map +1 -1
  43. package/dist/server/readConfig.d.ts +109 -57
  44. package/dist/server/readConfig.d.ts.map +1 -1
  45. package/dist/server/readConfig.js +19 -5
  46. package/dist/server/readConfig.js.map +1 -1
  47. package/dist/transport/httpClient.d.ts +7 -2
  48. package/dist/transport/httpClient.d.ts.map +1 -1
  49. package/dist/transport/httpClient.js +4 -2
  50. package/dist/transport/httpClient.js.map +1 -1
  51. package/dist/transport/httpTransport.d.ts.map +1 -1
  52. package/dist/transport/httpTransport.js +29 -7
  53. package/dist/transport/httpTransport.js.map +1 -1
  54. package/dist/wire/accountResponses.d.ts +18 -13
  55. package/dist/wire/accountResponses.d.ts.map +1 -1
  56. package/dist/wire/accountResponses.js +3 -9
  57. package/dist/wire/accountResponses.js.map +1 -1
  58. package/dist/wire/claims.d.ts +1 -0
  59. package/dist/wire/claims.d.ts.map +1 -1
  60. package/dist/wire/claims.js +7 -1
  61. package/dist/wire/claims.js.map +1 -1
  62. package/dist/wire/index.d.ts +1 -1
  63. package/dist/wire/index.d.ts.map +1 -1
  64. package/dist/wire/index.js +1 -1
  65. package/dist/wire/index.js.map +1 -1
  66. package/dist/wire/listEnvelope.d.ts +20 -0
  67. package/dist/wire/listEnvelope.d.ts.map +1 -1
  68. package/dist/wire/listEnvelope.js +20 -0
  69. package/dist/wire/listEnvelope.js.map +1 -1
  70. package/package.json +1 -1
  71. package/src/auth/apiKey.ts +13 -61
  72. package/src/auth/baseUrl.ts +144 -0
  73. package/src/auth/index.ts +11 -1
  74. package/src/branches.ts +2 -4
  75. package/src/errorCodes.ts +19 -1
  76. package/src/index.ts +2 -0
  77. package/src/resources/httpResources.ts +42 -0
  78. package/src/resources/modelOperations.ts +12 -0
  79. package/src/resources/where.ts +70 -4
  80. package/src/schema/openapi.ts +60 -14
  81. package/src/server/adapter.ts +2 -0
  82. package/src/server/index.ts +8 -2
  83. package/src/server/readConfig.ts +111 -57
  84. package/src/transport/httpClient.ts +17 -4
  85. package/src/transport/httpTransport.ts +29 -7
  86. package/src/wire/accountResponses.ts +3 -9
  87. package/src/wire/claims.ts +7 -1
  88. package/src/wire/index.ts +7 -1
  89. package/src/wire/listEnvelope.ts +23 -0
@@ -36,6 +36,7 @@ import {
36
36
  commitRecordListSchema,
37
37
  commitRecordWhereSchema,
38
38
  } from '../wire/commit.js';
39
+ import { CURSOR_PARAM, CURSOR_PARAM_ALIAS } from '../wire/listEnvelope.js';
39
40
  import {
40
41
  claimRequestSchema,
41
42
  claimHeartbeatRequestSchema,
@@ -185,6 +186,43 @@ function fieldSchema(f: FieldMeta): Json {
185
186
 
186
187
  const pascal = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
187
188
  const idParam = (): Json => ({ name: 'id', in: 'path', required: true, schema: { type: 'string' } });
189
+ const idempotencyKeyParam = (): Json => ({
190
+ name: 'Idempotency-Key',
191
+ in: 'header',
192
+ schema: { type: 'string', maxLength: 255 },
193
+ description: 'Replay identity. Reuse the same key only for an identical request.',
194
+ });
195
+ const collectionPageParams = (): Json[] => [
196
+ {
197
+ name: 'limit',
198
+ in: 'query',
199
+ schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
200
+ },
201
+ cursorParam(),
202
+ retiredCursorParam(),
203
+ ];
204
+
205
+ /** The cursor parameter, named from the wire contract that issues its value. */
206
+ const cursorParam = (): Json => ({
207
+ name: CURSOR_PARAM,
208
+ in: 'query',
209
+ schema: { type: 'string' },
210
+ description: 'The opaque next_cursor returned by the preceding page.',
211
+ });
212
+
213
+ /**
214
+ * The retired spelling, documented as deprecated so a caller still sending it
215
+ * can see it is going. Declared once: `queryParams` reaches for it too, and a
216
+ * second copy is how the model list came to document the same parameter without
217
+ * the deprecation the collection routes carried.
218
+ */
219
+ const retiredCursorParam = (): Json => ({
220
+ name: CURSOR_PARAM_ALIAS,
221
+ in: 'query',
222
+ deprecated: true,
223
+ schema: { type: 'string' },
224
+ description: `Deprecated spelling of ${CURSOR_PARAM}, honoured until it is removed. Send ${CURSOR_PARAM}.`,
225
+ });
188
226
  const jsonBody = (schema: Json): Json => ({
189
227
  required: true,
190
228
  content: { 'application/json': { schema } },
@@ -313,16 +351,22 @@ function withGenericRows(derived: Json): Json {
313
351
  */
314
352
  function queryParams(schema: z.ZodType): Json[] {
315
353
  const props = (derive(schema, 'input').properties ?? {}) as Record<string, Json>;
316
- return Object.entries(props).map(([name, s]) => ({
317
- name,
318
- in: 'query',
319
- // Query strings arrive at the server as text, but OpenAPI describes the
320
- // caller-facing value before serialization. Generated clients should take
321
- // an integer here and encode it, not expose a stringly typed page size.
322
- schema: name === 'limit'
323
- ? { type: 'integer', minimum: 1 }
324
- : s,
325
- }));
354
+ return Object.entries(props).map(([name, s]) => {
355
+ // The retired cursor spelling is documented the same way wherever it
356
+ // appears. Derived from the schema it still lives in, so the flag cannot be
357
+ // present on one route's copy of the parameter and absent on another's.
358
+ if (name === CURSOR_PARAM_ALIAS) return retiredCursorParam();
359
+ return {
360
+ name,
361
+ in: 'query',
362
+ // Query strings arrive at the server as text, but OpenAPI describes the
363
+ // caller-facing value before serialization. Generated clients should take
364
+ // an integer here and encode it, not expose a stringly typed page size.
365
+ schema: name === 'limit'
366
+ ? { type: 'integer', minimum: 1 }
367
+ : s,
368
+ };
369
+ });
326
370
  }
327
371
 
328
372
  const commitRecordListQuerySchema = commitRecordWhereSchema.safeExtend({
@@ -492,8 +536,8 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
492
536
  parameters: [modelParam(), ...queryParams(listQuerySchema)],
493
537
  responses: {
494
538
  '200': namedResp(
495
- 'A page of rows. `next_cursor` feeds `starting_after` on the next ' +
496
- 'call; `stamp` is the watermark the page was read at.',
539
+ 'A page of rows. `next_cursor` feeds `cursor` on the next call; ' +
540
+ '`stamp` is the watermark the page was read at.',
497
541
  'ModelPage',
498
542
  ),
499
543
  },
@@ -599,6 +643,7 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
599
643
  get: {
600
644
  tags: ['branches'],
601
645
  summary: 'List transaction branches for the credential project',
646
+ parameters: collectionPageParams(),
602
647
  responses: {
603
648
  '200': jsonResp(
604
649
  'The root and every active child branch.',
@@ -611,6 +656,7 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
611
656
  summary: 'Create an isolated child branch',
612
657
  description:
613
658
  'The returned id is immutable; retain it for automation. The slug is a project-scoped human handle.',
659
+ parameters: [idempotencyKeyParam()],
614
660
  requestBody: jsonBody(derive(createBranchRequestSchema, 'input')),
615
661
  responses: {
616
662
  '201': jsonResp('The ready branch.', derive(branchResponseSchema, 'output')),
@@ -866,7 +912,7 @@ export function abloOpenApi(options: SchemaToOpenApiOptions = {}): Json {
866
912
  post: {
867
913
  tags: ['commits'],
868
914
  summary: 'Commit a batch of operations atomically, and/or register durable premises',
869
- parameters: [{ name: 'Idempotency-Key', in: 'header', schema: { type: 'string' }, description: 'Replay-safe key; the server returns the cached receipt on retry.' }],
915
+ parameters: [idempotencyKeyParam()],
870
916
  requestBody: commitBody(),
871
917
  responses: { '200': commitReceipt() },
872
918
  },
@@ -993,7 +1039,7 @@ export function schemaToOpenApi<S extends SchemaRecord>(
993
1039
  post: {
994
1040
  tags: ['commits'],
995
1041
  summary: 'Commit a batch of operations atomically, and/or register durable premises',
996
- parameters: [{ name: 'Idempotency-Key', in: 'header', schema: { type: 'string' }, description: 'Replay-safe key; the server returns the cached receipt on retry.' }],
1042
+ parameters: [idempotencyKeyParam()],
997
1043
  requestBody: commitBody(),
998
1044
  responses: { '200': commitReceipt() },
999
1045
  },
@@ -61,6 +61,8 @@ export type ReadResult =
61
61
  | {
62
62
  readonly kind: 'query';
63
63
  readonly rows: readonly Row[];
64
+ /** Opaque upstream cursor when a source-backed list has another page. */
65
+ readonly nextCursor?: string;
64
66
  };
65
67
 
66
68
  // ── sync ─────────────────────────────────────────────────────────────────────
@@ -4,7 +4,8 @@
4
4
  * vocabulary ({@link Row}, {@link ReadRequest}, {@link ChangeSet}, and the rest),
5
5
  * the {@link CommitContext} and {@link CommitExecutionResult} commit types, the
6
6
  * {@link StorageMode} enumeration, and the per-model read configuration
7
- * ({@link BootstrapModel}, {@link ColumnOverride}). These are plain, driver-free
7
+ * ({@link ReadModelShape} and the {@link BootstrapModel} descriptor built on it,
8
+ * plus {@link ColumnOverride} and {@link ParentScope}). These are plain, driver-free
8
9
  * types; you supply the database code that fulfills them.
9
10
  */
10
11
  export type {
@@ -27,4 +28,9 @@ export {
27
28
  type CommitRecord,
28
29
  } from '../wire/commit.js';
29
30
  export { storageModeSchema, type StorageMode } from './storageMode.js';
30
- export type { ColumnOverride, BootstrapModel } from './readConfig.js';
31
+ export type {
32
+ ColumnOverride,
33
+ ParentScope,
34
+ ReadModelShape,
35
+ BootstrapModel,
36
+ } from './readConfig.js';
@@ -1,9 +1,23 @@
1
1
  /**
2
- * Canonical per-model read configuration consumed when a client bootstraps. Each
3
- * {@link BootstrapModel} maps a model to its physical table, its tenancy column, and
4
- * any parent scoping — plain data with no database driver. It feeds the read side of
5
- * the data adapter contract; your query builder reads it to load a model's initial
6
- * rows.
2
+ * Canonical per-model read configuration how one model's rows are located,
3
+ * scoped to a tenant, and decoded back into the types the schema declared.
4
+ *
5
+ * {@link ReadModelShape} is the definition site for those facts. Every read seam
6
+ * needs the same set, and each one that restated the set drifted from the
7
+ * others: `json` decoding reached three of them, `number` decoding reached none,
8
+ * and the where-clause `boolean` list reached exactly one — so a `bigint`-backed
9
+ * field left one seam as a decimal string and its `int4` neighbour left the next
10
+ * as a number. Adding a declared type is now one edit here rather than a
11
+ * remembered sweep.
12
+ *
13
+ * The descriptors below carry that shape plus the facts only they need. They
14
+ * disagree on one thing, deliberately: the model's own name. A bootstrap
15
+ * descriptor spells it `name`, the server's read-model descriptor spells it
16
+ * `typename`. `name` is part of the published data-adapter contract, so the two
17
+ * spellings stay and the shape they share owns everything else.
18
+ *
19
+ * Plain data, no database driver — it feeds the read side of the data adapter
20
+ * contract, and your query builder reads it to load a model's initial rows.
7
21
  */
8
22
 
9
23
  /** A mapping from a declared field to a physical column, with the alias to apply after a `SELECT *`. */
@@ -13,70 +27,110 @@ export interface ColumnOverride {
13
27
  readonly alias: string;
14
28
  }
15
29
 
16
- /** Read configuration for one model: how to locate its rows and scope them to a tenant. */
17
- export interface BootstrapModel {
18
- name: string;
30
+ /**
31
+ * Parent-table tenancy scoping for rows that have no `organization_id` column
32
+ * of their own, mirroring the schema's `scopedVia` option. A read carrying this
33
+ * adds:
34
+ *
35
+ * WHERE <table>.<localKey> IN
36
+ * (SELECT <parentKey> FROM <parentTable> WHERE <parentOrgColumn> = $1)
37
+ *
38
+ * It applies on top of whatever `orgScoped` dictates, so a table can carry its
39
+ * own `organization_id` and still narrow through a parent. The common case is
40
+ * `orgScoped: false` together with this on a table that lacks the column —
41
+ * without it, such a table reads every tenant's rows.
42
+ */
43
+ export interface ParentScope {
44
+ readonly localKey: string;
45
+ readonly parentTable: string;
46
+ readonly parentKey?: string;
47
+ readonly parentOrgColumn?: string;
48
+ }
49
+
50
+ /**
51
+ * Everything a read seam needs to know about one model except what it is called.
52
+ *
53
+ * Split from the descriptors that carry it because the two of them name the
54
+ * model differently and agree on all of this. A seam that only decodes, or only
55
+ * compiles SQL, projects the members it uses with `Pick` rather than declaring
56
+ * its own near-copy.
57
+ */
58
+ export interface ReadModelShape {
19
59
  /**
20
60
  * Extra names accepted when a request looks up or filters this model. When the
21
- * physical table name is canonical, `name` stays that table name and the aliases
22
- * cover generated compatibility names such as `WeatherReports` or `weatherReports`.
61
+ * physical table name is canonical, the descriptor's own name stays that table
62
+ * name and the aliases cover generated compatibility names such as
63
+ * `WeatherReports` or `weatherReports`.
23
64
  */
24
- aliases?: readonly string[];
65
+ readonly aliases?: readonly string[];
25
66
  /**
26
- * The schema key used by source endpoints. `name` stays the wire and result model
27
- * name (usually the typename), while source handlers are keyed by the developer's
28
- * schema key, such as `files` or `blocks`.
67
+ * The schema key used by source endpoints. The descriptor's own name stays the
68
+ * wire and result model name (usually the typename), while source handlers are
69
+ * keyed by the developer's schema key, such as `files` or `blocks`.
29
70
  */
30
- sourceModel?: string;
31
- table: string;
32
- syncGroups?: string[];
33
- enabled?: boolean;
34
- /** Max rows to return. Omit for unlimited. Maps to schema's bootstrapLimit. */
35
- limit?: number;
36
- /** SQL ORDER BY clause. Default: 'id'. Maps to schema's bootstrapOrderBy. */
37
- orderBy?: string;
71
+ readonly sourceModel?: string;
72
+ readonly table: string;
38
73
  /** Whether the table has organization_id. Default: true. */
39
- orgScoped?: boolean;
74
+ readonly orgScoped?: boolean;
40
75
  /**
41
76
  * The model derives its tenant from the connected data source (`policy:
42
- * { by: 'source' }`) rather than a row column. It carries no tenant predicate on a
43
- * log plane, so it is excluded from the bootstrap set entirely until the
44
- * write-through connect path can resolve the org from the source registration
45
- * serving it now would return every row to every tenant. Kept on the type as a
46
- * defense-in-depth marker so a source-scoped model that reaches `getBootstrapData`
47
- * fails closed rather than bootstrapping unscoped.
77
+ * { by: 'source' }`) rather than a row column the source registration is the
78
+ * tenant boundary. Distinct from `orgScoped: false` (a `none`/global model): a
79
+ * source-scoped model IS tenant-scoped, just not by a column. It carries no
80
+ * tenant predicate on a log plane, so every read and bootstrap site fails it
81
+ * closed with `source_tenancy_not_enforced` rather than treating it as global
82
+ * serving it now would return every row to every tenant. When the
83
+ * write-through connect path can resolve the org from the source registration,
84
+ * this same flag routes to that resolution.
48
85
  */
49
- sourceScoped?: boolean;
86
+ readonly sourceScoped?: boolean;
50
87
  /** Physical tenancy column (default `organization_id`, configurable per model). */
51
- orgColumn?: string;
52
- /**
53
- * Parent-table scoping for rows that have no `organization_id` column, mirroring
54
- * the schema's `scopedVia` option. When set, the bootstrap query adds:
55
- *
56
- * WHERE <table>.<localKey> IN
57
- * (SELECT <parentKey> FROM <parentTable> WHERE <parentOrgColumn> = $1)
58
- *
59
- * This applies on top of whatever `orgScoped` dictates, so a table can carry its
60
- * own `organization_id` and still narrow through a parent. The common case,
61
- * though, is `orgScoped: false` together with `scopedVia` on a table that lacks the
62
- * column.
63
- */
64
- scopedVia?: {
65
- localKey: string;
66
- parentTable: string;
67
- parentKey?: string;
68
- parentOrgColumn?: string;
69
- };
88
+ readonly orgColumn?: string;
89
+ /** Parent-table scoping for rows with no tenancy column of their own. */
90
+ readonly scopedVia?: ParentScope;
70
91
  /** Client-facing field name → physical DB column for declared fields. */
71
- fieldColumns?: Record<string, string>;
92
+ readonly fieldColumns?: Readonly<Record<string, string>>;
72
93
  /** Physical-column aliases needed after SELECT * for `.from(...)` fields. */
73
- columnOverrides?: readonly ColumnOverride[];
94
+ readonly columnOverrides?: readonly ColumnOverride[];
74
95
  /**
75
- * Physical columns the schema declares as JSON (via `field.json()`). A JSON field
76
- * stored in a text column comes back from `row_to_json` as a serialized string, so
77
- * the bootstrap reparses these columns to make the wire value the canonical object
78
- * regardless of the physical column type. A `jsonb` column already returns an
79
- * object, so reparsing it is a no-op.
96
+ * Physical columns the schema declares as JSON (via `field.json()`). A JSON
97
+ * field stored in a text column comes back serialized, so a read reparses these
98
+ * to make the wire value the canonical object regardless of the physical column
99
+ * type. A `jsonb` column already returns an object, so reparsing it is a no-op.
80
100
  */
81
- jsonColumns?: readonly string[];
101
+ readonly jsonColumns?: readonly string[];
102
+ /**
103
+ * Physical columns the schema declares as a boolean (via `field.boolean()`).
104
+ * Two seams need the list. A where-clause parameter is coerced to a real JS
105
+ * boolean before binding, because the driver's bool encoder is a strict
106
+ * `=== true` and a string `'t'` would silently bind as `'f'` — an INVERTED
107
+ * filter, not an error. A row read back from the log is coerced the same way,
108
+ * because a value echoed from a customer's database arrives as PG's text
109
+ * literal rather than as `true`.
110
+ */
111
+ readonly boolColumns?: readonly string[];
112
+ /**
113
+ * Physical columns the schema declares as a number (via `field.number()`).
114
+ * Postgres has four integer widths where JavaScript has one, so a column too
115
+ * wide to always fit a JS number comes back as decimal text. A read decodes
116
+ * these so a `bigint`-backed field and an `int4`-backed one reach the client as
117
+ * the same JS type.
118
+ */
119
+ readonly numberColumns?: readonly string[];
120
+ }
121
+
122
+ /**
123
+ * Read configuration for one model at bootstrap: {@link ReadModelShape} plus the
124
+ * facts only the initial payload needs — which models are in it, how much of
125
+ * each, and in what order.
126
+ */
127
+ export interface BootstrapModel extends ReadModelShape {
128
+ /** The wire and result model name. `ModelMeta.typename` is the same fact. */
129
+ readonly name: string;
130
+ readonly syncGroups?: readonly string[];
131
+ readonly enabled?: boolean;
132
+ /** Max rows to return. Omit for unlimited. Maps to schema's bootstrapLimit. */
133
+ readonly limit?: number;
134
+ /** SQL ORDER BY clause. Default: 'id'. Maps to schema's bootstrapOrderBy. */
135
+ readonly orderBy?: string;
82
136
  }
@@ -32,10 +32,12 @@ import type {
32
32
  HttpTransportModel,
33
33
  ModelReadOptions,
34
34
  ModelMutationOptions,
35
+ ModelList,
35
36
  CreateSessionParams,
36
37
  AbloSession,
37
38
  SessionResource,
38
39
  } from '../resources/httpResources.js';
40
+ import { modelList } from '../resources/httpResources.js';
39
41
  import type {
40
42
  ModelCreateParams,
41
43
  ModelDeleteParams,
@@ -128,7 +130,12 @@ export interface HttpModelClient<T, C = T> {
128
130
  * Reads the rows matching a filter. Same resolution as `get`, in bulk,
129
131
  * and deduplicated so concurrent identical calls share one request.
130
132
  */
131
- list(options?: ServerReadOptions<T>): Promise<CapturedRow<T>[]>;
133
+ /**
134
+ * The rows are an array as before; `hasMore` and `nextCursor` on the result
135
+ * say whether the collection continues past this page. Pass `nextCursor`
136
+ * back as `cursor`, with the same `where` and `orderBy`, to walk it.
137
+ */
138
+ list(options?: ServerReadOptions<T>): Promise<ModelList<CapturedRow<T>>>;
132
139
  /**
133
140
  * Creates a row and returns it, including any framework-applied defaults.
134
141
  * Passing an id that already exists is idempotent: the existing row is
@@ -427,10 +434,16 @@ function createHttpModelClient<T, C = T>(
427
434
  return read.data as CapturedRow<T> | undefined;
428
435
  };
429
436
 
430
- const list = async (options?: ServerReadOptions<T>): Promise<CapturedRow<T>[]> => {
437
+ const list = async (
438
+ options?: ServerReadOptions<T>,
439
+ ): Promise<ModelList<CapturedRow<T>>> => {
431
440
  const snapshot = await protocol.list(options);
441
+ const page = modelList<CapturedRow<T>>(
442
+ snapshot.data as readonly CapturedRow<T>[],
443
+ snapshot,
444
+ );
432
445
  const registry = readSetContext?.getStore();
433
- if (!registry) return [...snapshot.data] as CapturedRow<T>[];
446
+ if (!registry) return page;
434
447
  if (!snapshot.evidence) {
435
448
  throw new AbloConnectionError(
436
449
  `${modelName}.list did not return row evidence. Upgrade the Ablo server or use get({ id }).`,
@@ -451,7 +464,7 @@ function createHttpModelClient<T, C = T>(
451
464
  }
452
465
  capturePointRead(readSetContext, clientIdentity, modelName, id, row, stamp);
453
466
  }
454
- return [...snapshot.data] as CapturedRow<T>[];
467
+ return page;
455
468
  };
456
469
 
457
470
  // Claim acquisition performs its authoritative read only after the grant.
@@ -84,6 +84,7 @@ import type {
84
84
  ModelMutationOptions,
85
85
  ModelReadOptions,
86
86
  HttpTransportRead,
87
+ HttpTransportList,
87
88
  HttpLogsResource,
88
89
  ModelTarget,
89
90
  CreateSessionParams,
@@ -151,6 +152,7 @@ import { declaredMeta, wireMeta } from '../coordination/claimMeta.js';
151
152
  import type { Claim, ClaimHeartbeat, ClaimHeartbeatOptions, HeldClaim } from '../types/streams.js';
152
153
  import type { CoordinationObservability } from '../observability.js';
153
154
  import { assertWriteOptions } from '../resources/writeOptionsSchema.js';
155
+ import { normalizeWhere } from '../resources/where.js';
154
156
  import {
155
157
  createDurableHttpCommitEnvelope,
156
158
  canonicalHttpCommitBody,
@@ -1493,9 +1495,12 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1493
1495
  async function listModel<T>(
1494
1496
  modelName: string,
1495
1497
  options?: ServerReadOptions<T>,
1496
- ): Promise<{ readonly data: readonly T[]; readonly evidence?: readonly { id: string; stamp: number }[] }> {
1498
+ ): Promise<HttpTransportList<T>> {
1497
1499
  const params = new URLSearchParams();
1498
1500
  if (options?.limit !== undefined) params.set('limit', String(options.limit));
1501
+ if (options?.cursor !== undefined) {
1502
+ params.set('cursor', options.cursor);
1503
+ }
1499
1504
  if (options?.orderBy) {
1500
1505
  const [col, dir] = Object.entries(options.orderBy)[0] ?? [];
1501
1506
  if (col) {
@@ -1503,12 +1508,27 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1503
1508
  if (dir === 'desc') params.set('order', 'desc');
1504
1509
  }
1505
1510
  }
1506
- // The collection route turns any non-reserved query param into an equality
1507
- // filter (`?status=todo`). The wire is AND-only equality matches what a
1508
- // stateless reactor needs; richer predicates stay on the stateful path.
1509
- if (options?.where && typeof options.where === 'object') {
1510
- for (const [k, v] of Object.entries(options.where as Record<string, unknown>)) {
1511
- if (v !== undefined && v !== null && typeof v !== 'object') params.set(k, String(v));
1511
+ // The whole filter travels as the canonical clause list, so every operator
1512
+ // the grammar declares survives the trip. The previous encoding walked the
1513
+ // object's own entries and skipped any value that was an object, which
1514
+ // silently discarded `IN` filters and every tuple-form clause: the request
1515
+ // went out unfiltered and the caller read the result as a filtered one.
1516
+ const clauses = normalizeWhere(options?.where);
1517
+ if (clauses.length > 0) {
1518
+ params.set('where', JSON.stringify(clauses));
1519
+ // Plain equality also goes out in the older `?column=value` shorthand.
1520
+ // A server that predates the `where` parameter does not reserve the name
1521
+ // and does not declare it as a column, so it skips it — and a dropped
1522
+ // filter is not an error there, it is a wider answer than the caller
1523
+ // asked for. Sending both means a version skew loses only the operator
1524
+ // filters, which that server could not have honoured anyway. A current
1525
+ // server ignores a shorthand key the clause list already carries.
1526
+ for (const clause of clauses) {
1527
+ const [column] = clause;
1528
+ const value = clause.length === 2 ? clause[1] : clause[2];
1529
+ if (clause.length === 3 && clause[1] !== '=') continue;
1530
+ if (value === null || typeof value === 'object') continue;
1531
+ params.set(column, String(value));
1512
1532
  }
1513
1533
  }
1514
1534
  const qs = params.toString();
@@ -1523,6 +1543,8 @@ export function createHttpTransport(options: HttpTransportOptions): HttpTranspor
1523
1543
  // the typed facade above, which holds the model's schema.
1524
1544
  return {
1525
1545
  data: res.data as T[],
1546
+ hasMore: res.has_more,
1547
+ nextCursor: res.next_cursor,
1526
1548
  ...(res.evidence ? { evidence: res.evidence } : {}),
1527
1549
  };
1528
1550
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import { z } from 'zod';
17
+ import { listEnvelopeSchema } from './listEnvelope.js';
17
18
  // Composed, never restated: these are the artifact's own shapes, and a
18
19
  // hand-written mirror here would be a second copy of the exact record this
19
20
  // response exists to stop withholding.
@@ -45,16 +46,9 @@ export type ProjectResponse = z.infer<typeof projectResponseSchema>;
45
46
  /**
46
47
  * `GET /v1/projects`.
47
48
  *
48
- * Note what this is NOT: the canonical {@link ListEnvelope}. An org's projects
49
- * are returned whole, so the route emits `data` with no `has_more`/`next_cursor`
50
- * beside it. That divergence is real and this schema states it rather than
51
- * describing a pagination the endpoint does not implement — adding the two
52
- * fields is an API change, not a documentation fix.
49
+ * Uses the canonical paginated list envelope shared by every collection.
53
50
  */
54
- export const projectListResponseSchema = z.object({
55
- object: z.literal('list'),
56
- data: z.array(projectResponseSchema).readonly(),
57
- });
51
+ export const projectListResponseSchema = listEnvelopeSchema(projectResponseSchema);
58
52
  export type ProjectListResponse = z.infer<typeof projectListResponseSchema>;
59
53
 
60
54
  /** One minted key, returned exactly once — the plaintext is never readable again. */
@@ -121,7 +121,13 @@ export const listQuerySchema = z.object({
121
121
  limit: z.string().optional(),
122
122
  order_by: z.string().optional(),
123
123
  order: z.enum(['asc', 'desc']).optional(),
124
- /** Keyset cursor: the id of the last row from the previous page. */
124
+ /**
125
+ * Keyset cursor: the opaque `next_cursor` the previous page returned. It
126
+ * encodes the sort position it was issued for, so it is not a row id and is
127
+ * refused against a different `order_by`/`order`.
128
+ */
129
+ cursor: z.string().optional(),
130
+ /** @deprecated The pre-0.53.0 spelling of `cursor`. Send `cursor`. */
125
131
  starting_after: z.string().optional(),
126
132
  });
127
133
  export type ListQuery = z.infer<typeof listQuerySchema>;
package/src/wire/index.ts CHANGED
@@ -22,7 +22,13 @@ export {
22
22
  INTERNAL_ERROR_PUBLIC_MESSAGE,
23
23
  } from './errorEnvelope.js';
24
24
  export type { ErrorEnvelope } from './errorEnvelope.js';
25
- export { listEnvelopeSchema, listEnvelope } from './listEnvelope.js';
25
+ export {
26
+ listEnvelopeSchema,
27
+ listEnvelope,
28
+ CURSOR_PARAM,
29
+ CURSOR_PARAM_ALIAS,
30
+ CURSOR_PARAM_NAMES,
31
+ } from './listEnvelope.js';
26
32
  export type { ListEnvelope } from './listEnvelope.js';
27
33
 
28
34
  // The `GET /v1/logs` feed — the two arms, their envelope, and the cursor that
@@ -10,6 +10,29 @@
10
10
  */
11
11
  import { z } from 'zod';
12
12
 
13
+ /**
14
+ * The query parameter a caller resumes a collection with, and the single alias
15
+ * retained for it.
16
+ *
17
+ * Declared beside the envelope that issues `next_cursor`, because the parameter
18
+ * and the field it consumes are one contract and both planes read them: the
19
+ * server to know which names are taken, the spec generator to document them,
20
+ * the client to send one. When the name lived in each of those separately, this
21
+ * spec described `starting_after` for a model list and `cursor` for a commit
22
+ * record list — the same concept under two names, in one document.
23
+ *
24
+ * `starting_after` is the spelling used through 0.52.0. It borrowed a name whose
25
+ * established meaning elsewhere is a row id, while this value has always been an
26
+ * opaque token bound to the sort it was issued for.
27
+ */
28
+ export const CURSOR_PARAM = 'cursor';
29
+
30
+ /** @deprecated The pre-0.53.0 spelling of {@link CURSOR_PARAM}. */
31
+ export const CURSOR_PARAM_ALIAS = 'starting_after';
32
+
33
+ /** Both names a cursor may arrive under, for reserving them in a query string. */
34
+ export const CURSOR_PARAM_NAMES: readonly string[] = [CURSOR_PARAM, CURSOR_PARAM_ALIAS];
35
+
13
36
  /**
14
37
  * Builds the authoritative list-envelope schema for a row schema. Keeping the
15
38
  * item validator generic lets every endpoint share the same envelope contract