@object-ui/data-objectstack 17.6.0 → 17.7.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.
package/README.md CHANGED
@@ -23,6 +23,9 @@ npm install @object-ui/data-objectstack
23
23
  ```typescript
24
24
  import { createObjectStackAdapter } from '@object-ui/data-objectstack';
25
25
  import { SchemaRenderer } from '@object-ui/react';
26
+ import type { DeclaredNode } from '@object-ui/types';
27
+
28
+ declare const mySchema: DeclaredNode;
26
29
 
27
30
  // 1. Create the adapter
28
31
  const dataSource = createObjectStackAdapter({
@@ -44,6 +47,8 @@ function App() {
44
47
  ### Advanced Configuration
45
48
 
46
49
  ```typescript
50
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
51
+
47
52
  const dataSource = createObjectStackAdapter({
48
53
  baseUrl: 'https://api.example.com',
49
54
  token: 'your-api-token',
@@ -71,15 +76,367 @@ const dataSource = createObjectStackAdapter({
71
76
  - ✅ **Auto-Reconnect**: Automatic reconnection with exponential backoff on connection failures.
72
77
  - ✅ **Batch Progress**: Progress events for tracking bulk operation status.
73
78
 
79
+ ## Query Translation
80
+
81
+ `find()` accepts Object UI's OData-style `QueryParams` and translates them into
82
+ ObjectStack's native query format, so a schema never has to be written in the
83
+ protocol's own shape:
84
+
85
+ ```typescript
86
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
87
+
88
+ declare const dataSource: ObjectStackAdapter;
89
+
90
+ // Query with filters (MongoDB-like operators)
91
+ const result = await dataSource.find('tasks', {
92
+ $filter: {
93
+ status: 'active',
94
+ priority: { $gte: 2 },
95
+ },
96
+ $orderby: { createdAt: 'desc' },
97
+ $top: 20,
98
+ $skip: 0,
99
+ });
100
+
101
+ // Escape hatch: reach the underlying ObjectStack client for anything
102
+ // the DataSource interface does not cover
103
+ const client = dataSource.getClient();
104
+ const metadata = await client.meta.getItem('object', 'task');
105
+ ```
106
+
107
+ ### Query Parameter Mapping
108
+
109
+ | Object UI (`$`) | ObjectStack | Description |
110
+ |--------------|-------------|-------------|
111
+ | `$select` | `select` | Field selection |
112
+ | `$filter` | `filters` (AST) | Filter conditions (converted to FilterNode AST) |
113
+ | `$orderby` | `sort` | Sort order |
114
+ | `$skip` | `skip` | Pagination offset |
115
+ | `$top` | `top` | Limit records |
116
+
117
+ ### Filter Conversion
118
+
119
+ The adapter converts MongoDB-like filter operators into **ObjectStack FilterNode
120
+ AST format**. This is what keeps it compatible with the ObjectStack Protocol
121
+ (v0.1.2+).
122
+
123
+ #### Supported Filter Operators
124
+
125
+ Every row below is decided by `@object-ui/core`'s `convertFiltersToAST`, and
126
+ this package's `src/readme-filter-operator-table.test.ts` runs each worked
127
+ example through it on every test run, so a row cannot drift from the code
128
+ unnoticed again (objectui#8558). Every spelling below is the spec's own
129
+ (`FILTER_OPERATORS` in `@objectstack/spec`'s `data/filter.zod.ts`) and there are
130
+ no aliases: the four lowercase spellings this table used to list beside the
131
+ camelCase keys — `$notin`, `$notcontains`, `$startswith`, `$endswith` — were
132
+ retired by objectui#8568 and moved to the refused table below.
133
+
134
+ `$icontains` is the case-insensitive member of the `$contains` family, and its
135
+ ObjectStack spelling is the SAME word: `icontains` is itself a member of the
136
+ spec's `VALID_AST_OPERATORS`, so nothing is squashed on the way down. It had no
137
+ row here at all until objectui#8976 — `convertFiltersToAST` refused it as an
138
+ unknown operator while `ValueDataSource` executed it and this repo's own filter
139
+ builder emitted it. The pin now holds these two tables complete against the
140
+ spec's `FILTER_OPERATORS` as well as against the code, so a canonical operator
141
+ that is documented NEITHER as supported NOR as refused fails the suite instead
142
+ of going unnoticed.
143
+
144
+ | MongoDB Operator | ObjectStack Operator | Example |
145
+ |------------------|---------------------|---------|
146
+ | plain value (no operator) | `=` | `{ status: 'active' }` → `['status', '=', 'active']` |
147
+ | `$eq` | `=` | `{ status: { $eq: 'active' } }` → `['status', '=', 'active']` |
148
+ | `$ne` | `!=` | `{ status: { $ne: 'archived' } }` → `['status', '!=', 'archived']` |
149
+ | `$gt` | `>` | `{ age: { $gt: 18 } }` → `['age', '>', 18]` |
150
+ | `$gte` | `>=` | `{ age: { $gte: 18 } }` → `['age', '>=', 18]` |
151
+ | `$lt` | `<` | `{ age: { $lt: 65 } }` → `['age', '<', 65]` |
152
+ | `$lte` | `<=` | `{ age: { $lte: 65 } }` → `['age', '<=', 65]` |
153
+ | `$in` | `in` | `{ status: { $in: ['active', 'pending'] } }` → `['status', 'in', ['active', 'pending']]` |
154
+ | `$nin` | `nin` | `{ status: { $nin: ['archived'] } }` → `['status', 'nin', ['archived']]` |
155
+ | `$between` | `between` | `{ age: { $between: [18, 65] } }` → `['age', 'between', [18, 65]]` |
156
+ | `$contains` | `contains` | `{ name: { $contains: 'John' } }` → `['name', 'contains', 'John']` |
157
+ | `$notContains` | `notcontains` | `{ name: { $notContains: 'test' } }` → `['name', 'notcontains', 'test']` |
158
+ | `$startsWith` | `startswith` | `{ email: { $startsWith: 'admin' } }` → `['email', 'startswith', 'admin']` |
159
+ | `$endsWith` | `endswith` | `{ email: { $endsWith: '@example.com' } }` → `['email', 'endswith', '@example.com']` |
160
+ | `$icontains` | `icontains` | `{ name: { $icontains: 'john' } }` → `['name', 'icontains', 'john']` |
161
+ | `$null` | `is_null` / `is_not_null` | `{ email: { $null: true } }` → `['email', 'is_null', true]` |
162
+ | `$exists` | `is_not_null` / `is_null` | `{ email: { $exists: true } }` → `['email', 'is_not_null', true]` |
163
+ | `$empty` | `is_empty` / `is_not_empty` | `{ email: { $empty: true } }` → `['email', 'is_empty', true]` |
164
+
165
+ `$null`, `$exists` and `$empty` read their boolean: `$null: false` lowers to
166
+ `is_not_null`, `$exists: false` to `is_null` and `$empty: false` to
167
+ `is_not_empty`. The lowered node's value slot is always `true` — the direction
168
+ comes from the operator name, which is how the spec's `data/filter.zod.ts`
169
+ reads it. `$empty` takes ONLY a boolean: any other flag throws `INVALID_FILTER`
170
+ / 400 at lowering time, which is how the spec's own doors treat it.
171
+
172
+ `$empty` is not `$null`. objectstack#20446 admitted it to the spec's
173
+ `FILTER_OPERATORS` and flipped the view operators `is_empty` / `is_not_empty`
174
+ to lower to it; they lowered to `$null` before. Its meaning is the spec's ruled
175
+ per-type table: a text-like field is empty when null or `''`, a multi-value
176
+ field when null or `[]`, and any other field when null. Which backend applies
177
+ that table, and how, is listed in the docblock of the spec's
178
+ `FILTER_OPERATORS`, not here. Until objectui#11094 `convertFiltersToAST`
179
+ refused `$empty` as an unknown operator.
180
+
181
+ `$icontains` constrains its **comparand**, which no other row in this table
182
+ does: `@objectstack/spec`'s `FILTER_TEXT_CASES` declares an empty or non-string
183
+ comparand REFUSED, so an empty box or an uncoerced number throws
184
+ `INVALID_FILTER` / 400 at lowering time instead of sending a predicate that
185
+ constrains nothing or a question nobody wrote (objectui#9001). The operator is
186
+ supported exactly as the row says — write a non-empty string, or drop the
187
+ condition.
188
+
189
+ #### Logical combinators
190
+
191
+ `$and` and `$or` take an array of filter conditions and lower to an AST group
192
+ node whose members are lowered with the table above. Sibling keys of one
193
+ object are still combined with `and` (see "Complex Filter Examples" below). An
194
+ empty `$and` is TRUE and is dropped rather than emitted as an empty group —
195
+ `['and']` is not a filter, so emitting it would widen the result set — and an
196
+ empty `$or` is FALSE and matches no row.
197
+
198
+ | MongoDB Key | AST Keyword | Example |
199
+ |-------------|-------------|---------|
200
+ | `$and` | `and` | `{ $and: [{ status: 'open' }, { priority: { $gte: 2 } }] }` → `['and', ['status', '=', 'open'], ['priority', '>=', 2]]` |
201
+ | `$or` | `or` | `{ $or: [{ status: 'open' }, { status: 'blocked' }] }` → `['or', ['status', '=', 'open'], ['status', '=', 'blocked']]` |
202
+
203
+ #### Refused at lowering time
204
+
205
+ These shapes throw a `FilterOperatorError` (`code: 'INVALID_FILTER'`,
206
+ `httpStatus: 400`) from `convertFiltersToAST` instead of reaching the wire. The
207
+ refusal names the field and the value, so a filter that carries one fails at
208
+ the call site rather than as a `400` from the server or as an empty list.
209
+
210
+ | Shape | Why | Example |
211
+ |-------|-----|---------|
212
+ | `$regex` | The spec has no `$regex`, and it is not downgraded to `contains`: a pattern match and a substring match are different questions, not stronger and weaker forms of one. Use `$contains` for a case-sensitive substring, `$icontains` for a case-insensitive one, or `$startsWith` / `$endsWith`. | `{ name: { $regex: '^J' } }` → throws `INVALID_FILTER` |
213
+ | `$not` | The AST has no negation keyword, and rewriting the negation inward would be silently partial. Use a negated operator instead: `$ne`, `$nin`, `$notContains`. | `{ $not: { status: 'open' } }` → throws `INVALID_FILTER` |
214
+ | a bare array as a field's value | The AST has no array-equality node, and the array is deliberately not read as `$in` (see below). | `{ tags: ['a', 'b'] }` → throws `INVALID_FILTER` |
215
+ | `$notin` / `$notcontains` / `$startswith` / `$endswith` | Retired lowercase aliases (objectui#8568). The `$` dialect follows `@objectstack/spec`'s spellings, and this repo's in-memory matcher already refused these; accepting them here made one authored filter behave differently depending on the data source behind the view. The refusal names the canonical spelling for the alias you wrote — rename the key, the operator is unchanged. | `{ email: { $startswith: 'a' } }` → throws `INVALID_FILTER` |
216
+ | any other `$` key in operator position | Unknown operator; the error message lists the supported ones. | `{ age: { $foo: 1 } }` → throws `INVALID_FILTER` |
217
+
218
+ A bare array as a field's value — `{ tags: ['a', 'b'] }` — is **refused** at
219
+ lowering time with a `FilterOperatorError` (`INVALID_FILTER` / 400) naming the
220
+ field and the comparand, not read as `$in` (objectui#8530): the ObjectQL AST
221
+ has no array-equality node, so `['tags', '=', ['a', 'b']]` was answered by a
222
+ `400` from the wire or an empty list from every in-memory matcher. Spell
223
+ membership as `{ tags: { $in: ['a', 'b'] } }` (or `$nin` for its negation).
224
+
225
+ An **exotic object** as a field's value — a `RegExp`, a `Set`, a `Map`, a `URL`,
226
+ any class instance — is refused the same way (objectui#8567). It has no row in
227
+ the table above because the table's examples are executed as JSON literals and
228
+ none of these shapes can be written as one; the refusal is no less real for
229
+ that. `{ name: /abc/ }` used to lower to **nothing at all**: `Object.entries` of
230
+ a `RegExp` is `[]`, so the operator loop ran zero times and the `name` condition
231
+ was dropped from the filter entirely, silently widening the result set. The spec
232
+ rules these out — a comparison value must be a string, number, bigint, boolean,
233
+ null or `Date` (`ACCEPTED_FILTER_COMPARAND_TYPES`), and
234
+ `normalizeFilterComparandTypes` answers `400 INVALID_FILTER` for a `RegExp` —
235
+ so the refusal now lands at lowering time instead of the field vanishing. Spell
236
+ a text match as `{ name: { $contains: 'abc' } }` (`$startsWith` / `$endsWith`), a
237
+ membership test as `{ name: { $in: [...] } }`, and a date bound as
238
+ `{ created: { $gte: someDate } }`. A `Date` comparand is **not** exotic: the spec
239
+ accepts it and it lowers as an equality node (objectui#8555). An **empty
240
+ operator object** — `{ created: {} }`, a field with no operator — is refused too,
241
+ alone or beside other keys, with a `FilterOperatorError` naming the field
242
+ (objectui#9164 alone, objectui#10788 beside other keys). `@objectstack/spec` records
243
+ `{ field: {} }` as REJECTED (objectstack#5240), and beside a key that lowers it
244
+ used to be dropped the same silent way: `{ status: 'a', created: {} }` sent
245
+ exactly what `{ status: 'a' }` sends. Give the field an operator (`$eq`, `$in`,
246
+ `$null`, …) or remove the key.
247
+
248
+ #### Complex Filter Examples
249
+
250
+ **Multiple conditions** are combined with `'and'`:
251
+
252
+ ```typescript
253
+ // Input
254
+ const $filter = {
255
+ age: { $gte: 18, $lte: 65 },
256
+ status: 'active',
257
+ };
258
+
259
+ // Converted to AST
260
+ const ast = [
261
+ 'and',
262
+ ['age', '>=', 18],
263
+ ['age', '<=', 65],
264
+ ['status', '=', 'active'],
265
+ ];
266
+ ```
267
+
268
+ #### Rule-shaped arrays, on `find()` **and** `aggregate()`
269
+
270
+ Server-driven view configs store their conditions as an array of rules
271
+ (`ViewFilterRule[]`), not as a MongoDB-style object:
272
+
273
+ ```typescript
274
+ const filter = [{ field: 'stage', operator: 'equals', value: 'won' }];
275
+ ```
276
+
277
+ Both read paths lower that array to the same AST before it reaches the wire —
278
+ `find()` via `$filter`, and `aggregate()` via the analytics `where`. They share
279
+ one translator, so a stored filter cannot mean one thing on a list and another
280
+ on a KPI:
281
+
282
+ ```typescript
283
+ // find(): filter=["stage","=","won"]
284
+ // aggregate(): { ..., where: ["stage", "=", "won"] }
285
+ ```
286
+
287
+ Operator aliases (`equals`, `greater_than_or_equal`, `not_in`, `before`, ...)
288
+ map to the canonical AST symbols, and rules spread into a logical node
289
+ (`['and', ...rules, ...tuples]`) are lowered at depth. A rule that cannot be
290
+ translated raises `MalformedFilterError` rather than being dropped — dropping
291
+ one condition of an `and` would widen the result set and report success.
292
+
293
+ An `icontains` rule constrains its **comparand** here exactly as `$icontains`
294
+ does in the object form: `@objectstack/spec`'s `FILTER_TEXT_CASES` declares an
295
+ empty or non-string comparand REFUSED, so a rule such as
296
+ `{ field: 'name', operator: 'icontains', value: '' }` — or one whose `value` is
297
+ a number, `null` or missing — throws `MalformedFilterError` (`INVALID_FILTER` /
298
+ 400) before any request is sent, instead of sending a predicate that constrains
299
+ nothing or a question nobody wrote (objectui#9048). The reason is the spec's
300
+ own, the one the object form's refusal carries (objectui#9001), and the message
301
+ names the operator spelling that arrived (`icontains`, `ICONTAINS`, …) beside
302
+ the `$icontains` spelling the contract uses. The rule is refused wherever it
303
+ sits — at the top level, in a nested group, or spread into a logical node — and
304
+ on both `find()` and `aggregate()`. Write a non-empty string, or drop the rule.
305
+
306
+ Non-array filters are passed through unchanged on the aggregate path: a
307
+ MongoDB-style object is already what `/analytics/query` accepts.
308
+
309
+ #### `aggregate({ where })` does NOT lower — it refuses
310
+
311
+ The lowering above is the `filter` parameter, on the analytics path.
312
+ `aggregate()` has a second, spec-shape branch — entered when the params carry
313
+ an array `groupBy`, an array `aggregations`, or **any** `where` key — which
314
+ posts `where` to `POST /data/:object/query` verbatim. That `where` is the spec
315
+ Query DSL's `where`, so it must ALREADY be lowered, and since objectui#6825 an
316
+ array that the spec's own `isFilterAST` gate rejects is refused here rather
317
+ than shipped:
318
+
319
+ ```typescript
320
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
321
+
322
+ declare const dataSource: ObjectStackAdapter;
323
+
324
+ // ✅ lowered — reaches the wire unchanged
325
+ await dataSource.aggregate('opportunity', {
326
+ groupBy: ['stage'],
327
+ aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
328
+ where: ['stage', '=', 'won'],
329
+ });
330
+
331
+ // ⛔ throws UnloweredAggregateWhereError — authoring sugar, not a filter
332
+ await dataSource.aggregate('opportunity', {
333
+ groupBy: ['stage'],
334
+ aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
335
+ where: [{ field: 'stage', operator: 'equals', value: 'won' }],
336
+ });
337
+ ```
338
+
339
+ This is a producer-side refusal of a value the server already refused (`is not
340
+ a filter`, `400 INVALID_FILTER`): the point is that you now find out at the call
341
+ site, with the offending value named, instead of on the wire — or not at all,
342
+ with a chart quietly rendering unfiltered numbers. Lower the rules in whatever
343
+ built the params, or use the analytics branch (`filter` + the legacy `field` /
344
+ `function` / `groupBy` params), which lowers for you.
345
+
346
+ Two shapes are deliberately NOT refused, because the receiving door accepts
347
+ them: a `FilterCondition` object (`{ stage: 'won' }` — what `QuerySchema.where`
348
+ declares), and an empty array (`[]` means "no filter").
349
+
350
+ #### The two shapes are alternatives, not a mixture
351
+
352
+ The spec-shape branch reads exactly four keys — `groupBy`, `aggregations`,
353
+ `where`, `limit` — and `filter` / `field` / `function` are the **analytics**
354
+ branch's own parameters. Until objectui#6864 they were neither read nor
355
+ reported on the spec-shape branch: they simply were not in the body that went
356
+ out. Since #6864 they are refused, by name:
357
+
358
+ ```typescript
359
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
360
+
361
+ declare const dataSource: ObjectStackAdapter;
362
+
363
+ // ⛔ throws AnalyticsKeysOnSpecShapeError — an ARRAY `groupBy` selects the
364
+ // spec-shape branch, and these three keys would have vanished from the query:
365
+ // no filter, and no measure at all.
366
+ await dataSource.aggregate('opportunity', {
367
+ field: 'amount',
368
+ function: 'sum',
369
+ groupBy: ['stage'],
370
+ filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
371
+ });
372
+
373
+ // ✅ the analytics shape, unchanged — a STRING `groupBy` keeps this call on the
374
+ // analytics branch, where all three keys are read and `filter` is lowered for you
375
+ await dataSource.aggregate('opportunity', {
376
+ field: 'amount',
377
+ function: 'sum',
378
+ groupBy: 'stage',
379
+ filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
380
+ });
381
+ ```
382
+
383
+ Pick one shape per call. A key that is present but nullish (`filter: undefined`)
384
+ carries nothing to drop and is not refused, so params built by spreading
385
+ possibly-absent authored values keep working. Keys outside those three are not
386
+ refused either — the gate names `filter`, `field` and `function`, and only
387
+ those.
388
+
389
+ ### Group headers for server-side grid grouping
390
+
391
+ `queryGroupHeaders(resource, query)` answers a grouped list view's **group
392
+ header query** — the `EngineAggregateOptions` that `@objectstack/spec/ui`'s
393
+ `compileListViewGroupQuery` compiles from the view's `grouping` (and its column
394
+ summaries). It is how a grouped `object-grid` gets its group set and every
395
+ header number from the server instead of bucketing a fetched page
396
+ (objectui#7189): one row per group, each grouped field under its own name
397
+ holding the raw stored value (`null` for the empty group), and the group's
398
+ total row count under `count`.
399
+
400
+ The query is posted **verbatim** to the existing `POST /data/:object/query`
401
+ door (the same one the spec-shape `aggregate()` branch uses), and the answer is
402
+ its `records`. Unlike `aggregate()`, it never degrades to a client-side
403
+ reduction: a refusal is thrown with the server's code and status, and an answer
404
+ without a `records` array is refused rather than read as "no groups". The rows
405
+ inside a group are an ordinary `find()` with the group's compiled row query
406
+ (`compileListViewGroupRowsQuery`).
407
+
408
+ ### Sorting
409
+
410
+ ```typescript
411
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
412
+
413
+ declare const dataSource: ObjectStackAdapter;
414
+
415
+ // OData-style
416
+ await dataSource.find('users', {
417
+ $orderby: {
418
+ createdAt: 'desc',
419
+ name: 'asc',
420
+ },
421
+ });
422
+
423
+ // Converted to ObjectStack: ['-createdAt', 'name']
424
+ ```
425
+
74
426
  ## Metadata Caching
75
427
 
76
428
  The adapter includes built-in metadata caching to improve performance when fetching schemas:
77
429
 
78
430
  ```typescript
431
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
432
+
433
+ const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
434
+
79
435
  // Get cache statistics
80
436
  const stats = dataSource.getCacheStats();
81
437
  console.log(`Cache hit rate: ${stats.hitRate * 100}%`);
82
438
  console.log(`Cache size: ${stats.size}/${stats.maxSize}`);
439
+ console.log(`Fetches coalesced onto an in-flight request: ${stats.coalesced}`);
83
440
 
84
441
  // Manually invalidate cache entries
85
442
  dataSource.invalidateCache('users'); // Invalidate specific schema
@@ -95,13 +452,23 @@ dataSource.clearCache();
95
452
  - **TTL Expiration**: Entries expire after the configured time-to-live from creation (default: 5 minutes)
96
453
  - Note: TTL is fixed from creation time, not sliding based on access
97
454
  - **Memory Limits**: Configurable maximum cache size (default: 100 entries)
98
- - **Concurrent Access**: Handles async operations safely. Note that concurrent requests for the same uncached key may result in multiple fetcher calls.
455
+ - **Request Coalescing**: Concurrent `get` calls for the same uncached key share a single
456
+ fetch. The first caller invokes the fetcher; every caller that arrives while that promise
457
+ is still in flight is handed the same promise instead of starting a second request, and
458
+ each one increments the `coalesced` counter in `getCacheStats()` — so the saving is
459
+ something you can read off the adapter, not just a claim in this page.
460
+ - The in-flight slot is released in a `finally`, so a rejected fetch is not cached and
461
+ does not poison the key: the next call starts a fresh fetch.
99
462
 
100
463
  ## Connection State Monitoring
101
464
 
102
465
  The adapter provides real-time connection state monitoring with automatic reconnection:
103
466
 
104
467
  ```typescript
468
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
469
+
470
+ const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
471
+
105
472
  // Monitor connection state changes
106
473
  const unsubscribe = dataSource.onConnectionStateChange((event) => {
107
474
  console.log('Connection state:', event.state);
@@ -146,6 +513,12 @@ The adapter automatically attempts to reconnect on connection failures:
146
513
  Track progress of bulk operations in real-time:
147
514
 
148
515
  ```typescript
516
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
517
+
518
+ const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
519
+
520
+ declare const largeDataset: Array<Record<string, unknown>>;
521
+
149
522
  // Monitor batch operation progress
150
523
  const unsubscribe = dataSource.onBatchProgress((event) => {
151
524
  console.log(`${event.operation}: ${event.percentage.toFixed(1)}%`);
@@ -187,30 +560,60 @@ import {
187
560
  // SYMBOL is prefixed because @objectstack/spec/kernel
188
561
  // owns `ValidationError` for a { field, message, code? }
189
562
  // record (objectui#3160).
563
+ MalformedFilterError, // A filter rule that cannot be translated (400
564
+ // INVALID_FILTER) — thrown rather than dropped,
565
+ // because dropping one condition of an `and` widens
566
+ // the result set and reports success. Also thrown,
567
+ // before any request, for an `icontains` rule whose
568
+ // `value` is empty or not a string, naming the
569
+ // spelling that arrived. See "Rule-shaped arrays".
570
+ UnloweredAggregateWhereError, // aggregate()'s spec-shape `where` was an array
571
+ // the spec's filter-AST gate rejects (400
572
+ // INVALID_FILTER). See "aggregate({ where }) does
573
+ // NOT lower" above.
574
+ AnalyticsKeysOnSpecShapeError, // aggregate()'s spec-shape branch was handed the
575
+ // ANALYTICS branch's keys (`filter` / `field` /
576
+ // `function`), which it does not read (400
577
+ // INVALID_FILTER). See "the two shapes are
578
+ // alternatives, not a mixture" above.
579
+ isMalformedFilterError, // Recognises ALL THREE of the above, and the server's
580
+ // own version of the same refusal.
190
581
  } from '@object-ui/data-objectstack';
191
582
  ```
192
583
 
193
584
  ### Error Handling Example
194
585
 
195
586
  ```typescript
587
+ import {
588
+ ObjectStackError,
589
+ MetadataNotFoundError,
590
+ ConnectionError,
591
+ AuthenticationError,
592
+ type ObjectStackAdapter,
593
+ } from '@object-ui/data-objectstack';
594
+
595
+ declare const dataSource: ObjectStackAdapter;
596
+
196
597
  try {
197
598
  const schema = await dataSource.getObjectSchema('users');
198
599
  } catch (error) {
199
600
  if (error instanceof MetadataNotFoundError) {
200
- console.error(`Schema not found: ${error.details.objectName}`);
601
+ console.error(`Schema not found: ${error.details?.objectName}`);
201
602
  } else if (error instanceof ConnectionError) {
202
603
  console.error(`Connection failed to: ${error.url}`);
203
604
  } else if (error instanceof AuthenticationError) {
204
605
  console.error('Authentication required');
205
606
  }
206
-
207
- // All errors have consistent structure
208
- console.error({
209
- code: error.code,
210
- message: error.message,
211
- statusCode: error.statusCode,
212
- details: error.details
213
- });
607
+
608
+ // Every error this adapter throws carries the same shape
609
+ if (error instanceof ObjectStackError) {
610
+ console.error({
611
+ code: error.code,
612
+ message: error.message,
613
+ statusCode: error.statusCode,
614
+ details: error.details,
615
+ });
616
+ }
214
617
  }
215
618
  ```
216
619
 
@@ -219,6 +622,12 @@ try {
219
622
  Bulk operations provide detailed error reporting with partial success information:
220
623
 
221
624
  ```typescript
625
+ import { BulkOperationError, type ObjectStackAdapter } from '@object-ui/data-objectstack';
626
+
627
+ declare const dataSource: ObjectStackAdapter;
628
+
629
+ declare const records: Array<Record<string, unknown>>;
630
+
222
631
  try {
223
632
  await dataSource.bulk('users', 'update', records);
224
633
  } catch (error) {
@@ -244,6 +653,9 @@ All errors include unique error codes for programmatic handling:
244
653
  - `CONNECTION_ERROR` - Connection/network error
245
654
  - `AUTHENTICATION_ERROR` - Authentication failure
246
655
  - `VALIDATION_ERROR` - Data validation error
656
+ - `INVALID_FILTER` - A request the adapter refuses to send (`MalformedFilterError`,
657
+ `UnloweredAggregateWhereError`, `AnalyticsKeysOnSpecShapeError`); matches the data
658
+ API's own code for the same refusal
247
659
  - `UNSUPPORTED_OPERATION` - Unsupported operation
248
660
  - `NOT_FOUND` - Resource not found
249
661
  - `UNKNOWN_ERROR` - Unknown error
@@ -253,6 +665,10 @@ All errors include unique error codes for programmatic handling:
253
665
  The adapter supports optimized batch operations with automatic fallback:
254
666
 
255
667
  ```typescript
668
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
669
+
670
+ declare const dataSource: ObjectStackAdapter;
671
+
256
672
  // Batch create
257
673
  const newUsers = await dataSource.bulk('users', 'create', [
258
674
  { name: 'Alice', email: 'alice@example.com' },
@@ -286,6 +702,10 @@ writes as a single all-or-nothing unit — the master-detail case, where a paren
286
702
  and its children must commit or roll back together — use `batchTransaction`:
287
703
 
288
704
  ```typescript
705
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
706
+
707
+ declare const dataSource: ObjectStackAdapter;
708
+
289
709
  // Create a parent and a child that references it, atomically.
290
710
  // `{ $ref: 0 }` resolves to the id minted by operation 0 (the parent).
291
711
  await dataSource.batchTransaction([
@@ -341,6 +761,115 @@ still succeeds, but non-atomically via the fallback above. Treat the advertised
341
761
  capability as the floor for the atomicity guarantee, not as a connection
342
762
  prerequisite.
343
763
 
764
+ ## Object-Metadata Write Guard
765
+
766
+ `MetadataClient.save` refuses an `object` document whose `fields` carry either
767
+ of two incomplete fields, **before** issuing the request:
768
+
769
+ - a relationship field (`lookup`, `master_detail`) with a missing, empty or
770
+ whitespace-only `reference`;
771
+ - a choice field (`select`, `radio`) with no option source: neither a non-empty
772
+ `options` list nor a shared `picklist`. `options: []` counts as none.
773
+
774
+ ```ts
775
+ import { MetadataClient } from '@object-ui/data-objectstack';
776
+
777
+ const client = new MetadataClient({ baseUrl: '/api/v1' });
778
+
779
+ await client.save('object', 'account', {
780
+ name: 'account',
781
+ fields: { owner: { type: 'lookup', label: 'Owner' } },
782
+ });
783
+ // throws: MetadataClient.save refused this object metadata write: the field
784
+ // `owner` is a `lookup` and carries no `reference` key at all ...
785
+ ```
786
+
787
+ For the relationship rule, nothing that previously succeeded now fails.
788
+ `@objectstack/spec` refuses the same document at the server with a 422 on
789
+ `fields.owner.reference`, and that refusal blocks every *later* save of the
790
+ object for as long as the half-filled field rides along in the draft. The guard
791
+ moves the identical refusal earlier, names the field while it is still on screen,
792
+ and leaves the draft in the client.
793
+
794
+ The choice rule is different, and on purpose: it holds a document the installed
795
+ server still stores. It is the objectui half of the maintainer's ruling A on
796
+ [objectstack#20827](https://github.com/objectstack-ai/objectstack/issues/20827),
797
+ which refuses such a field at the `FieldSchema` door and has objectui stop
798
+ sending it first. A `select` added in Studio, and saved before its first option,
799
+ now waits in the client, with the field named, until it has one:
800
+
801
+ ```ts
802
+ import { MetadataClient } from '@object-ui/data-objectstack';
803
+
804
+ const client = new MetadataClient({ baseUrl: '/api/v1' });
805
+
806
+ await client.save('object', 'deal', {
807
+ name: 'deal',
808
+ fields: { stage: { type: 'select', label: 'Stage', options: [] } },
809
+ });
810
+ // throws: MetadataClient.save refused this object metadata write: the field
811
+ // `stage` is a `select` with no options ...
812
+ ```
813
+
814
+ Writes of every other metadata type are untouched, and the guard never strips
815
+ the offending field — a dropped field reported as saved would be a silent
816
+ deletion.
817
+
818
+ Hosts that write object metadata through their own transport can apply the same
819
+ invariant at their own door:
820
+
821
+ ```ts
822
+ import { assertObjectMetadataWritable } from '@object-ui/data-objectstack';
823
+
824
+ async function uploadObject(name: string, body: unknown) {
825
+ assertObjectMetadataWritable('object', body, 'uploadObject');
826
+ await fetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, {
827
+ method: 'PUT',
828
+ headers: { 'Content-Type': 'application/json' },
829
+ body: JSON.stringify(body),
830
+ });
831
+ }
832
+ ```
833
+
834
+ `RELATIONSHIP_TYPES_REQUIRING_REFERENCE`, `CHOICE_TYPES_REQUIRING_OPTIONS` and
835
+ `OBJECT_METADATA_TYPE` are exported beside it. Both type sets are derived from
836
+ the installed `@objectstack/spec` by this package's own pin (the choice set from
837
+ its `field/choice-without-options` completeness rule), so they follow the
838
+ contract rather than a remembered list.
839
+
840
+ ## Showing a Refused Metadata Save
841
+
842
+ When the server's spec check refuses a metadata save, it answers `422
843
+ INVALID_METADATA`. The error's `message` is a headline: a count of issues and
844
+ where they are. What the author should do is in the structured issues, which
845
+ `MetadataClient` puts on the thrown error's `issues`. Show the error through
846
+ `formatMetadataError`, not through `message` alone:
847
+
848
+ ```ts
849
+ import { MetadataClient, formatMetadataError } from '@object-ui/data-objectstack';
850
+
851
+ const client = new MetadataClient({ baseUrl: '/api/v1' });
852
+
853
+ try {
854
+ await client.save('object', 'account', {
855
+ name: 'account',
856
+ fields: { 'Bad Name': { type: 'text', label: 'Bad' } },
857
+ });
858
+ } catch (err) {
859
+ showBanner(formatMetadataError(err));
860
+ // • fields.Bad Name — Invalid key in record
861
+ // • fields.Bad Name — Field names must be lowercase snake_case (e.g., "first_name", …)
862
+ }
863
+
864
+ declare function showBanner(text: string): void;
865
+ ```
866
+
867
+ It writes one line per issue, naming the field, and falls back to the error's
868
+ `message` when there are no issues. Render the text with a class that keeps
869
+ newlines (`whitespace-pre-line`). `formatMetadataIssue(issue)` is the same
870
+ one-line format for a single issue, for code that lists several failures, such
871
+ as a publish response's `failed[]`.
872
+
344
873
  ## User-Scoped State Adapter
345
874
 
346
875
  In addition to the main `DataSource` adapter, this package ships
@@ -348,9 +877,12 @@ In addition to the main `DataSource` adapter, this package ships
348
877
  persist per-user UI state (favorites, recent items) into ObjectStack.
349
878
 
350
879
  ```typescript
351
- import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
880
+ import { createObjectStackUserStateAdapter, type ObjectStackAdapter } from '@object-ui/data-objectstack';
352
881
  import { useAttachUserStateAdapters } from '@object-ui/app-shell';
353
882
 
883
+ declare const dataSource: ObjectStackAdapter;
884
+ declare const user: { id: string };
885
+
354
886
  const favoritesAdapter = createObjectStackUserStateAdapter({
355
887
  dataSource, // the ObjectStack DataSource
356
888
  userId: user.id,
@@ -359,6 +891,8 @@ const favoritesAdapter = createObjectStackUserStateAdapter({
359
891
  // onError: (op, err) => console.warn(`[user-state] ${op} failed`, err),
360
892
  });
361
893
 
894
+ const attach = useAttachUserStateAdapters();
895
+
362
896
  attach('favorites', favoritesAdapter);
363
897
  ```
364
898
 
@@ -393,6 +927,8 @@ for the full design.
393
927
 
394
928
  #### Constructor
395
929
 
930
+ <!-- doc-snippet: fragment — the constructor SIGNATURE in prose notation, not a statement: `new ObjectStackAdapter(config: { ... })` names the parameter type where a call would carry a value. Measured TS1005x10. -->
931
+
396
932
  ```typescript
397
933
  new ObjectStackAdapter(config: {
398
934
  baseUrl: string;
@@ -446,6 +982,10 @@ new ObjectStackAdapter(config: {
446
982
  #### Schema Not Found
447
983
 
448
984
  ```typescript
985
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
986
+
987
+ declare const dataSource: ObjectStackAdapter;
988
+
449
989
  // Error: MetadataNotFoundError
450
990
  // Solution: Verify object name and ensure schema exists on server
451
991
  const schema = await dataSource.getObjectSchema('correct_object_name');
@@ -454,6 +994,8 @@ const schema = await dataSource.getObjectSchema('correct_object_name');
454
994
  #### Connection Errors
455
995
 
456
996
  ```typescript
997
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
998
+
457
999
  // Error: ConnectionError
458
1000
  // Solution: Check baseUrl and network connectivity
459
1001
  const dataSource = createObjectStackAdapter({
@@ -465,6 +1007,10 @@ const dataSource = createObjectStackAdapter({
465
1007
  #### Cache Issues
466
1008
 
467
1009
  ```typescript
1010
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
1011
+
1012
+ const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
1013
+
468
1014
  // Clear cache if stale data is being returned
469
1015
  dataSource.clearCache();
470
1016