Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/9175-value-data-source-structured-clone.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
'@object-ui/core': minor
---

`ValueDataSource` deep-clones its inline rows with `structuredClone` instead of
`JSON.parse(JSON.stringify(...))` — in the constructor and in `getAll()`
(objectui#9175, maintainer ruling A on objectui#9061).

**Why the round-trip was wrong.** The clone exists for exactly one reason, stated
in the comment above it: "Deep clone to prevent external mutation". That is an
ALIASING barrier on a read-only query source. A JSON round-trip is an aliasing
barrier too, but it is also a SERIALIZATION boundary — and nothing asked for one.
So every row that reached `provider: 'value'` silently acquired a requirement the
contract never states. `ViewData.items` is `z.array(z.unknown())` in
`@objectstack/spec`, not an array of JSON, and objectui#6018 pinned the
consequence in words: an inline value never has to be serializable at all. That
guarantee became false the moment a renderer routed its inline rows through this
adapter to honour `filter` / `sort` / the objectui#7210 row ceiling.

**Behaviour that moves — measured, per shape.** Inline rows now reach the
renderer as authored:

| in `items` | before | now |
| --- | --- | --- |
| `Date` | ISO **string** | a `Date` |
| key whose value is `undefined` | key **deleted** | key kept, value `undefined` |
| `Map` / `Set` | `{}` | a `Map` / a `Set` |
| `RegExp` | `{}` | a `RegExp` |
| `NaN` / `Infinity` | `null` | `NaN` / `Infinity` |
| `BigInt` | **threw** `TypeError` | the `BigInt` |
| cyclic row graph | **threw** `TypeError` | the graph, cycle intact |
| a function-valued key | key **deleted**, silently | **throws** `DataCloneError` |

Two consequences worth naming because they are observable through the adapter's
own API rather than only in the rows: `getObjectSchema` infers types with
`typeof`, so a `Date` column now infers `'object'` where it inferred `'string'`,
and a key whose value is `undefined` now appears in the inferred schema at all;
and `$orderby` on a `Date` column now sorts chronologically rather than
lexically over ISO text (the same order for ISO-8601, a different one for any
other date rendering).

**The last row of that table is the only narrowing, and it is deliberate.** A
function in a row used to vanish without a word; it now fails loudly at
construction. There is no `try`/`catch` fallback to the round-trip, because a
fallback would restore precisely the silent flattening this replaces — the
maintainer's ruling was to fix the clone, not to make it tolerant.

**Migration.** Code that relied on reading a `Date` back as a string (for
example `row.start.slice(0, 10)`, or `===` against an ISO literal) must read it
as a `Date`. Code that relied on an `undefined`-valued key disappearing must test
the value rather than `in` / `hasOwnProperty`. A row carrying a function must
stop doing so — inline rows are data.

Marked `minor` rather than `major` per this repo's version-alignment rule
(objectui's major tracks `@objectstack`'s); the breaking semantics are the table
above.
16 changes: 14 additions & 2 deletions packages/core/src/adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ For `provider: 'value'`. Everything runs against an in-memory array, which is
deep-cloned on construction so the caller's array is never mutated. Useful for
static content, fixtures, and previews.

The clone is a **`structuredClone`**, not a JSON round-trip (objectui#9175). It
is an aliasing barrier and nothing more: `ViewData.items` is
`z.array(z.unknown())` in `@objectstack/spec`, so **an inline row does not have
to be serializable** (objectui#6018). A `Date` arrives as a `Date`, a key whose
value is `undefined` keeps its key, `Map` / `Set` / `RegExp` / `BigInt` /
`NaN` / a cyclic record graph all survive as themselves. What
`structuredClone` cannot copy — a function, a DOM node — throws
`DataCloneError` at construction: **loud, on purpose**, and there is no
fallback to the round-trip, because a fallback would restore the silent
flattening this replaced.

```typescript
import { ValueDataSource } from '@object-ui/core';

Expand All @@ -75,8 +86,9 @@ const { data, total } = await dataSource.find('people', {

It implements `$filter` (both MongoDB-style objects and FilterNode AST arrays),
`$search`, `$orderby`, `$skip`, `$top` and `$select` locally, plus `bulk()`,
`aggregate()` and `onMutation()`. `getAll()` returns a cloned snapshot and
`count` the current length.
`aggregate()` and `onMutation()`. `getAll()` returns a cloned snapshot — the
same `structuredClone` rule as the constructor — and `count` the current
length.

#### What `$filter` executes, and what it refuses

Expand Down
32 changes: 28 additions & 4 deletions packages/core/src/adapters/ValueDataSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,8 +1051,27 @@ export class ValueDataSource<T = any> implements DataSource<T> {
private mutationListeners = new Set<(event: DataSourceMutationEvent<T>) => void>();

constructor(config: ValueDataSourceConfig<T>) {
// Deep clone to prevent external mutation
this.items = JSON.parse(JSON.stringify(config.items));
// Deep clone to prevent external mutation.
//
// `structuredClone`, NOT a `JSON.parse(JSON.stringify(...))` round-trip
// (objectui#9175, maintainer ruling A on objectui#9061). The clone exists
// only to stop a caller mutating rows this read-only query source already
// handed out; it was never a serialization boundary, and the round-trip
// quietly made it one. Everything routed through `provider: 'value'` had to
// survive `JSON.stringify` — so a `Date` came back as a string, keys whose
// value was `undefined` disappeared, a cycle threw, and objectui#6018's
// pinned guarantee ("an inline value never has to be serializable at all")
// became false the moment a renderer routed its inline rows through this
// adapter to honour `filter` / `sort` / the objectui#7210 ceiling.
//
// `structuredClone` handles cycles, `Date`, `Map`/`Set`, `BigInt` and typed
// arrays, and is already an unguarded runtime requirement of published
// ObjectUI packages (`@object-ui/app-shell`, `@object-ui/plugin-designer`).
// It still throws `DataCloneError` on a function or a DOM node — that is
// deliberate and stays LOUD: ⛔ no `try`/`catch` fallback here, because
// falling back to the round-trip would restore exactly the silent
// flattening this replaces.
this.items = structuredClone(config.items);
this.idField = config.idField;
}

Expand Down Expand Up @@ -1291,8 +1310,13 @@ export class ValueDataSource<T = any> implements DataSource<T> {
return this.items.length;
}

/** Get a snapshot of all items (cloned) */
/**
* Get a snapshot of all items (cloned).
*
* Same clone as the constructor and for the same reason — see the note
* there: `structuredClone`, never a JSON round-trip (objectui#9175).
*/
getAll(): T[] {
return JSON.parse(JSON.stringify(this.items));
return structuredClone(this.items);
}
}
Loading
Loading