@toclocoinc/lattice-grid 1.24.0 → 1.26.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
@@ -4,7 +4,7 @@
4
4
  dependencies, no build step required. Optional adapters for React, Vue, Svelte
5
5
  and Web Components ship alongside it.
6
6
 
7
- Version 1.24.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
7
+ Version 1.26.0 · [latticegrid.dev](https://www.latticegrid.dev) · TOCLOCO Inc
8
8
 
9
9
  ---
10
10
 
package/docs/API.html CHANGED
@@ -1151,10 +1151,70 @@ grid.overlay.hide();</code></pre>
1151
1151
  <tr><td class="sig">settle(id, ok, reason?)</td><td class="type">boolean</td><td class="desc">Report the outcome of an optimistic write. Only needed with <code>edit.confirm: 'manual'</code>; the id arrives on <code>cell:pending</code>.</td></tr>
1152
1152
  <tr><td class="sig">pending()</td><td class="type">OpenWrite[]</td><td class="desc">Writes still awaiting an outcome. Empty unless <code>edit.commit</code> is set.</td></tr>
1153
1153
  <tr><td class="sig">status(key, colId)</td><td class="type">'pending' | null</td><td class="desc">Whether a cell has a write in flight.</td></tr>
1154
+ <tr><td class="sig">addRow(row)</td><td class="type">string | null</td><td class="desc">Append a row optimistically and persist it (over a source declaring <code>mutate.append</code>). Returns the client temp key; on the server key it fires <code>row:confirmed</code> after rekeying selection, expansion, focus and in-flight cell edits. <code>null</code> when append is unavailable.</td></tr>
1155
+ <tr><td class="sig">deleteRow(key)</td><td class="type">string | null</td><td class="desc">Delete a row optimistically and persist it (over a source declaring <code>mutate.delete</code>). Tombstones then confirms, or restores on refusal. <code>null</code> when delete is unavailable.</td></tr>
1156
+ <tr><td class="sig">settleRow(id, ok, reason?, reconcile?)</td><td class="type">boolean</td><td class="desc">Report the outcome of a structural op. Only needed with <code>edit.confirm: 'manual'</code>; the id arrives on <code>row:pending</code>.</td></tr>
1157
+ <tr><td class="sig">rowStatus(key)</td><td class="type">'pending' | null</td><td class="desc">Whether a row has an append/delete in flight.</td></tr>
1158
+ <tr><td class="sig">pendingRows()</td><td class="type">OpenRowOp[]</td><td class="desc">Structural ops still awaiting an outcome. Empty unless the source can append or delete.</td></tr>
1154
1159
  </tbody>
1155
1160
  </table>
1156
1161
  </div>
1157
1162
 
1163
+ <p class="section-note">
1164
+ <strong>Appending and deleting rows.</strong> Over a remote source whose adapter declares
1165
+ <code>mutate.append</code>/<code>mutate.delete</code>, <code>grid.edit.addRow</code> and
1166
+ <code>grid.edit.deleteRow</code> are the structural counterparts of the cell edit path. An
1167
+ appended row shows at once under a client temp key; when the server hands back the real key the
1168
+ row is rekeyed everywhere the grid tracks it &mdash; the source row, selection, expansion,
1169
+ focus and any in-flight cell edits all follow &mdash; and <code>row:confirmed</code> fires. A
1170
+ delete tombstones the row immediately and either purges it on confirmation or restores it on
1171
+ refusal. The example drives both against a mock adapter, and shows the rekey moving a selection:
1172
+ </p>
1173
+ <pre data-run="js" data-expect="srv-1 selected; deleted" data-covers="event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
1174
+ <span class="kw">const</span> { createPushdownSource } = <span class="kw">await</span> import('../packages/core/src/source/pushdown.js');
1175
+
1176
+ <span class="cmt">// A mock adapter that persists append and delete. append returns the server key.</span>
1177
+ <span class="kw">let</span> nextId = 1;
1178
+ <span class="kw">const</span> adapter = {
1179
+ name: 'mock',
1180
+ capabilities: { sort: <span class="kw">true</span>, mutate: { append: <span class="kw">true</span>, delete: <span class="kw">true</span>, returning: 'key' } },
1181
+ <span class="kw">async</span> execute() { <span class="kw">return</span> { rows: [], total: 0 }; },
1182
+ <span class="kw">async</span> mutate(op) {
1183
+ <span class="kw">if</span> (op.kind === 'append') <span class="kw">return</span> { ok: <span class="kw">true</span>, keys: [`srv-${nextId++}`] };
1184
+ <span class="kw">return</span> { ok: <span class="kw">true</span> }; <span class="cmt">// delete confirmed</span>
1185
+ },
1186
+ };
1187
+
1188
+ <span class="kw">const</span> grid = createHeadlessGrid({
1189
+ rowKey: 'id',
1190
+ columns: [{ field: 'id' }, { field: 'name' }],
1191
+ source: createPushdownSource({ adapter, edit: <span class="kw">true</span> }),
1192
+ selection: 'multiple',
1193
+ edit: <span class="kw">true</span>,
1194
+ });
1195
+
1196
+ <span class="cmt">// The structural lifecycle events, bridged onto the grid's own bus.</span>
1197
+ <span class="kw">const</span> fired = [];
1198
+ grid.on('row:pending', (e) =&gt; fired.push(`pending:${e.kind}`));
1199
+ grid.on('row:confirmed', (e) =&gt; fired.push(`confirmed:${e.kind}`));
1200
+ grid.on('row:reverted', (e) =&gt; fired.push(`reverted:${e.kind}`));
1201
+ grid.on('row:conflict', () =&gt; fired.push('conflict'));
1202
+
1203
+ <span class="cmt">// Append: shows immediately under a temp key, then rekeys to the server key.</span>
1204
+ <span class="kw">const</span> temp = grid.edit.addRow({ name: 'Ada' });
1205
+ grid.selection.set([temp]); <span class="cmt">// select the optimistic row</span>
1206
+ <span class="kw">await</span> <span class="kw">new</span> Promise((r) =&gt; setTimeout(r, 0)); <span class="cmt">// let mutate resolve; row:confirmed fires</span>
1207
+ <span class="kw">const</span> movedTo = grid.selection.keys()[0]; <span class="cmt">// selection followed the rekey</span>
1208
+
1209
+ <span class="cmt">// Delete: tombstones then confirms.</span>
1210
+ grid.edit.deleteRow(movedTo);
1211
+ <span class="kw">await</span> <span class="kw">new</span> Promise((r) =&gt; setTimeout(r, 0));
1212
+ <span class="kw">const</span> gone = grid.rows.byKey(movedTo) === <span class="kw">undefined</span>;
1213
+
1214
+ grid.destroy();
1215
+ <span class="cmt">// fired: pending:append, confirmed:append, pending:delete, confirmed:delete</span>
1216
+ <span class="kw">return</span> `${movedTo} selected; ${gone ? 'deleted' : 'still-there'}`;</code></pre>
1217
+
1158
1218
  <p class="section-note">
1159
1219
  <strong>Previewing a bulk paste.</strong> A paste can rewrite dozens of cells at once, and one
1160
1220
  that lands somewhere unexpected looks exactly like one that worked. Set
@@ -2589,6 +2649,8 @@ createGrid(host, { source, columns: [...] });</code></pre>
2589
2649
  <tr><td class="name">fetch</td><td class="type">typeof fetch</td><td class="type">the global <code>fetch</code></td><td class="desc">Your own fetch, for a token that expires, a proxy, or a non-browser runtime. The adapter bundles no HTTP client. See <a href="#adapter-auth">authenticating</a>.</td></tr>
2590
2650
  <tr><td class="name">count</td><td class="type">boolean</td><td class="type">true</td><td class="desc">Whether to ask for <code>$count=true</code> and read <code>@odata.count</code>. On by default because the grid sizes its scrollbar from the total; set <code>false</code> for a server that does not support it.</td></tr>
2591
2651
  <tr><td class="name">search</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Whether the server implements <code>$search</code>. Off by default, so quick-filter text stays with the grid until you confirm the endpoint honours it; <code>true</code> pushes it as <code>$search</code>.</td></tr>
2652
+ <tr><td class="name">edit</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Opt into cell write-back. Off keeps the source read-only; <code>true</code> advertises <code>mutate: { update: true, returning: 'row' }</code>, so a committed cell edit is persisted with <code>PATCH</code>. Wave 1 wires <code>update</code> only.</td></tr>
2653
+ <tr><td class="name">key</td><td class="type">string</td><td class="type">the row key</td><td class="desc">The key property a cell update names in its entity-key URL segment, e.g. <code>/Orders(&lt;key&gt;)</code>. Write-back only.</td></tr>
2592
2654
  </tbody>
2593
2655
  </table>
2594
2656
  </div>
@@ -2615,10 +2677,51 @@ createGrid(host, { source, columns: [...] });</code></pre>
2615
2677
  <tr><td class="name">encodeFilter</td><td class="type">(filters: object) =&gt; string</td><td class="type"><code>JSON.stringify</code></td><td class="desc">How the pushed condition tree becomes the <code>filter</code> parameter's value. Override it to emit whatever query language your service parses instead of JSON.</td></tr>
2616
2678
  <tr><td class="name">rows</td><td class="type">(body: unknown) =&gt; unknown[]</td><td class="type">body itself if an array, else <code>body.rows</code> then <code>body.data</code></td><td class="desc">Pulls the row array out of the response body, for an envelope that nests it somewhere else.</td></tr>
2617
2679
  <tr><td class="name">total</td><td class="type">(body: unknown, rows: unknown[]) =&gt; number</td><td class="type"><code>body.total</code> then <code>body.count</code>, else the page length</td><td class="desc">Reads the count of <em>all</em> matching rows, not the page. The grid sizes its scrollbar from it, so a page-sized total makes a large result look like one page.</td></tr>
2680
+ <tr><td class="name">edit</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Opt into write-back. Off keeps the source read-only; <code>true</code> advertises <code>mutate: { update: true, delete: true, returning }</code>, so a committed cell edit is persisted with <code>PATCH</code> and a row delete with <code>DELETE</code>. Add-row needs the row-keyed pending engine and is refused loudly.</td></tr>
2681
+ <tr><td class="name">returning</td><td class="type">'row' | 'none'</td><td class="type"><code>none</code></td><td class="desc">The reconcile contract for a successful write. <code>none</code> is last-write-wins — the optimistic value stands; <code>row</code> reads the server's authoritative row (via <code>writeRow</code>) back before confirm.</td></tr>
2682
+ <tr><td class="name">encodeMutation</td><td class="type">(op: MutationOp) =&gt; { method: string, url: string, headers?: object, body?: unknown }</td><td class="type">the default verb map</td><td class="desc">Full control of a mutation's HTTP shape, overriding the default method, URL and body. Supersedes <code>writeUrlFor</code>.</td></tr>
2683
+ <tr><td class="name">writeUrlFor</td><td class="type">(op: MutationOp) =&gt; string</td><td class="type"><code>${url}/${key}</code></td><td class="desc">The endpoint a single mutation targets, when the default per-row URL is not what the service uses. Ignored when <code>encodeMutation</code> is supplied.</td></tr>
2684
+ <tr><td class="name">writeRow</td><td class="type">(body: unknown) =&gt; unknown</td><td class="type">the entity, or <code>body.row</code>/<code>body.data</code></td><td class="desc">Pulls the authoritative row out of a write response when <code>returning: 'row'</code>.</td></tr>
2618
2685
  </tbody>
2619
2686
  </table>
2620
2687
  </div>
2621
2688
 
2689
+ <p class="section-note">
2690
+ <strong>Persisting a cell edit.</strong> With <code>edit: true</code> the adapter advertises
2691
+ <code>mutate</code>, so a committed cell edit is sent as an HTTP request. REST has no universal
2692
+ write convention, so the request is yours to shape: <code>writeUrlFor</code> names the per-row
2693
+ endpoint, <code>encodeMutation</code> takes full control of method and body, and
2694
+ <code>writeRow</code> reads the authoritative row back when <code>returning: 'row'</code>.
2695
+ </p>
2696
+ <pre data-run="js" data-expect="shipped" data-covers="export:restAdapter"><code><span class="kw">const</span> { restAdapter } = <span class="kw">await</span> import('../packages/core/src/index.js');
2697
+
2698
+ <span class="cmt">// The per-row endpoint a mutation targets.</span>
2699
+ <span class="kw">const</span> writeUrlFor = (op) =&gt; `/api/orders/${op.key}`;
2700
+
2701
+ <span class="kw">let</span> sentMethod;
2702
+ <span class="kw">const</span> adapter = restAdapter({
2703
+ url: '/api/orders',
2704
+ edit: <span class="kw">true</span>, <span class="cmt">// opt into write-back (advertises mutate.update / delete)</span>
2705
+ returning: 'row', <span class="cmt">// reconcile to the server's authoritative row</span>
2706
+ writeUrlFor,
2707
+ <span class="cmt">// Full control of the request; supersedes the default envelope.</span>
2708
+ encodeMutation: (op) =&gt; ({
2709
+ method: 'PATCH',
2710
+ url: writeUrlFor(op),
2711
+ headers: { 'Content-Type': 'application/json' },
2712
+ body: JSON.stringify(op.patch),
2713
+ }),
2714
+ <span class="cmt">// Pull the authoritative row out of this service's envelope.</span>
2715
+ writeRow: (body) =&gt; body.record,
2716
+ fetch: <span class="kw">async</span> (url, init) =&gt; {
2717
+ sentMethod = init.method;
2718
+ <span class="kw">return</span> { ok: <span class="kw">true</span>, status: 200, json: <span class="kw">async</span> () =&gt; ({ record: { id: '42', status: 'shipped' } }) };
2719
+ },
2720
+ });
2721
+
2722
+ <span class="kw">const</span> result = <span class="kw">await</span> adapter.mutate({ kind: 'update', key: '42', patch: { status: 'shipped' } });
2723
+ <span class="kw">return</span> result.rows[0].status; <span class="cmt">// 'shipped', read back from the server</span></code></pre>
2724
+
2622
2725
  <h5 id="duckdb-options"><code>duckdbAdapter</code></h5>
2623
2726
  <div class="table-wrap">
2624
2727
  <table>
@@ -2627,6 +2730,9 @@ createGrid(host, { source, columns: [...] });</code></pre>
2627
2730
  <tr><td class="name">connection</td><td class="type">object</td><td class="type">—</td><td class="desc">A live connection exposing <code>query</code>, and ideally <code>prepare</code>. Required. A connection without <code>prepare</code> is used only for unfiltered queries, because interpolating a user's filter into SQL is worse than not filtering.</td></tr>
2628
2731
  <tr><td class="name">from</td><td class="type">string</td><td class="type">—</td><td class="desc">A table, a view, or any FROM expression. Required. <code>read_parquet('s3://bucket/*.parquet')</code> is as valid as a table name.</td></tr>
2629
2732
  <tr><td class="name">fields</td><td class="type">string[]</td><td class="type">everything (<code>SELECT *</code>)</td><td class="desc">The columns to select. Name them to narrow the projection when the grid shows a subset of a wide table.</td></tr>
2733
+ <tr><td class="name">writable</td><td class="type">boolean</td><td class="type">false</td><td class="desc">Allow cell updates against a plain writable table. Off keeps the source read-only, so a <code>from</code> that is a view or an expression can never be mutated by accident. Wave 1 wires <code>update</code> only.</td></tr>
2734
+ <tr><td class="name">keyField</td><td class="type">string</td><td class="type">—</td><td class="desc">The key column a cell update targets in its <code>WHERE</code>. Write-back is refused unless this names a real column, because an <code>UPDATE</code> without a unique key could touch more than one row.</td></tr>
2735
+ <tr><td class="name">returning</td><td class="type">'row' | 'none'</td><td class="type"><code>row</code></td><td class="desc">The reconcile contract for a successful update. <code>row</code> appends <code>RETURNING *</code> and reconciles server truth (computed columns, triggers); <code>none</code> keeps the optimistic value.</td></tr>
2630
2736
  </tbody>
2631
2737
  </table>
2632
2738
  </div>
@@ -2645,6 +2751,8 @@ createGrid(host, { source, columns: [...] });</code></pre>
2645
2751
  <tr><td class="name">limit</td><td class="type">number</td><td class="type">server default</td><td class="desc">Caps rows <em>scanned</em>, not matched — which is why every request also sends <code>countOnly</code> to reveal the true match count.</td></tr>
2646
2752
  <tr><td class="name">headers</td><td class="type">Record&lt;string, string&gt;</td><td class="type">{}</td><td class="desc">Extra headers merged over the bearer token, for a gateway that needs its own.</td></tr>
2647
2753
  <tr><td class="name">fetch</td><td class="type">typeof fetch</td><td class="type">the global <code>fetch</code></td><td class="desc">Your own fetch, for a proxy or a non-browser runtime.</td></tr>
2754
+ <tr><td class="name">writeUrl</td><td class="type">string</td><td class="type">the default write endpoint</td><td class="desc">Where record mutations are POSTed, when the deployment's write endpoint differs from the default. Write-back persists update, delete and add-row.</td></tr>
2755
+ <tr><td class="name">encodeCreate</td><td class="type">(row: unknown) =&gt; Record&lt;string, unknown&gt;</td><td class="type">the row's own fields</td><td class="desc">Maps a new grid row to the DemandFlow <code>fields</code> an append needs — its required <code>entity</code>/<code>level</code>/<code>comboKey</code> — since the structural append only knows the row's own fields.</td></tr>
2648
2756
  </tbody>
2649
2757
  </table>
2650
2758
  </div>
@@ -3198,6 +3306,10 @@ off(); <span class="cmt">// on() returns i
3198
3306
  <tr><td class="name">row:dblclicked</td><td class="type">{ row, key, index, event }</td><td class="desc"></td></tr>
3199
3307
  <tr><td class="name">row:edit:start</td><td class="type">{ row, key, colId, column }</td><td class="desc">Replaces the cell pair when <code>edit.mode</code> is <code>'row'</code>.</td></tr>
3200
3308
  <tr><td class="name">row:edit:end</td><td class="type">{ row, key, colId, valid, errors }</td><td class="desc">In row mode an invalid cell blocks the whole commit and the session stays open.</td></tr>
3309
+ <tr><td class="name">row:pending</td><td class="type">{ id, kind, key, temp, row }</td><td class="desc">A row was appended or deleted optimistically, not yet durable. <code>kind</code> is <code>'append'</code> or <code>'delete'</code>; <code>temp: true</code> means an appended row under a client temp key. Only over a source that declares <code>mutate.append</code>/<code>delete</code>.</td></tr>
3310
+ <tr><td class="name">row:confirmed</td><td class="type">{ id, kind, key, tempKey?, row, superseded }</td><td class="desc">The append or delete reached the server. For an append the row has already been rekeyed from <code>tempKey</code> to the server <code>key</code> — selection, expansion, focus and in-flight cell edits followed.</td></tr>
3311
+ <tr><td class="name">row:reverted</td><td class="type">{ id, kind, key, tempKey?, reason, row, superseded, applied }</td><td class="desc">The append or delete failed: an appended row is removed, a deleted row restored. <code>applied: false</code> means a newer op owned the key, so nothing was undone.</td></tr>
3312
+ <tr><td class="name">row:conflict</td><td class="type">{ id, kind, key, serverRow, row }</td><td class="desc">The op succeeded but the server row had moved underneath it. Last-write-wins: <code>serverRow</code> carries the server's truth so the divergence is surfaced, never swallowed.</td></tr>
3201
3313
  <tr><td class="name">cell:contextmenu</td><td class="type">{ ...cellParams, row }</td><td class="desc">Right-click on a cell.</td></tr>
3202
3314
  <tr><td class="name">sort:changed</td><td class="type">{ sort }</td><td class="desc">The full sort entry list.</td></tr>
3203
3315
  <tr><td class="name">filter:changed</td><td class="type">{ filters } | { quick }</td><td class="desc">The condition tree or the quick filter changed.</td></tr>
@@ -4373,7 +4485,7 @@ grid.destroy();
4373
4485
  <p class="section-note">Each documented event is subscribed to and unsubscribed on every build. A consumer
4374
4486
  wiring a handler to a renamed event gets silence, which is indistinguishable from an event that
4375
4487
  has not fired yet — so the name is checked rather than left to be discovered.</p>
4376
- <pre data-run="js" data-expect="102" data-covers="event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4488
+ <pre data-run="js" data-expect="106" data-covers="event:cell:changed event:cell:clicked event:cell:confirmed event:cell:conflict event:cell:contextmenu event:cell:dblclicked event:cell:edit:end event:cell:edit:start event:cell:pending event:cell:reverted event:clipboard:copy event:column:filter:open event:column:grouped event:column:menu:open event:column:pivoted event:column:resized event:columns:changed event:columns:tagged event:comment:added event:comment:deleted event:comment:edited event:comment:failed event:comment:indexLoaded event:comment:resolved event:comment:threadClosed event:comment:threadOpened event:comment:unresolved event:destroy event:detail:toggled event:diff:changed event:diff:swapped event:export:progress event:facet:computed event:facet:expanded event:facet:failed event:facet:filtered event:form:closed event:form:error event:form:opened event:form:saved event:formatting:changed event:group:toggled event:header:contextmenu event:highlight:changed event:history:applied event:history:changed event:licence:changed event:page:changed event:permissions:changed event:presence:failed event:presence:joined event:presence:left event:presence:lockRefused event:presence:published event:presence:updated event:presentation:captured event:presentation:changed event:presentation:ended event:presentation:scale event:presentation:spotlight event:presentation:started event:presentation:view event:range:changed event:ready event:redaction:changed event:render:done event:render:first event:row:clicked event:row:copied event:row:dblclicked event:row:edit:end event:row:edit:start event:row:moved event:row:received event:row:sent event:rows:deferred event:rows:paused event:rows:queued event:rows:resumed event:scroll event:scroll:end event:selection:changed event:size:changed event:source:error event:stream:chunk event:stream:end event:stream:evicted event:timeline:attached event:timeline:detached event:timeline:seek event:timeline:seeking event:toolpanel:focus event:tree:loadAborted event:tree:loadFailed event:tree:loaded event:tree:loading event:view:applied event:view:default event:view:removed event:view:renamed event:view:saved event:views:changed event:row:pending event:row:confirmed event:row:reverted event:row:conflict"><code><span class="kw">const</span> { createHeadlessGrid } = <span class="kw">await</span> import('../packages/core/src/index.js');
4377
4489
 
4378
4490
  <span class="cmt">// Every documented event name, checked against the bus that would carry it.</span>
4379
4491
  <span class="cmt">// Subscribing to a name the grid does not know is the failure this catches:</span>
@@ -4398,6 +4510,7 @@ grid.destroy();
4398
4510
  'presentation:view', 'range:changed', 'ready', 'redaction:changed',
4399
4511
  'render:done', 'render:first', 'row:clicked', 'row:copied',
4400
4512
  'row:dblclicked', 'row:edit:end', 'row:edit:start', 'row:moved',
4513
+ 'row:pending', 'row:confirmed', 'row:reverted', 'row:conflict',
4401
4514
  'row:received', 'row:sent', 'rows:deferred', 'rows:paused',
4402
4515
  'rows:queued', 'rows:resumed', 'scroll', 'scroll:end',
4403
4516
  'selection:changed', 'size:changed', 'source:error', 'stream:chunk',
@@ -5685,6 +5798,11 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
5685
5798
  <tr><td class="name">settle</td><td class="type">(</td><td class="desc">Report the outcome of an in-flight write (§18.3; §5.1-5.2 reconcile). `reconcile` carries server truth on a successful settle: `value` is a server-authoritative value written back before `cell:confirmed` (`returning: 'row'`); `conflict.serverRow` surfaces a last-write-wins conflict via `cell:conflict`. Omit both to keep the optimistic value.</td></tr>
5686
5799
  <tr><td class="name">pending</td><td class="type">(): OpenWrite[]</td><td class="desc"></td></tr>
5687
5800
  <tr><td class="name">status</td><td class="type">(key: string, colId: string): 'pending' | null</td><td class="desc"></td></tr>
5801
+ <tr><td class="name">addRow</td><td class="type">(row: object): string | null</td><td class="desc">Append a row to a remote source optimistically and persist it (§5.3), the structural analog of the cell edit path. The row shows immediately under a client temp key, and `adapter.mutate({ kind: 'append', rows: [row] })` is asked to persist it; when the server returns the real key the row is rekeyed everywhere the grid tracks it and `row:confirmed` fires, while a refused append is removed and fires `row:reverted`. Only wired when the source declares `mutate.append`; otherwise it warns once and returns null.</td></tr>
5802
+ <tr><td class="name">deleteRow</td><td class="type">(key: string): string | null</td><td class="desc">Delete a row from a remote source optimistically and persist it (§5.3). The row is tombstoned immediately and `adapter.mutate({ kind: 'delete', keys: [key] })` is asked to remove it; on confirmation the row is purged and `row:confirmed` fires, on refusal it is restored and `row:reverted` fires. Only wired when the source declares `mutate.delete`; otherwise it warns once and returns null.</td></tr>
5803
+ <tr><td class="name">settleRow</td><td class="type">(id: string, ok: boolean, reason?: string, reconcile?: { key?: string; row?: unknown; conflict?: { serverRow?: unknown } }): boolean</td><td class="desc">Report the outcome of an optimistic structural write (§5.3), the counterpart to {@link settle} for `edit.confirm: 'manual'` over a backend that acknowledges an append/delete on a separate channel. The id arrives on `row:pending`.</td></tr>
5804
+ <tr><td class="name">rowStatus</td><td class="type">(key: string): 'pending' | null</td><td class="desc">Whether a row has a structural op in flight (§5.3).</td></tr>
5805
+ <tr><td class="name">pendingRows</td><td class="type">(): OpenRowOp[]</td><td class="desc">Every structural op still awaiting an outcome (§5.3), oldest first; always empty when the source cannot append or delete.</td></tr>
5688
5806
  </tbody>
5689
5807
  </table>
5690
5808
  </div>
@@ -6416,9 +6534,9 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
6416
6534
  <table>
6417
6535
  <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6418
6536
  <tbody>
6419
- <tr><td class="name">append</td><td class="type">boolean</td><td class="desc">The adapter can insert new rows. Wave 1: declared, not yet bridged. <small>(optional)</small></td></tr>
6420
- <tr><td class="name">update</td><td class="type">boolean</td><td class="desc">The adapter can patch existing rows. Wave 1: the wired kind (§4.3 Option A). <small>(optional)</small></td></tr>
6421
- <tr><td class="name">delete</td><td class="type">boolean</td><td class="desc">The adapter can remove rows. Wave 1: declared, not yet bridged. <small>(optional)</small></td></tr>
6537
+ <tr><td class="name">append</td><td class="type">boolean</td><td class="desc">The adapter can insert new rows, bridged by the structural engine (`edit.addRow`, §5.3). <small>(optional)</small></td></tr>
6538
+ <tr><td class="name">update</td><td class="type">boolean</td><td class="desc">The adapter can patch existing rows, bridged by the cell edit path (§4.3 Option A). <small>(optional)</small></td></tr>
6539
+ <tr><td class="name">delete</td><td class="type">boolean</td><td class="desc">The adapter can remove rows, bridged by the structural engine (`edit.deleteRow`, §5.3). <small>(optional)</small></td></tr>
6422
6540
  <tr><td class="name">returning</td><td class="type">'row' | 'key' | 'none'</td><td class="desc">The reconcile contract — what the server hands back after a successful mutation (§5.1). `'row'`: the authoritative row (id, computed columns, timestamps), reconciled before confirm. `'key'`: only the assigned key. `'none'` (the default): nothing — the optimistic value stands (last-write-wins). <small>(optional)</small></td></tr>
6423
6541
  </tbody>
6424
6542
  </table>
@@ -6481,6 +6599,20 @@ return `avg ${avg.value} over ${avg.over.size}; kinds ${WINDOW_KINDS.join('/')};
6481
6599
  </tbody>
6482
6600
  </table>
6483
6601
  </div>
6602
+ <h3 id="type-OpenRowOp">OpenRowOp</h3>
6603
+ <p class="section-note">A structural op (append or delete) still awaiting an outcome (§5.3).</p>
6604
+ <div class="table-wrap">
6605
+ <table>
6606
+ <thead><tr><th>Member</th><th>Type</th><th>Description</th></tr></thead>
6607
+ <tbody>
6608
+ <tr><td class="name">id</td><td class="type">string</td><td class="desc"></td></tr>
6609
+ <tr><td class="name">kind</td><td class="type">'append' | 'delete'</td><td class="desc"></td></tr>
6610
+ <tr><td class="name">key</td><td class="type">string</td><td class="desc"></td></tr>
6611
+ <tr><td class="name">state</td><td class="type">'pending' | 'superseded'</td><td class="desc"></td></tr>
6612
+ <tr><td class="name">age</td><td class="type">number</td><td class="desc"></td></tr>
6613
+ </tbody>
6614
+ </table>
6615
+ </div>
6484
6616
  <h3 id="type-OpenWrite">OpenWrite</h3>
6485
6617
  <div class="table-wrap">
6486
6618
  <table>
@@ -437,7 +437,7 @@
437
437
  <div class="shell">
438
438
  <aside class="rail">
439
439
  <p class="rail__brand">Lattice Grid</p>
440
- <p class="rail__sub">Developer guide · v1.24.0</p>
440
+ <p class="rail__sub">Developer guide · v1.26.0</p>
441
441
  <nav>
442
442
  <div class="rail__group">
443
443
  <span class="rail__label">Start here</span>
@@ -6198,6 +6198,10 @@ el.grid.sort.set([{ col: 'charge', dir: 'desc' }]);</code></pre>
6198
6198
  <tr><td class="name">cell:edit:start</td><td class="desc">An edit session opened.</td></tr>
6199
6199
  <tr><td class="name">cell:pending</td><td class="desc">Applied optimistically, not yet durable. Only with edit.commit.</td></tr>
6200
6200
  <tr><td class="name">cell:reverted</td><td class="desc">The write failed. applied: false means a newer edit owned the cell, so nothing was written back.</td></tr>
6201
+ <tr><td class="name">row:pending</td><td class="desc">A row was appended or deleted optimistically, not yet durable. kind is 'append' or 'delete'. Only over a source that declares mutate.append/delete.</td></tr>
6202
+ <tr><td class="name">row:confirmed</td><td class="desc">The append or delete reached the server. An appended row has already been rekeyed from its temp key to the server key, and selection, expansion, focus and in-flight cell edits followed.</td></tr>
6203
+ <tr><td class="name">row:reverted</td><td class="desc">The append or delete failed: an appended row is removed, a deleted row restored. applied: false means a newer op owned the key, so nothing was undone.</td></tr>
6204
+ <tr><td class="name">row:conflict</td><td class="desc">The op succeeded but the server row had moved underneath it. Last-write-wins with the divergence surfaced: serverRow carries the server's truth.</td></tr>
6201
6205
  <tr><td class="name">form:closed</td><td class="desc">The row form closed without saving.</td></tr>
6202
6206
  <tr><td class="name">form:error</td><td class="desc">A commit from the form failed validation or was rejected.</td></tr>
6203
6207
  <tr><td class="name">form:opened</td><td class="desc">The row form opened.</td></tr>
package/lattice-grid.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Lattice Grid 1.24.0, type declarations
2
+ * Lattice Grid 1.26.0, type declarations
3
3
  * Copyright (c) 2026 TOCLOCO Inc. All rights reserved.
4
4
  * https://latticegrid.dev
5
5
  */
@@ -1144,6 +1144,15 @@ export interface OpenWrite {
1144
1144
  age: number;
1145
1145
  }
1146
1146
 
1147
+ /** A structural op (append or delete) still awaiting an outcome (§5.3). */
1148
+ export interface OpenRowOp {
1149
+ id: string;
1150
+ kind: 'append' | 'delete';
1151
+ key: string;
1152
+ state: 'pending' | 'superseded';
1153
+ age: number;
1154
+ }
1155
+
1147
1156
  /**
1148
1157
  * What an adapter can persist back to its source — the write-back capability
1149
1158
  * (§4.1), declared on `AdapterCapabilities.mutate`. `false` (the default) is
@@ -1151,11 +1160,11 @@ export interface OpenWrite {
1151
1160
  * adapter opts in.
1152
1161
  */
1153
1162
  export interface MutateCapability {
1154
- /** The adapter can insert new rows. Wave 1: declared, not yet bridged. */
1163
+ /** The adapter can insert new rows, bridged by the structural engine (`edit.addRow`, §5.3). */
1155
1164
  append?: boolean;
1156
- /** The adapter can patch existing rows. Wave 1: the wired kind (§4.3 Option A). */
1165
+ /** The adapter can patch existing rows, bridged by the cell edit path (§4.3 Option A). */
1157
1166
  update?: boolean;
1158
- /** The adapter can remove rows. Wave 1: declared, not yet bridged. */
1167
+ /** The adapter can remove rows, bridged by the structural engine (`edit.deleteRow`, §5.3). */
1159
1168
  delete?: boolean;
1160
1169
  /**
1161
1170
  * The reconcile contract — what the server hands back after a successful
@@ -2809,6 +2818,7 @@ export type EventName =
2809
2818
  | 'cell:clicked' | 'cell:dblclicked' | 'cell:contextmenu'
2810
2819
  | 'cell:edit:start' | 'cell:edit:end' | 'row:edit:start' | 'row:edit:end'
2811
2820
  | 'row:clicked' | 'row:dblclicked'
2821
+ | 'row:pending' | 'row:confirmed' | 'row:reverted' | 'row:conflict'
2812
2822
  | 'form:opened' | 'form:closed' | 'form:saved' | 'form:error'
2813
2823
  /* Query */
2814
2824
  | 'sort:changed' | 'filter:changed' | 'group:toggled'
@@ -3167,6 +3177,52 @@ export interface EditApi {
3167
3177
  ): boolean;
3168
3178
  pending(): OpenWrite[];
3169
3179
  status(key: string, colId: string): 'pending' | null;
3180
+ /**
3181
+ * Append a row to a remote source optimistically and persist it (§5.3), the
3182
+ * structural analog of the cell edit path. The row shows immediately under a
3183
+ * client temp key, and `adapter.mutate({ kind: 'append', rows: [row] })` is
3184
+ * asked to persist it; when the server returns the real key the row is rekeyed
3185
+ * everywhere the grid tracks it and `row:confirmed` fires, while a refused
3186
+ * append is removed and fires `row:reverted`. Only wired when the source
3187
+ * declares `mutate.append`; otherwise it warns once and returns null.
3188
+ * @param row the new row (it need not carry a key yet)
3189
+ * @returns the client temp key the row is tracked under, or null when append
3190
+ * is not available on this source
3191
+ */
3192
+ addRow(row: object): string | null;
3193
+ /**
3194
+ * Delete a row from a remote source optimistically and persist it (§5.3). The
3195
+ * row is tombstoned immediately and `adapter.mutate({ kind: 'delete', keys: [key] })`
3196
+ * is asked to remove it; on confirmation the row is purged and `row:confirmed`
3197
+ * fires, on refusal it is restored and `row:reverted` fires. Only wired when
3198
+ * the source declares `mutate.delete`; otherwise it warns once and returns null.
3199
+ * @param key the row key to remove
3200
+ * @returns the id the op is tracked under, or null when delete is not available
3201
+ */
3202
+ deleteRow(key: string): string | null;
3203
+ /**
3204
+ * Report the outcome of an optimistic structural write (§5.3), the counterpart
3205
+ * to {@link settle} for `edit.confirm: 'manual'` over a backend that
3206
+ * acknowledges an append/delete on a separate channel. The id arrives on
3207
+ * `row:pending`.
3208
+ * @param id the op id from `row:pending`
3209
+ * @param ok true when the op reached the server
3210
+ * @param reason why it failed, carried on `row:reverted`
3211
+ * @param reconcile server key / row / conflict for a successful append settle
3212
+ * @returns true when the id named an op still awaiting an outcome
3213
+ */
3214
+ settleRow(id: string, ok: boolean, reason?: string, reconcile?: { key?: string; row?: unknown; conflict?: { serverRow?: unknown } }): boolean;
3215
+ /**
3216
+ * Whether a row has a structural op in flight (§5.3).
3217
+ * @param key the row key
3218
+ * @returns `'pending'`, or null when the row is settled
3219
+ */
3220
+ rowStatus(key: string): 'pending' | null;
3221
+ /**
3222
+ * Every structural op still awaiting an outcome (§5.3), oldest first; always
3223
+ * empty when the source cannot append or delete.
3224
+ */
3225
+ pendingRows(): OpenRowOp[];
3170
3226
  }
3171
3227
 
3172
3228
  export interface ScrollApi {
@@ -4366,6 +4422,18 @@ export function applyResidual(
4366
4422
  export function odataAdapter(options: {
4367
4423
  url: string; fetch?: typeof fetch; headers?: Record<string, string>;
4368
4424
  count?: boolean; search?: boolean;
4425
+ /**
4426
+ * The key property a cell update targets in its entity-key URL segment
4427
+ * (`/Orders(<key>)`). Write-back only (§7 OData, wave 1).
4428
+ */
4429
+ key?: string;
4430
+ /**
4431
+ * Opt the adapter into cell write-back. `false` (the default) declares the
4432
+ * source read-only; `true` advertises `mutate: { update: true, returning: 'row' }`
4433
+ * so a committed cell edit is persisted with `PATCH`. Wave 1 wires `update`
4434
+ * only; append and delete are deferred.
4435
+ */
4436
+ edit?: boolean;
4369
4437
  }): PushdownAdapter & { urlFor(query: RemoteRequest): string };
4370
4438
 
4371
4439
  /**
@@ -4378,6 +4446,35 @@ export function restAdapter(options: {
4378
4446
  capabilities?: PushdownCapabilities; operators?: string[];
4379
4447
  encodeFilter?: (filters: object) => string;
4380
4448
  rows?: (body: unknown) => unknown[]; total?: (body: unknown, rows: unknown[]) => number;
4449
+ /**
4450
+ * Opt the adapter into cell write-back. `false` (the default) declares the
4451
+ * source read-only; `true` advertises `mutate: { update: true, delete: true, returning }`
4452
+ * so a committed cell edit is persisted with `PATCH` and a row delete with
4453
+ * `DELETE`. Append needs the row-keyed pending engine and is refused loudly.
4454
+ */
4455
+ edit?: boolean;
4456
+ /**
4457
+ * The reconcile contract for a successful write (§5.1). `'none'` (the default)
4458
+ * is last-write-wins — the optimistic value stands; `'row'` reads the server's
4459
+ * authoritative row (via {@link writeRow}) back before confirm.
4460
+ */
4461
+ returning?: 'row' | 'none';
4462
+ /**
4463
+ * Full control of a mutation's HTTP shape, overriding the default verb map and
4464
+ * URL. Given the {@link MutationOp}, return the method, url and optional
4465
+ * headers/body actually sent. Overriding this supersedes {@link writeUrlFor}.
4466
+ */
4467
+ encodeMutation?: (op: MutationOp) => { method: string, url: string, headers?: Record<string, string>, body?: unknown };
4468
+ /**
4469
+ * The endpoint a single mutation targets, when the default `${url}/${key}` is
4470
+ * not what the service uses. Ignored when {@link encodeMutation} is supplied.
4471
+ */
4472
+ writeUrlFor?: (op: MutationOp) => string;
4473
+ /**
4474
+ * Pull the authoritative row out of a write response when `returning: 'row'`.
4475
+ * Tolerates the plain entity, a `{ row }` or a `{ data }` envelope by default.
4476
+ */
4477
+ writeRow?: (body: unknown) => unknown;
4381
4478
  }): PushdownAdapter & { urlFor(query: RemoteRequest): string };
4382
4479
 
4383
4480
  /**
@@ -4400,6 +4497,24 @@ export function duckdbAdapter(options: {
4400
4497
  from: string;
4401
4498
  /** Columns to select. Everything by default. */
4402
4499
  fields?: string[];
4500
+ /**
4501
+ * The key column a cell update targets in its `WHERE`. Write-back is refused
4502
+ * unless this names a real column, because an `UPDATE` without a unique key
4503
+ * could touch more than one row (§7 DuckDB, wave 1).
4504
+ */
4505
+ keyField?: string;
4506
+ /**
4507
+ * Allow cell updates against a plain writable table. `false` (the default)
4508
+ * keeps the source read-only, so a `from` that is a view or an expression can
4509
+ * never be mutated by accident. Wave 1 wires `update` only.
4510
+ */
4511
+ writable?: boolean;
4512
+ /**
4513
+ * The reconcile contract for a successful update (§5.1). `'row'` (the default)
4514
+ * appends `RETURNING *` and reconciles server truth (computed columns,
4515
+ * triggers); `'none'` keeps the optimistic value (last-write-wins).
4516
+ */
4517
+ returning?: 'row' | 'none';
4403
4518
  }): PushdownAdapter & { sqlFor(query: RemoteRequest): { sql: string; params: unknown[] } };
4404
4519
 
4405
4520
  /**
@@ -4428,6 +4543,18 @@ export function dfqlAdapter(options: {
4428
4543
  limit?: number;
4429
4544
  fetch?: typeof fetch;
4430
4545
  headers?: Record<string, string>;
4546
+ /**
4547
+ * Where record mutations are POSTed, when the default write endpoint is not
4548
+ * what the deployment uses. Write-back persists update, delete and add-row
4549
+ * (§7 DFQL, card 771).
4550
+ */
4551
+ writeUrl?: string;
4552
+ /**
4553
+ * Map a new grid row to the DemandFlow `fields` an append needs — its required
4554
+ * `entity`/`level`/`comboKey` — since the grid's structural append only knows
4555
+ * the row's own fields. Called once per appended row.
4556
+ */
4557
+ encodeCreate?: (row: unknown) => Record<string, unknown>;
4431
4558
  }): PushdownAdapter & { linesFor(query: RemoteRequest): object[] };
4432
4559
 
4433
4560
  export function createGrid(element: HTMLElement, config?: GridConfig): Grid;