@object-ui/data-objectstack 17.6.0 → 18.0.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',
@@ -62,6 +67,7 @@ const dataSource = createObjectStackAdapter({
62
67
  ## Features
63
68
 
64
69
  - ✅ **CRUD Operations**: Implements `find`, `findOne`, `create`, `update`, `delete`.
70
+ - ✅ **Shared In-Flight Reads**: Concurrent `find` calls with the same resource and params, and concurrent `findOne` calls with the same resource, id and params, share one request. The entry is dropped when the read settles, so a later call reads again (this is not a response cache), and a failed read is not remembered. A write through the adapter drops the pending `findOne` reads of the resource it wrote, so a read asked after a save never gets an answer sent before it. `MetadataClient` shares its `listTypes`, `list`, `listDrafts`, `get` and `getDraft` reads the same way. Calls with the same method, URL and headers on clients built with the same `fetch` share one request, and every caller gets an answer no other caller holds. A save, publish or reset through any of those clients drops the reads still pending.
65
71
  - ✅ **Metadata Caching**: Automatic LRU caching of schema metadata with TTL expiration.
66
72
  - ✅ **Metadata Fetching**: Implements `getObjectSchema` to power auto-generated forms and grids.
67
73
  - ✅ **Query Translation**: Converts Object UI's OData-like query parameters to ObjectStack's native query format.
@@ -71,15 +77,367 @@ const dataSource = createObjectStackAdapter({
71
77
  - ✅ **Auto-Reconnect**: Automatic reconnection with exponential backoff on connection failures.
72
78
  - ✅ **Batch Progress**: Progress events for tracking bulk operation status.
73
79
 
80
+ ## Query Translation
81
+
82
+ `find()` accepts Object UI's OData-style `QueryParams` and translates them into
83
+ ObjectStack's native query format, so a schema never has to be written in the
84
+ protocol's own shape:
85
+
86
+ ```typescript
87
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
88
+
89
+ declare const dataSource: ObjectStackAdapter;
90
+
91
+ // Query with filters (MongoDB-like operators)
92
+ const result = await dataSource.find('tasks', {
93
+ $filter: {
94
+ status: 'active',
95
+ priority: { $gte: 2 },
96
+ },
97
+ $orderby: { createdAt: 'desc' },
98
+ $top: 20,
99
+ $skip: 0,
100
+ });
101
+
102
+ // Escape hatch: reach the underlying ObjectStack client for anything
103
+ // the DataSource interface does not cover
104
+ const client = dataSource.getClient();
105
+ const metadata = await client.meta.getItem('object', 'task');
106
+ ```
107
+
108
+ ### Query Parameter Mapping
109
+
110
+ | Object UI (`$`) | ObjectStack | Description |
111
+ |--------------|-------------|-------------|
112
+ | `$select` | `select` | Field selection |
113
+ | `$filter` | `filters` (AST) | Filter conditions (converted to FilterNode AST) |
114
+ | `$orderby` | `sort` | Sort order |
115
+ | `$skip` | `skip` | Pagination offset |
116
+ | `$top` | `top` | Limit records |
117
+
118
+ ### Filter Conversion
119
+
120
+ The adapter converts MongoDB-like filter operators into **ObjectStack FilterNode
121
+ AST format**. This is what keeps it compatible with the ObjectStack Protocol
122
+ (v0.1.2+).
123
+
124
+ #### Supported Filter Operators
125
+
126
+ Every row below is decided by `@object-ui/core`'s `convertFiltersToAST`, and
127
+ this package's `src/readme-filter-operator-table.test.ts` runs each worked
128
+ example through it on every test run, so a row cannot drift from the code
129
+ unnoticed again (objectui#8558). Every spelling below is the spec's own
130
+ (`FILTER_OPERATORS` in `@objectstack/spec`'s `data/filter.zod.ts`) and there are
131
+ no aliases: the four lowercase spellings this table used to list beside the
132
+ camelCase keys — `$notin`, `$notcontains`, `$startswith`, `$endswith` — were
133
+ retired by objectui#8568 and moved to the refused table below.
134
+
135
+ `$icontains` is the case-insensitive member of the `$contains` family, and its
136
+ ObjectStack spelling is the SAME word: `icontains` is itself a member of the
137
+ spec's `VALID_AST_OPERATORS`, so nothing is squashed on the way down. It had no
138
+ row here at all until objectui#8976 — `convertFiltersToAST` refused it as an
139
+ unknown operator while `ValueDataSource` executed it and this repo's own filter
140
+ builder emitted it. The pin now holds these two tables complete against the
141
+ spec's `FILTER_OPERATORS` as well as against the code, so a canonical operator
142
+ that is documented NEITHER as supported NOR as refused fails the suite instead
143
+ of going unnoticed.
144
+
145
+ | MongoDB Operator | ObjectStack Operator | Example |
146
+ |------------------|---------------------|---------|
147
+ | plain value (no operator) | `=` | `{ status: 'active' }` → `['status', '=', 'active']` |
148
+ | `$eq` | `=` | `{ status: { $eq: 'active' } }` → `['status', '=', 'active']` |
149
+ | `$ne` | `!=` | `{ status: { $ne: 'archived' } }` → `['status', '!=', 'archived']` |
150
+ | `$gt` | `>` | `{ age: { $gt: 18 } }` → `['age', '>', 18]` |
151
+ | `$gte` | `>=` | `{ age: { $gte: 18 } }` → `['age', '>=', 18]` |
152
+ | `$lt` | `<` | `{ age: { $lt: 65 } }` → `['age', '<', 65]` |
153
+ | `$lte` | `<=` | `{ age: { $lte: 65 } }` → `['age', '<=', 65]` |
154
+ | `$in` | `in` | `{ status: { $in: ['active', 'pending'] } }` → `['status', 'in', ['active', 'pending']]` |
155
+ | `$nin` | `nin` | `{ status: { $nin: ['archived'] } }` → `['status', 'nin', ['archived']]` |
156
+ | `$between` | `between` | `{ age: { $between: [18, 65] } }` → `['age', 'between', [18, 65]]` |
157
+ | `$contains` | `contains` | `{ name: { $contains: 'John' } }` → `['name', 'contains', 'John']` |
158
+ | `$notContains` | `notcontains` | `{ name: { $notContains: 'test' } }` → `['name', 'notcontains', 'test']` |
159
+ | `$startsWith` | `startswith` | `{ email: { $startsWith: 'admin' } }` → `['email', 'startswith', 'admin']` |
160
+ | `$endsWith` | `endswith` | `{ email: { $endsWith: '@example.com' } }` → `['email', 'endswith', '@example.com']` |
161
+ | `$icontains` | `icontains` | `{ name: { $icontains: 'john' } }` → `['name', 'icontains', 'john']` |
162
+ | `$null` | `is_null` / `is_not_null` | `{ email: { $null: true } }` → `['email', 'is_null', true]` |
163
+ | `$exists` | `is_not_null` / `is_null` | `{ email: { $exists: true } }` → `['email', 'is_not_null', true]` |
164
+ | `$empty` | `is_empty` / `is_not_empty` | `{ email: { $empty: true } }` → `['email', 'is_empty', true]` |
165
+
166
+ `$null`, `$exists` and `$empty` read their boolean: `$null: false` lowers to
167
+ `is_not_null`, `$exists: false` to `is_null` and `$empty: false` to
168
+ `is_not_empty`. The lowered node's value slot is always `true` — the direction
169
+ comes from the operator name, which is how the spec's `data/filter.zod.ts`
170
+ reads it. `$empty` takes ONLY a boolean: any other flag throws `INVALID_FILTER`
171
+ / 400 at lowering time, which is how the spec's own doors treat it.
172
+
173
+ `$empty` is not `$null`. objectstack#20446 admitted it to the spec's
174
+ `FILTER_OPERATORS` and flipped the view operators `is_empty` / `is_not_empty`
175
+ to lower to it; they lowered to `$null` before. Its meaning is the spec's ruled
176
+ per-type table: a text-like field is empty when null or `''`, a multi-value
177
+ field when null or `[]`, and any other field when null. Which backend applies
178
+ that table, and how, is listed in the docblock of the spec's
179
+ `FILTER_OPERATORS`, not here. Until objectui#11094 `convertFiltersToAST`
180
+ refused `$empty` as an unknown operator.
181
+
182
+ `$icontains` constrains its **comparand**, which no other row in this table
183
+ does: `@objectstack/spec`'s `FILTER_TEXT_CASES` declares an empty or non-string
184
+ comparand REFUSED, so an empty box or an uncoerced number throws
185
+ `INVALID_FILTER` / 400 at lowering time instead of sending a predicate that
186
+ constrains nothing or a question nobody wrote (objectui#9001). The operator is
187
+ supported exactly as the row says — write a non-empty string, or drop the
188
+ condition.
189
+
190
+ #### Logical combinators
191
+
192
+ `$and` and `$or` take an array of filter conditions and lower to an AST group
193
+ node whose members are lowered with the table above. Sibling keys of one
194
+ object are still combined with `and` (see "Complex Filter Examples" below). An
195
+ empty `$and` is TRUE and is dropped rather than emitted as an empty group —
196
+ `['and']` is not a filter, so emitting it would widen the result set — and an
197
+ empty `$or` is FALSE and matches no row.
198
+
199
+ | MongoDB Key | AST Keyword | Example |
200
+ |-------------|-------------|---------|
201
+ | `$and` | `and` | `{ $and: [{ status: 'open' }, { priority: { $gte: 2 } }] }` → `['and', ['status', '=', 'open'], ['priority', '>=', 2]]` |
202
+ | `$or` | `or` | `{ $or: [{ status: 'open' }, { status: 'blocked' }] }` → `['or', ['status', '=', 'open'], ['status', '=', 'blocked']]` |
203
+
204
+ #### Refused at lowering time
205
+
206
+ These shapes throw a `FilterOperatorError` (`code: 'INVALID_FILTER'`,
207
+ `httpStatus: 400`) from `convertFiltersToAST` instead of reaching the wire. The
208
+ refusal names the field and the value, so a filter that carries one fails at
209
+ the call site rather than as a `400` from the server or as an empty list.
210
+
211
+ | Shape | Why | Example |
212
+ |-------|-----|---------|
213
+ | `$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` |
214
+ | `$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` |
215
+ | 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` |
216
+ | `$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` |
217
+ | any other `$` key in operator position | Unknown operator; the error message lists the supported ones. | `{ age: { $foo: 1 } }` → throws `INVALID_FILTER` |
218
+
219
+ A bare array as a field's value — `{ tags: ['a', 'b'] }` — is **refused** at
220
+ lowering time with a `FilterOperatorError` (`INVALID_FILTER` / 400) naming the
221
+ field and the comparand, not read as `$in` (objectui#8530): the ObjectQL AST
222
+ has no array-equality node, so `['tags', '=', ['a', 'b']]` was answered by a
223
+ `400` from the wire or an empty list from every in-memory matcher. Spell
224
+ membership as `{ tags: { $in: ['a', 'b'] } }` (or `$nin` for its negation).
225
+
226
+ An **exotic object** as a field's value — a `RegExp`, a `Set`, a `Map`, a `URL`,
227
+ any class instance — is refused the same way (objectui#8567). It has no row in
228
+ the table above because the table's examples are executed as JSON literals and
229
+ none of these shapes can be written as one; the refusal is no less real for
230
+ that. `{ name: /abc/ }` used to lower to **nothing at all**: `Object.entries` of
231
+ a `RegExp` is `[]`, so the operator loop ran zero times and the `name` condition
232
+ was dropped from the filter entirely, silently widening the result set. The spec
233
+ rules these out — a comparison value must be a string, number, bigint, boolean,
234
+ null or `Date` (`ACCEPTED_FILTER_COMPARAND_TYPES`), and
235
+ `normalizeFilterComparandTypes` answers `400 INVALID_FILTER` for a `RegExp` —
236
+ so the refusal now lands at lowering time instead of the field vanishing. Spell
237
+ a text match as `{ name: { $contains: 'abc' } }` (`$startsWith` / `$endsWith`), a
238
+ membership test as `{ name: { $in: [...] } }`, and a date bound as
239
+ `{ created: { $gte: someDate } }`. A `Date` comparand is **not** exotic: the spec
240
+ accepts it and it lowers as an equality node (objectui#8555). An **empty
241
+ operator object** — `{ created: {} }`, a field with no operator — is refused too,
242
+ alone or beside other keys, with a `FilterOperatorError` naming the field
243
+ (objectui#9164 alone, objectui#10788 beside other keys). `@objectstack/spec` records
244
+ `{ field: {} }` as REJECTED (objectstack#5240), and beside a key that lowers it
245
+ used to be dropped the same silent way: `{ status: 'a', created: {} }` sent
246
+ exactly what `{ status: 'a' }` sends. Give the field an operator (`$eq`, `$in`,
247
+ `$null`, …) or remove the key.
248
+
249
+ #### Complex Filter Examples
250
+
251
+ **Multiple conditions** are combined with `'and'`:
252
+
253
+ ```typescript
254
+ // Input
255
+ const $filter = {
256
+ age: { $gte: 18, $lte: 65 },
257
+ status: 'active',
258
+ };
259
+
260
+ // Converted to AST
261
+ const ast = [
262
+ 'and',
263
+ ['age', '>=', 18],
264
+ ['age', '<=', 65],
265
+ ['status', '=', 'active'],
266
+ ];
267
+ ```
268
+
269
+ #### Rule-shaped arrays, on `find()` **and** `aggregate()`
270
+
271
+ Server-driven view configs store their conditions as an array of rules
272
+ (`ViewFilterRule[]`), not as a MongoDB-style object:
273
+
274
+ ```typescript
275
+ const filter = [{ field: 'stage', operator: 'equals', value: 'won' }];
276
+ ```
277
+
278
+ Both read paths lower that array to the same AST before it reaches the wire —
279
+ `find()` via `$filter`, and `aggregate()` via the analytics `where`. They share
280
+ one translator, so a stored filter cannot mean one thing on a list and another
281
+ on a KPI:
282
+
283
+ ```typescript
284
+ // find(): filter=["stage","=","won"]
285
+ // aggregate(): { ..., where: ["stage", "=", "won"] }
286
+ ```
287
+
288
+ Operator aliases (`equals`, `greater_than_or_equal`, `not_in`, `before`, ...)
289
+ map to the canonical AST symbols, and rules spread into a logical node
290
+ (`['and', ...rules, ...tuples]`) are lowered at depth. A rule that cannot be
291
+ translated raises `MalformedFilterError` rather than being dropped — dropping
292
+ one condition of an `and` would widen the result set and report success.
293
+
294
+ An `icontains` rule constrains its **comparand** here exactly as `$icontains`
295
+ does in the object form: `@objectstack/spec`'s `FILTER_TEXT_CASES` declares an
296
+ empty or non-string comparand REFUSED, so a rule such as
297
+ `{ field: 'name', operator: 'icontains', value: '' }` — or one whose `value` is
298
+ a number, `null` or missing — throws `MalformedFilterError` (`INVALID_FILTER` /
299
+ 400) before any request is sent, instead of sending a predicate that constrains
300
+ nothing or a question nobody wrote (objectui#9048). The reason is the spec's
301
+ own, the one the object form's refusal carries (objectui#9001), and the message
302
+ names the operator spelling that arrived (`icontains`, `ICONTAINS`, …) beside
303
+ the `$icontains` spelling the contract uses. The rule is refused wherever it
304
+ sits — at the top level, in a nested group, or spread into a logical node — and
305
+ on both `find()` and `aggregate()`. Write a non-empty string, or drop the rule.
306
+
307
+ Non-array filters are passed through unchanged on the aggregate path: a
308
+ MongoDB-style object is already what `/analytics/query` accepts.
309
+
310
+ #### `aggregate({ where })` does NOT lower — it refuses
311
+
312
+ The lowering above is the `filter` parameter, on the analytics path.
313
+ `aggregate()` has a second, spec-shape branch — entered when the params carry
314
+ an array `groupBy`, an array `aggregations`, or **any** `where` key — which
315
+ posts `where` to `POST /data/:object/query` verbatim. That `where` is the spec
316
+ Query DSL's `where`, so it must ALREADY be lowered, and since objectui#6825 an
317
+ array that the spec's own `isFilterAST` gate rejects is refused here rather
318
+ than shipped:
319
+
320
+ ```typescript
321
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
322
+
323
+ declare const dataSource: ObjectStackAdapter;
324
+
325
+ // ✅ lowered — reaches the wire unchanged
326
+ await dataSource.aggregate('opportunity', {
327
+ groupBy: ['stage'],
328
+ aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
329
+ where: ['stage', '=', 'won'],
330
+ });
331
+
332
+ // ⛔ throws UnloweredAggregateWhereError — authoring sugar, not a filter
333
+ await dataSource.aggregate('opportunity', {
334
+ groupBy: ['stage'],
335
+ aggregations: [{ function: 'count', field: 'id', alias: 'n' }],
336
+ where: [{ field: 'stage', operator: 'equals', value: 'won' }],
337
+ });
338
+ ```
339
+
340
+ This is a producer-side refusal of a value the server already refused (`is not
341
+ a filter`, `400 INVALID_FILTER`): the point is that you now find out at the call
342
+ site, with the offending value named, instead of on the wire — or not at all,
343
+ with a chart quietly rendering unfiltered numbers. Lower the rules in whatever
344
+ built the params, or use the analytics branch (`filter` + the legacy `field` /
345
+ `function` / `groupBy` params), which lowers for you.
346
+
347
+ Two shapes are deliberately NOT refused, because the receiving door accepts
348
+ them: a `FilterCondition` object (`{ stage: 'won' }` — what `QuerySchema.where`
349
+ declares), and an empty array (`[]` means "no filter").
350
+
351
+ #### The two shapes are alternatives, not a mixture
352
+
353
+ The spec-shape branch reads exactly four keys — `groupBy`, `aggregations`,
354
+ `where`, `limit` — and `filter` / `field` / `function` are the **analytics**
355
+ branch's own parameters. Until objectui#6864 they were neither read nor
356
+ reported on the spec-shape branch: they simply were not in the body that went
357
+ out. Since #6864 they are refused, by name:
358
+
359
+ ```typescript
360
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
361
+
362
+ declare const dataSource: ObjectStackAdapter;
363
+
364
+ // ⛔ throws AnalyticsKeysOnSpecShapeError — an ARRAY `groupBy` selects the
365
+ // spec-shape branch, and these three keys would have vanished from the query:
366
+ // no filter, and no measure at all.
367
+ await dataSource.aggregate('opportunity', {
368
+ field: 'amount',
369
+ function: 'sum',
370
+ groupBy: ['stage'],
371
+ filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
372
+ });
373
+
374
+ // ✅ the analytics shape, unchanged — a STRING `groupBy` keeps this call on the
375
+ // analytics branch, where all three keys are read and `filter` is lowered for you
376
+ await dataSource.aggregate('opportunity', {
377
+ field: 'amount',
378
+ function: 'sum',
379
+ groupBy: 'stage',
380
+ filter: [{ field: 'stage', operator: 'equals', value: 'won' }],
381
+ });
382
+ ```
383
+
384
+ Pick one shape per call. A key that is present but nullish (`filter: undefined`)
385
+ carries nothing to drop and is not refused, so params built by spreading
386
+ possibly-absent authored values keep working. Keys outside those three are not
387
+ refused either — the gate names `filter`, `field` and `function`, and only
388
+ those.
389
+
390
+ ### Group headers for server-side grid grouping
391
+
392
+ `queryGroupHeaders(resource, query)` answers a grouped list view's **group
393
+ header query** — the `EngineAggregateOptions` that `@objectstack/spec/ui`'s
394
+ `compileListViewGroupQuery` compiles from the view's `grouping` (and its column
395
+ summaries). It is how a grouped `object-grid` gets its group set and every
396
+ header number from the server instead of bucketing a fetched page
397
+ (objectui#7189): one row per group, each grouped field under its own name
398
+ holding the raw stored value (`null` for the empty group), and the group's
399
+ total row count under `count`.
400
+
401
+ The query is posted **verbatim** to the existing `POST /data/:object/query`
402
+ door (the same one the spec-shape `aggregate()` branch uses), and the answer is
403
+ its `records`. Unlike `aggregate()`, it never degrades to a client-side
404
+ reduction: a refusal is thrown with the server's code and status, and an answer
405
+ without a `records` array is refused rather than read as "no groups". The rows
406
+ inside a group are an ordinary `find()` with the group's compiled row query
407
+ (`compileListViewGroupRowsQuery`).
408
+
409
+ ### Sorting
410
+
411
+ ```typescript
412
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
413
+
414
+ declare const dataSource: ObjectStackAdapter;
415
+
416
+ // OData-style
417
+ await dataSource.find('users', {
418
+ $orderby: {
419
+ createdAt: 'desc',
420
+ name: 'asc',
421
+ },
422
+ });
423
+
424
+ // Converted to ObjectStack: ['-createdAt', 'name']
425
+ ```
426
+
74
427
  ## Metadata Caching
75
428
 
76
429
  The adapter includes built-in metadata caching to improve performance when fetching schemas:
77
430
 
78
431
  ```typescript
432
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
433
+
434
+ const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
435
+
79
436
  // Get cache statistics
80
437
  const stats = dataSource.getCacheStats();
81
438
  console.log(`Cache hit rate: ${stats.hitRate * 100}%`);
82
439
  console.log(`Cache size: ${stats.size}/${stats.maxSize}`);
440
+ console.log(`Fetches coalesced onto an in-flight request: ${stats.coalesced}`);
83
441
 
84
442
  // Manually invalidate cache entries
85
443
  dataSource.invalidateCache('users'); // Invalidate specific schema
@@ -95,13 +453,23 @@ dataSource.clearCache();
95
453
  - **TTL Expiration**: Entries expire after the configured time-to-live from creation (default: 5 minutes)
96
454
  - Note: TTL is fixed from creation time, not sliding based on access
97
455
  - **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.
456
+ - **Request Coalescing**: Concurrent `get` calls for the same uncached key share a single
457
+ fetch. The first caller invokes the fetcher; every caller that arrives while that promise
458
+ is still in flight is handed the same promise instead of starting a second request, and
459
+ each one increments the `coalesced` counter in `getCacheStats()` — so the saving is
460
+ something you can read off the adapter, not just a claim in this page.
461
+ - The in-flight slot is released in a `finally`, so a rejected fetch is not cached and
462
+ does not poison the key: the next call starts a fresh fetch.
99
463
 
100
464
  ## Connection State Monitoring
101
465
 
102
466
  The adapter provides real-time connection state monitoring with automatic reconnection:
103
467
 
104
468
  ```typescript
469
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
470
+
471
+ const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
472
+
105
473
  // Monitor connection state changes
106
474
  const unsubscribe = dataSource.onConnectionStateChange((event) => {
107
475
  console.log('Connection state:', event.state);
@@ -132,6 +500,26 @@ unsubscribe();
132
500
  - `reconnecting` - Attempting to reconnect after failure
133
501
  - `error` - Connection failed (check event.error for details)
134
502
 
503
+ ### After the First Connect
504
+
505
+ Once the first connection has succeeded, the state follows the outcome of the
506
+ requests your app already makes. The adapter adds no timer, heartbeat or
507
+ request of its own:
508
+
509
+ - **A request with no HTTP answer** (refused, reset, unreachable) moves the state
510
+ to `reconnecting`. When the retry window is spent, it moves to `error`. The
511
+ window is the same `autoReconnect` / `maxReconnectAttempts` budget as below,
512
+ counted in unanswered requests. With `autoReconnect: false` the first
513
+ unanswered request moves the state straight to `error`.
514
+ - **The next answered request** moves it back to `connected`.
515
+ - **An answered HTTP error does not move it.** A 4xx or 5xx means the server is
516
+ reachable. A request its caller aborted does not move it either.
517
+
518
+ Every request goes through the adapter's one fetch, including those made
519
+ through `getClient()`, except `uploadFile` / `uploadFiles`. Those two call the
520
+ global `fetch`, so an upload's outcome does not move the state. The response or
521
+ rejection reaches your code unchanged.
522
+
135
523
  ### Auto-Reconnect
136
524
 
137
525
  The adapter automatically attempts to reconnect on connection failures:
@@ -146,6 +534,12 @@ The adapter automatically attempts to reconnect on connection failures:
146
534
  Track progress of bulk operations in real-time:
147
535
 
148
536
  ```typescript
537
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
538
+
539
+ const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
540
+
541
+ declare const largeDataset: Array<Record<string, unknown>>;
542
+
149
543
  // Monitor batch operation progress
150
544
  const unsubscribe = dataSource.onBatchProgress((event) => {
151
545
  console.log(`${event.operation}: ${event.percentage.toFixed(1)}%`);
@@ -187,30 +581,60 @@ import {
187
581
  // SYMBOL is prefixed because @objectstack/spec/kernel
188
582
  // owns `ValidationError` for a { field, message, code? }
189
583
  // record (objectui#3160).
584
+ MalformedFilterError, // A filter rule that cannot be translated (400
585
+ // INVALID_FILTER) — thrown rather than dropped,
586
+ // because dropping one condition of an `and` widens
587
+ // the result set and reports success. Also thrown,
588
+ // before any request, for an `icontains` rule whose
589
+ // `value` is empty or not a string, naming the
590
+ // spelling that arrived. See "Rule-shaped arrays".
591
+ UnloweredAggregateWhereError, // aggregate()'s spec-shape `where` was an array
592
+ // the spec's filter-AST gate rejects (400
593
+ // INVALID_FILTER). See "aggregate({ where }) does
594
+ // NOT lower" above.
595
+ AnalyticsKeysOnSpecShapeError, // aggregate()'s spec-shape branch was handed the
596
+ // ANALYTICS branch's keys (`filter` / `field` /
597
+ // `function`), which it does not read (400
598
+ // INVALID_FILTER). See "the two shapes are
599
+ // alternatives, not a mixture" above.
600
+ isMalformedFilterError, // Recognises ALL THREE of the above, and the server's
601
+ // own version of the same refusal.
190
602
  } from '@object-ui/data-objectstack';
191
603
  ```
192
604
 
193
605
  ### Error Handling Example
194
606
 
195
607
  ```typescript
608
+ import {
609
+ ObjectStackError,
610
+ MetadataNotFoundError,
611
+ ConnectionError,
612
+ AuthenticationError,
613
+ type ObjectStackAdapter,
614
+ } from '@object-ui/data-objectstack';
615
+
616
+ declare const dataSource: ObjectStackAdapter;
617
+
196
618
  try {
197
619
  const schema = await dataSource.getObjectSchema('users');
198
620
  } catch (error) {
199
621
  if (error instanceof MetadataNotFoundError) {
200
- console.error(`Schema not found: ${error.details.objectName}`);
622
+ console.error(`Schema not found: ${error.details?.objectName}`);
201
623
  } else if (error instanceof ConnectionError) {
202
624
  console.error(`Connection failed to: ${error.url}`);
203
625
  } else if (error instanceof AuthenticationError) {
204
626
  console.error('Authentication required');
205
627
  }
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
- });
628
+
629
+ // Every error this adapter throws carries the same shape
630
+ if (error instanceof ObjectStackError) {
631
+ console.error({
632
+ code: error.code,
633
+ message: error.message,
634
+ statusCode: error.statusCode,
635
+ details: error.details,
636
+ });
637
+ }
214
638
  }
215
639
  ```
216
640
 
@@ -219,6 +643,12 @@ try {
219
643
  Bulk operations provide detailed error reporting with partial success information:
220
644
 
221
645
  ```typescript
646
+ import { BulkOperationError, type ObjectStackAdapter } from '@object-ui/data-objectstack';
647
+
648
+ declare const dataSource: ObjectStackAdapter;
649
+
650
+ declare const records: Array<Record<string, unknown>>;
651
+
222
652
  try {
223
653
  await dataSource.bulk('users', 'update', records);
224
654
  } catch (error) {
@@ -244,6 +674,9 @@ All errors include unique error codes for programmatic handling:
244
674
  - `CONNECTION_ERROR` - Connection/network error
245
675
  - `AUTHENTICATION_ERROR` - Authentication failure
246
676
  - `VALIDATION_ERROR` - Data validation error
677
+ - `INVALID_FILTER` - A request the adapter refuses to send (`MalformedFilterError`,
678
+ `UnloweredAggregateWhereError`, `AnalyticsKeysOnSpecShapeError`); matches the data
679
+ API's own code for the same refusal
247
680
  - `UNSUPPORTED_OPERATION` - Unsupported operation
248
681
  - `NOT_FOUND` - Resource not found
249
682
  - `UNKNOWN_ERROR` - Unknown error
@@ -253,6 +686,10 @@ All errors include unique error codes for programmatic handling:
253
686
  The adapter supports optimized batch operations with automatic fallback:
254
687
 
255
688
  ```typescript
689
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
690
+
691
+ declare const dataSource: ObjectStackAdapter;
692
+
256
693
  // Batch create
257
694
  const newUsers = await dataSource.bulk('users', 'create', [
258
695
  { name: 'Alice', email: 'alice@example.com' },
@@ -286,6 +723,10 @@ writes as a single all-or-nothing unit — the master-detail case, where a paren
286
723
  and its children must commit or roll back together — use `batchTransaction`:
287
724
 
288
725
  ```typescript
726
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
727
+
728
+ declare const dataSource: ObjectStackAdapter;
729
+
289
730
  // Create a parent and a child that references it, atomically.
290
731
  // `{ $ref: 0 }` resolves to the id minted by operation 0 (the parent).
291
732
  await dataSource.batchTransaction([
@@ -341,6 +782,232 @@ still succeeds, but non-atomically via the fallback above. Treat the advertised
341
782
  capability as the floor for the atomicity guarantee, not as a connection
342
783
  prerequisite.
343
784
 
785
+ ## Object-Metadata Write Guard
786
+
787
+ `MetadataClient.save` refuses an `object` document whose `fields` carry either
788
+ of two incomplete fields, **before** issuing the request:
789
+
790
+ - a relationship field (`lookup`, `master_detail`) with a missing, empty or
791
+ whitespace-only `reference`;
792
+ - a choice field (`select`, `radio`) with no option source: neither a non-empty
793
+ `options` list nor a shared `picklist`. `options: []` counts as none.
794
+
795
+ ```ts
796
+ import { MetadataClient } from '@object-ui/data-objectstack';
797
+
798
+ const client = new MetadataClient({ baseUrl: '/api/v1' });
799
+
800
+ await client.save('object', 'account', {
801
+ name: 'account',
802
+ fields: { owner: { type: 'lookup', label: 'Owner' } },
803
+ });
804
+ // throws: The object was not saved: the field
805
+ // `owner` is a `lookup` and carries no `reference` key at all ...
806
+ ```
807
+
808
+ For the relationship rule, nothing that previously succeeded now fails.
809
+ `@objectstack/spec` refuses the same document at the server with a 422 on
810
+ `fields.owner.reference`, and that refusal blocks every *later* save of the
811
+ object for as long as the half-filled field rides along in the draft. The guard
812
+ moves the identical refusal earlier, names the field while it is still on screen,
813
+ and leaves the draft in the client.
814
+
815
+ The choice rule is different, and on purpose: it holds a document the installed
816
+ server still stores. It is the objectui half of the maintainer's ruling A on
817
+ [objectstack#20827](https://github.com/objectstack-ai/objectstack/issues/20827),
818
+ which refuses such a field at the `FieldSchema` door and has objectui stop
819
+ sending it first. A `select` added in Studio, and saved before its first option,
820
+ now waits in the client, with the field named, until it has one:
821
+
822
+ ```ts
823
+ import { MetadataClient } from '@object-ui/data-objectstack';
824
+
825
+ const client = new MetadataClient({ baseUrl: '/api/v1' });
826
+
827
+ await client.save('object', 'deal', {
828
+ name: 'deal',
829
+ fields: { stage: { type: 'select', label: 'Stage', options: [] } },
830
+ });
831
+ // throws: The object was not saved: the field
832
+ // `stage` is a `select` with no options ...
833
+ ```
834
+
835
+ Writes of every other metadata type are untouched, and the guard never strips
836
+ the offending field — a dropped field reported as saved would be a silent
837
+ deletion.
838
+
839
+ Hosts that write object metadata through their own transport can apply the same
840
+ invariant at their own door:
841
+
842
+ ```ts
843
+ import { assertObjectMetadataWritable } from '@object-ui/data-objectstack';
844
+
845
+ async function uploadObject(name: string, body: unknown) {
846
+ assertObjectMetadataWritable('object', body, 'uploadObject');
847
+ await fetch(`/api/v1/meta/object/${encodeURIComponent(name)}`, {
848
+ method: 'PUT',
849
+ headers: { 'Content-Type': 'application/json' },
850
+ body: JSON.stringify(body),
851
+ });
852
+ }
853
+ ```
854
+
855
+ `RELATIONSHIP_TYPES_REQUIRING_REFERENCE`, `CHOICE_TYPES_REQUIRING_OPTIONS` and
856
+ `OBJECT_METADATA_TYPE` are exported beside it. Both type sets are derived from
857
+ the installed `@objectstack/spec` by this package's own pin (the choice set from
858
+ its `field/choice-without-options` completeness rule), so they follow the
859
+ contract rather than a remembered list.
860
+
861
+ ## Showing a Refused Metadata Save
862
+
863
+ When the server's spec check refuses a metadata save, it answers `422
864
+ INVALID_METADATA`. The error's `message` is a headline: a count of issues and
865
+ where they are. What the author should do is in the structured issues, which
866
+ `MetadataClient` puts on the thrown error's `issues`. Show the error through
867
+ `formatMetadataError`, not through `message` alone:
868
+
869
+ ```ts
870
+ import { MetadataClient, formatMetadataError } from '@object-ui/data-objectstack';
871
+
872
+ const client = new MetadataClient({ baseUrl: '/api/v1' });
873
+
874
+ try {
875
+ await client.save('object', 'account', {
876
+ name: 'account',
877
+ fields: { 'Bad Name': { type: 'text', label: 'Bad' } },
878
+ });
879
+ } catch (err) {
880
+ showBanner(formatMetadataError(err));
881
+ // • fields.Bad Name — Invalid key in record
882
+ // • fields.Bad Name — Field names must be lowercase snake_case (e.g., "first_name", …)
883
+ }
884
+
885
+ declare function showBanner(text: string): void;
886
+ ```
887
+
888
+ It writes one line per issue, naming the field, and falls back to the error's
889
+ `message` when there are no issues. Render the text with a class that keeps
890
+ newlines (`whitespace-pre-line`). `formatMetadataIssue(issue)` is the same
891
+ one-line format for a single issue, for code that lists several failures, such
892
+ as a publish response's `failed[]`.
893
+
894
+ ## Asking Why a Metadata Item Is Not Listed
895
+
896
+ The server filters metadata lists per caller. An app, a dashboard or a list view
897
+ the caller may not open is left out of their list, exactly like a name that does
898
+ not exist. `probeMetaItemAccess(type, name)` asks the by-name route which one it
899
+ is:
900
+
901
+ ```ts
902
+ import { ObjectStackAdapter, type MetaItemAccess } from '@object-ui/data-objectstack';
903
+
904
+ const adapter = new ObjectStackAdapter({ baseUrl: '/api/v1' });
905
+
906
+ const access: MetaItemAccess = await adapter.probeMetaItemAccess('dashboard', 'legal_board');
907
+ // { verdict: 'denied', message: "You do not have permission to open the 'legal_board' dashboard." }
908
+ ```
909
+
910
+ `verdict` is `granted`, `denied`, `not_found`, `unreachable` (no answer was
911
+ obtained) or `unknown` (nothing was asked). Only `denied`, read from the
912
+ `PERMISSION_DENIED` code and never from the status, is a permission refusal,
913
+ and only then does `message` carry the server's sentence. The probe never
914
+ throws and caches nothing. `probeAppAccess(name)` is the same probe for an app,
915
+ returning the verdict alone.
916
+
917
+ ## Saving an Object You Read From the Server
918
+
919
+ A select field can name a shared picklist (`picklist: 'industry'`) instead of
920
+ carrying its own options. The server *serves* such a field with both keys:
921
+ `picklist`, and the `options` it resolved from the list. The server's authoring
922
+ check refuses the two together, with `422 INVALID_METADATA` at
923
+ `fields.FIELD.options`, and that refuses the whole object. So code that reads an
924
+ object, edits it, and saves the whole document back must drop those resolved
925
+ options first. `dropServedPicklistOptions` does that:
926
+
927
+ ```ts
928
+ import { MetadataClient, dropServedPicklistOptions } from '@object-ui/data-objectstack';
929
+
930
+ const client = new MetadataClient({ baseUrl: '/api/v1' });
931
+
932
+ const account = await client.get<Record<string, unknown>>('object', 'account');
933
+ const edited = { ...account, label: 'Customer' };
934
+ await client.save('object', 'account', dropServedPicklistOptions(edited));
935
+ ```
936
+
937
+ It removes `options` from every field that names a picklist, and touches
938
+ nothing else: a field without `picklist` keeps its inline `options`, and every
939
+ other key is kept. It never mutates its input, and it returns the same object
940
+ when there is nothing to remove.
941
+
942
+ Apply it only to a body built from a served read. `MetadataClient.save` does
943
+ not apply it for you: the client cannot tell a served copy from an author who
944
+ wrote `picklist` and `options` together, and that second case should stay the
945
+ server's refusal, which says which key to delete.
946
+
947
+ ## Saving Only Over the Version You Read
948
+
949
+ Two editors of one metadata item overwrite each other unless each save says
950
+ which stored row it was built on. `MetadataClient.save` takes that precondition
951
+ as one of two options, never both:
952
+
953
+ - `ifMatch: version`, sent as `If-Match`: save only over that row. The token is
954
+ the `version` the `/meta` door served for the same address (type, name,
955
+ `packageId`, and the draft or active lifecycle): from the item read
956
+ (`getDraft` hands back the envelope, `version` included), from a save
957
+ receipt, or as `currentVersion` on a `409 METADATA_CONFLICT` refusal.
958
+ - `ifNoneMatch: '*'`, sent as `If-None-Match: *`: save only where no row of that
959
+ lifecycle exists yet. A create sends it, and so does the first draft save of
960
+ an item with no pending draft, or one whose read served `version: null`.
961
+
962
+ ```ts
963
+ import { MetadataClient } from '@object-ui/data-objectstack';
964
+
965
+ const client = new MetadataClient({ baseUrl: '/api/v1' });
966
+
967
+ const read = await client.getDraft<{ item: Record<string, unknown>; version?: string | null }>(
968
+ 'object',
969
+ 'account',
970
+ { packageId: 'com.acme.crm' },
971
+ );
972
+ // No pending draft, or none at this address: the save creates the draft row.
973
+ // A server that serves no `version` at all leaves the save unpinned.
974
+ const pin =
975
+ read === null || read.version === null
976
+ ? { ifNoneMatch: '*' as const }
977
+ : read.version
978
+ ? { ifMatch: read.version }
979
+ : {};
980
+ await client.save('object', 'account', { name: 'account', label: 'Customer' }, {
981
+ mode: 'draft',
982
+ packageId: 'com.acme.crm',
983
+ ...pin,
984
+ });
985
+ ```
986
+
987
+ Read at the package the save writes: a read that names no package serves a
988
+ package-bound draft with `version: null`, which says nothing about a save that
989
+ names the package. A save that sets both options throws before any request;
990
+ the door refuses the pair with a `400`. A save whose pin no longer holds is
991
+ refused `409 METADATA_CONFLICT`, and nothing is written.
992
+
993
+ ## Reading a Draft and Who Changed It
994
+
995
+ `MetadataClient.getDraft(type, name, { packageId })` answers the draft read's
996
+ wire envelope (`{ type, name, item, version }`, plus `draftChanges` when the
997
+ server recorded them), or `null` when nothing is pending. Read it through the
998
+ package's readers rather than by hand: `extractDraftBody` answers the body
999
+ with the read decorations removed; `readDraftChanges` answers the per-key
1000
+ provenance, one `{ path, by, actor, at }` per changed key path, or `null` when
1001
+ the key is absent, which means not recorded, never "nothing changed";
1002
+ `governingDraftChange(changes, path)` answers the entry that governs a key, the
1003
+ longest path that is the key or one of its ancestors, with `[]` standing for
1004
+ the whole body and segments compared whole; and `draftChangesUnder(changes,
1005
+ path)` answers the entries strictly under the key. A save names the surface it
1006
+ is made through with the `surface` option, `'studio' | 'api'`, sent once as
1007
+ `?surface=` and recorded as `by` on each key path a draft save changes; a save
1008
+ that omits it is recorded as `api`, and `copilot` is the server's own (its
1009
+ HTTP door refuses it), so this client cannot send it.
1010
+
344
1011
  ## User-Scoped State Adapter
345
1012
 
346
1013
  In addition to the main `DataSource` adapter, this package ships
@@ -348,9 +1015,12 @@ In addition to the main `DataSource` adapter, this package ships
348
1015
  persist per-user UI state (favorites, recent items) into ObjectStack.
349
1016
 
350
1017
  ```typescript
351
- import { createObjectStackUserStateAdapter } from '@object-ui/data-objectstack';
1018
+ import { createObjectStackUserStateAdapter, type ObjectStackAdapter } from '@object-ui/data-objectstack';
352
1019
  import { useAttachUserStateAdapters } from '@object-ui/app-shell';
353
1020
 
1021
+ declare const dataSource: ObjectStackAdapter;
1022
+ declare const user: { id: string };
1023
+
354
1024
  const favoritesAdapter = createObjectStackUserStateAdapter({
355
1025
  dataSource, // the ObjectStack DataSource
356
1026
  userId: user.id,
@@ -359,6 +1029,8 @@ const favoritesAdapter = createObjectStackUserStateAdapter({
359
1029
  // onError: (op, err) => console.warn(`[user-state] ${op} failed`, err),
360
1030
  });
361
1031
 
1032
+ const attach = useAttachUserStateAdapters();
1033
+
362
1034
  attach('favorites', favoritesAdapter);
363
1035
  ```
364
1036
 
@@ -387,12 +1059,46 @@ If the backend doesn't yet expose `sys_user_preference`, every call
387
1059
  [User-Scoped State Persistence](../../content/docs/guide/user-state-persistence.md)
388
1060
  for the full design.
389
1061
 
1062
+ ### Key families
1063
+
1064
+ Some state is one row per subject, and which subjects exist is only known
1065
+ from the rows: the console keeps each object's list state under
1066
+ `ui.list.<object>`. `createObjectStackUserPreferenceFamily` loads every row
1067
+ whose key starts with a prefix, and writes one key's row at a time:
1068
+
1069
+ ```typescript
1070
+ import { createObjectStackUserPreferenceFamily, type ObjectStackAdapter } from '@object-ui/data-objectstack';
1071
+
1072
+ declare const dataSource: ObjectStackAdapter;
1073
+ declare const user: { id: string };
1074
+
1075
+ const lists = createObjectStackUserPreferenceFamily<{ viewOrder?: string[] }>({
1076
+ dataSource,
1077
+ userId: user.id,
1078
+ prefix: 'ui.list.',
1079
+ });
1080
+
1081
+ const rows = await lists.load(); // { 'ui.list.account': { viewOrder: [...] }, ... }
1082
+ await lists.save('ui.list.account', { viewOrder: ['account.mine', 'account.all'] });
1083
+ ```
1084
+
1085
+ A family's `load()` joins the same shared read as the per-key adapters' loads
1086
+ issued in the same tick, so the console's slots and its families are still
1087
+ ONE request per load. That read asks for its keys or the prefix, and its
1088
+ `$top` is the key count plus `PREFERENCE_FAMILY_ROW_LIMIT` rows per prefix.
1089
+ When an answer fills that `$top`, a key whose row is missing from it reads
1090
+ its own row. Only rows whose value is a JSON object are returned. A failed
1091
+ read loads as `{}`, and a failed write is reported to `onError` and
1092
+ swallowed. A key outside the family is refused and nothing is written.
1093
+
390
1094
  ## API Reference
391
1095
 
392
1096
  ### ObjectStackAdapter
393
1097
 
394
1098
  #### Constructor
395
1099
 
1100
+ <!-- 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. -->
1101
+
396
1102
  ```typescript
397
1103
  new ObjectStackAdapter(config: {
398
1104
  baseUrl: string;
@@ -446,6 +1152,10 @@ new ObjectStackAdapter(config: {
446
1152
  #### Schema Not Found
447
1153
 
448
1154
  ```typescript
1155
+ import type { ObjectStackAdapter } from '@object-ui/data-objectstack';
1156
+
1157
+ declare const dataSource: ObjectStackAdapter;
1158
+
449
1159
  // Error: MetadataNotFoundError
450
1160
  // Solution: Verify object name and ensure schema exists on server
451
1161
  const schema = await dataSource.getObjectSchema('correct_object_name');
@@ -454,6 +1164,8 @@ const schema = await dataSource.getObjectSchema('correct_object_name');
454
1164
  #### Connection Errors
455
1165
 
456
1166
  ```typescript
1167
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
1168
+
457
1169
  // Error: ConnectionError
458
1170
  // Solution: Check baseUrl and network connectivity
459
1171
  const dataSource = createObjectStackAdapter({
@@ -465,6 +1177,10 @@ const dataSource = createObjectStackAdapter({
465
1177
  #### Cache Issues
466
1178
 
467
1179
  ```typescript
1180
+ import { createObjectStackAdapter } from '@object-ui/data-objectstack';
1181
+
1182
+ const dataSource = createObjectStackAdapter({ baseUrl: 'https://api.example.com' });
1183
+
468
1184
  // Clear cache if stale data is being returned
469
1185
  dataSource.clearCache();
470
1186