@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/CHANGELOG.md +2460 -0
- package/README.md +727 -11
- package/dist/index.cjs +2213 -405
- package/dist/index.d.cts +1971 -211
- package/dist/index.d.ts +1971 -211
- package/dist/index.js +2199 -406
- package/package.json +9 -8
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
|
-
- **
|
|
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
|
|
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
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|