Skip to content

1.16.0 - #65

Merged
ONyklicek merged 18 commits into
1.xfrom
1.16.0
Aug 2, 2026
Merged

1.16.0#65
ONyklicek merged 18 commits into
1.xfrom
1.16.0

Conversation

@ONyklicek

@ONyklicek ONyklicek commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Added

  • Search understands more than one substring — Table::search(). The box matched whatever was typed as a single LIKE '%term%' across every searchable column, which meant Ada Lovelace could not find the row whose first name is in one column and surname in another, and a number could only ever be searched for, never compared. Three capabilities are now opt-in per table, through a fluent SearchConfig: tokenize() splits on spaces and ANDs the words — each word still ORs across all columns, which is exactly what makes a name spanning two columns match — with double quotes keeping a phrase together and never being read as an operator; ranges() reads >100, >=100, <10, <=10, =42, 10..20, 10.., ..20 and the same over dates; wildcards() lets * and ? stand for runs and single characters. Everything is off by default, so an unconfigured table matches byte-for-byte what it always did — the whole term, one group, one substring. A typed date is read at the granularity it was written (2026-01-31 is that day, 2026-01 that month, 2026 that year), so <=2026-01-31 still includes a row stamped 23:30 on the 31st, which is the off-by-a-day this kind of feature usually ships with. A comparison is only ever asked of a column that can answer it — the value type comes from the model's casts, or from the new Column::searchAs('numeric'|'date') where the casts cannot speak for the column — and a comparison no column can answer (>100 on a table of names) is searched as the literal text that was typed rather than contributing an empty WHERE group that matches every row. The engine lives in wire-core (Core\Query\Search\) as a parser producing tokens, a compiler turning a token plus a clause into SQL, and the three driver strategies reduced to the one thing that genuinely differs between engines: LIKE versus ILIKE. Comparisons are plain portable SQL and are therefore built once rather than three times over. searchAs('code') covers the structured reference — 8866 01, 8866 02 — where the series is shared and the tail is zero-padded: typing 8866 01..08 yields one BETWEEN '8866 01' AND '8866 08' rather than a LIKE per number in the range. The space inside such a code is also what splits the term, so the range arrives separated from its series; rather than letting one reading win at parse time (8866 01..08 and praha 10..20 are the same shape and cannot be told apart syntactically), the range carries the word typed before it and each column takes the reading it can answer — a code column completes both bounds with the series, a numeric column ignores it and compares 1..8. Comparing as text only orders correctly while the width is constant, which is the assertion searchAs('code') makes, so the number must be typed as it is stored; a range crossing a width boundary is completed rather than refused (8866 50..100 reads as 050..100, since a hundredth member can only exist in a three-digit series). See docs/table/overview.md § Search syntax.
  • Four new columns, closing the gap where the table could not show what an infolist entry already could. ColorColumn renders a stored CSS color as a swatch plus its value (swatchOnly() for a narrow column), the table-side counterpart of ColorEntry. CheckboxColumn is an inline checkbox writing a boolean straight to the record — the same optimistic write path, the same server-side canEdit() guard and the same sync node as ToggleColumn, for tables too dense for a switch track. RatingColumn draws a numeric score as stars (max(), allowHalf(), showValue()), the read-only half of the Rating field's vocabulary. TagsColumn renders a multi-value state — array, JSON cast, Arrayable relation collection, or a separator()-split string — as chips, with limitList() collapsing the overflow into a "+N" chip. None of them re-encodes a palette: the tag chip is the same RendersBadgeSurface chrome as BadgeColumn and takes the same colors() map, and rating/checkbox colors resolve through the canonical Foundation owners. The three state-driven ones (ColorColumn, RatingColumn, TagsColumn) memoise their view render by its data, so a page of rows sharing a color, a score or a tag set costs one render each rather than one per row. ToggleColumn and CheckboxColumn now share CanEditBooleanCell, which owns the server-side disabled guard — the point being that a new boolean cell cannot ship without it. Browser-verified over CDP by workbench/scripts/verify-column-surfaces.mjs (20/20) against a new /previews/table-column-surfaces: the swatch colors as the browser actually parsed them, the seeded row whose stored value is red; background-image: url(…) drawing no background and issuing no request for it, a half star clipped only where halves are allowed, the +N overflow chip, and a checkbox cell committing through Livewire and surviving a fresh GET. See docs/table/columns/.
  • TrashedFilter — soft deletes were not covered by any filter at all. Unlike every other filter it constrains no column: it decides which global scope applies, mapping to withTrashed() / onlyTrashed(). Three states of which only two are options — "without deleted" is the placeholder, i.e. clearing the filter — rendered through the same select surface as SelectFilter, so an open soft-delete filter looks like any other. It bypassesPlanner(), since a scope change is not a column/operator/value definition. A model without SoftDeletes now fails with a TableConfigurationException naming both the filter and the model, rather than as an undefined onlyTrashed() deep inside the query builder — and the check runs only when the filter is active, so a cleared filter never inspects the model. It extends SelectFilter rather than Filter: the shared select panel calls isSearchable() on whatever it is handed, so the first version rendered a 500 on any table that used it — a failure every unit test missed, because they only asked the filter for its view's name. Browser-verified by workbench/scripts/verify-trashed-filter.mjs (14/14) against a new /previews/table-trashed-filter, which counts the rows that actually come back: 4 live, 2 with only, 6 with with, back to 4 when cleared. See docs/table/filters/trashed.md.
  • CheckboxList::segmented() / ::buttons() — the multiple-choice half of the toggle-button vocabulary. Radio has had segmented() and buttons() for a while; picking several options in that shape had no equivalent, so a multi-select of three short values was a column of checkboxes. Rather than adding a parallel field, the shared part of Radio's API — the variants, per-option icons() and colors(), inline(), and the size/color resolvers — moved into HasChoiceVariants, which both fields now use: one vocabulary, one chrome, and a single-choice and multi-choice control that look alike. In these variants the field shows the options alone; search, bulk toggle, grouping and columns are list chrome and do not apply. Radio's own cards variant stays with Radio. Browser-verified by workbench/scripts/verify-choice-variants.mjs (15/15): the peer-checked pill actually paints, and — the part markup cannot show — a second click adds to the selection rather than replacing it. See docs/forms/fields/checkbox-list.md.
  • Repeater::table() — repeat short rows as a table instead of a card per item. One column per schema field, headed once, with the per-cell label hidden so it is not repeated on every row; same state paths, same add/remove/reorder endpoints, only the arrangement differs. Hiding that label is a new canonical HasLabel::hiddenLabel() — the label still resolves, so it can head the column and serve accessibility, which is what separates it from clearing the label. Per-item collapsing has no meaning for a row, so collapsible() is ignored in this layout. Browser-verified by workbench/scripts/verify-repeater-table.mjs (16/16): each field heads one column, every row's inputs still bind to their own item path, and add/remove run through the same endpoints. See docs/forms/fields/repeater.md § Table Layout.
  • The CDP drivers have a shared harness — workbench/scripts/lib/cdp.mjs. Each of the ~50 existing drivers carries its own copy of the same 90 lines: spawn headless Chrome, speak DevTools over a raw WebSocket, collect console errors and 4xx/5xx, screenshot, print the summary the sweep greps for. The five new drivers import it instead, so a driver file is now only its checks. finish() always asserts a clean console and no 419 — a driver that renders the right markup over a broken Livewire roundtrip has verified nothing. Existing drivers are deliberately left alone: they pass, and rewriting 50 working files to prove a point is how a green suite stops being trustworthy. New workbench fixture alongside it: a Document model with soft deletes, a stored CSS color, a score and a tag list — its own model rather than columns bolted onto Task, which dozens of tests query and which adding SoftDeletes to would change every one of.
  • Builder — a repeater whose every item picks its own block type. Builder::make('content')->blocks([Block::make('heading')->schema([…]), …]) renders an "add" trigger that opens a picker of the declared blocks; each stored item is ['type' => …, 'data' => […]] and its fields bind under <path>.<index>.data, so a field named type inside a block cannot collide with the item's own discriminator. It extends Repeater deliberately rather than standing beside it: the form runtime identifies a repeated subtree by instanceof Repeater in ten places (reactivity, flattening, save, relationship handling), and a sibling class would have had to be threaded through every one of them. It therefore inherits add/remove/reorder, per-item reactivity and item limits unchanged; only relationship() does not apply, since mixed block types have no single related model. Block rules mount at <path>.*.data.<field> — and because the resolver validates by wildcard path, blocks sharing a field name share its rules, which is stated in the docs rather than papered over. An item whose stored type names no declared block renders its type and no fields instead of breaking the form: stored content outlives the code that declared it, and a renamed block must leave the content recognisable, movable and deletable. Browser-verified by workbench/scripts/verify-builder.mjs (15/15) against a new /previews/forms-builder: the picker lists every declared block, choosing one appends an item edited with that block's schema, typing stays inside the item it was typed into, and removing one re-binds the paths of the rest. See docs/forms/fields/builder.md.
  • A table can now stay current for everyone looking at it — Table::live(). Polling and change detection turned on together (live() is exactly ->poll($interval)->pollChangeDetection()), plus the piece that was missing under both: a write generation, a cross-process counter scoped by model that every write through a table moves on. Without it change detection is blind to a write landing in the same second as the previous checksum — updated_at is stored to the second, so such an edit is indistinguishable from no edit, and the next tick compares against that same second again. It was not shown late; it was not shown at all. The counter also retires every cached slice of the table at once, which is what makes cacheQuery() and a live table usable together — the cache namespace is derived from the SQL, so every filter and search term a user ever opened has its own entry and the writer knows none of them to delete. live(broadcast: true) adds the push half: TableRecordsChanged fires wherever a write retires the cache, and the page subscribes, so another session's write arrives on commit instead of on the next tick (measured at ~690ms end to end against a 2s interval). The event carries no data — a nudge to re-read, not a payload to apply — so each client refreshes through its own component and re-evaluates its own authorization, filters, sort and page server-side; the channel therefore has nothing on it but the scope name, and is authorized by the app like any other (TableRecordsChanged::channelFor() names it readably: wire-table.App.Models.Invoice). No broadcaster is a dependency and none is privileged — the client half calls nothing but window.Echo.private()/.leave(), a surface BroadcasterAgnosticTest pins — though Reverb is the only one the path has actually been run against, by a driver that runs on demand locally and is not part of CI. It is ShouldBroadcastNow, not ShouldBroadcast, and that word is the feature: a queued broadcast is swallowed whole by the very common setup of a configured queue with no worker running for it — silently, because polling covers for it and the table refreshes a moment later anyway. Found exactly that way, against a real Reverb: the socket connected, the private channel authorized, and no event ever arrived, while the run looked healthy. The trade is stated in the docs — the write now waits on the broadcaster's HTTP call before it answers. Every way the transport can fail is harmless — no Echo on the page, no connection configured, authorization refused, a dropped socket — because the interval is still running underneath: a slower table, never a stale one. A burst of writes coalesces into one re-read, and a re-read is held off while one of your own cells has a save in flight. See docs/table/advanced.md § Live Tables.
  • Action::optimisticLock() — refuse a record action whose record moved while its modal was open. An inline cell edit has been locked since it shipped; the modal path, which has by far the longer window (open, read, type, walk away, submit), carried nothing and overwrote whatever had happened in between. The baseline is captured when the frame is pushed and compared on submit through RecordVersion — the same object and the same convention the cell edit compares with, so there is one answer to "has this row moved" rather than two that can drift. On refusal the modal closes and a warning is raised; leaving the form up would put the user back in front of values that are no longer there with no way to tell. Off by default, deliberately: a moved record only invalidates an action that decided something from what it read. Approving an invoice whose total changed underneath is a lost update; deleting a record someone else renamed is not, and refusing it would be a new failure mode in exchange for nothing. Lives on Action rather than BaseAction because only a row action has one record to lock against. See docs/table/actions.md.

Fixed

  • A paginated table had no defined order, so editing a row could hide another one entirely. A page is a slice of an ordering, and the query carried none unless the user had sorted: LIMIT/OFFSET over an unordered result is undefined, and nothing says two pages were sliced from the same order. SQLite and MySQL/InnoDB happen to hand back primary-key order so it never showed — but PostgreSQL stores rows in a heap and an UPDATE writes a new tuple at the end of it rather than in place. Editing a row on page one therefore shifted everything behind it forward by one, and the record that would have led page two was skipped: the user never saw it, and nothing reported an error. Demonstrated against a live PostgreSQL — page 1 shows T1, T2; edit T1; page 2 shows T4, T5, and T3 is simply gone. The same hole exists on every engine whenever the sort column has duplicate values, since ties are returned in whatever order the engine found them. Every table query now ends with its primary key as a tiebreaker (Core\Query\StableOrder), following the direction already in force so newest-first stays newest-first among equals. It is appended after everything else that orders — including a column's own sortUsing() callback, which runs outside the query pipeline; applied any earlier it becomes the primary sort and silently replaces the ordering it exists to stabilise, which is exactly what the first attempt at this did. Skipped where a key is not a legal ordering term: GROUP BY (PostgreSQL rejects an ungrouped term, MySQL too under ONLY_FULL_GROUP_BY), DISTINCT (PostgreSQL requires ordering terms in the select list) and unions. Found while diagnosing seven PostgreSQL test failures that looked like a caching bug and were not: the write and the cache invalidation were both correct, the rows had simply moved.
  • A % typed into the search box matched every row. The term went into the LIKE pattern raw, so its % and _ were live metacharacters: searching 50% returned the whole table and turned every search into a full scan, and _ quietly matched any character. They are escaped now — and the escape character is !, not the backslash. This was fixed once before and reverted, because ESCAPE '\' is a syntax error on MySQL and MariaDB (the backslash inside a string literal escapes the closing quote) while SQLite and PostgreSQL accept it happily, so the test suite stayed green and every search on MariaDB died. ! has no special meaning in a string literal on any supported engine, so one pattern shape works everywhere; the clause is always declared explicitly because SQLite's LIKE has no default escape character at all. A test now pins that the escape character is not a backslash, with the reason next to it.
  • A searchable number or date column took the whole page down on PostgreSQL. PostgreSQL has no implicit cast for the pattern operators, so amount ILIKE '%50%' on a numeric column is not a search that misses — it is operator does not exist: numeric ~~* text, an error. MySQL and SQLite coerce silently, so TextColumn::make('amount')->searchable() worked everywhere else and failed only on PostgreSQL, and only once somebody typed into the box. The column is now cast to text there. The cast is unconditional rather than driven by the inferred value type: the type is a guess assembled from casts and registered schema and can be absent or wrong, while the column's real type is what the server enforces — and ILIKE '%…%' was never going to use an index either way. Found by running the behaviour suite against a real PostgreSQL 16, not by reading the code.
  • Column::searchable(['first_name', 'last_name']) did nothing on an ordinary column. The list was stored and never read: only StackedColumn and SplitColumn declared HasSearchColumns, so the planner searched a plain TextColumn's own name alone while docs/table/columns/index.md documented the array form as working. Column implements the contract now, so the columns listed are the columns searched.
  • Searching for 0 searched for nothing. ! empty($search) treated the string "0" as an absent term, so the table answered with every row instead of the ones containing a zero. A whitespace-only term is still no search.
  • The search documentation described an implementation that does not exist. docs/table/overview.md claimed MySQL used MATCH … AGAINST fulltext and PostgreSQL to_tsvector / ts_query, with SQLite LIKE as a "fallback". There is no fulltext code anywhere in the repository and there never was: all three engines do a LIKE/ILIKE substring match. Both language versions now describe what actually runs.
  • Column::editable() stopped pretending it can choose an editor. Its $type / $options arguments were documented as picking a 'text' / 'select' / 'toggle' editor, and no view has read an editor type in any revision since the first commit — verified against every historical revision, not inferred. TextInputColumn, SelectColumn and ToggleColumn have existed since that same commit and always did the actual rendering, so this was never a regression: it was a second route that was drawn and never connected. An ordinary column with ->editable(true, 'select', […]) rendered the plain value, and the fill handle skipped it too, since the client looks for an editable root ([data-record-key][data-column-name]) that only a dedicated column emits. The parameters are gone from the signature; a variadic swallows and refuses them, naming the column type to use instead, and the properties and their two getters are deleted. The variadic is not decoration: PHP drops surplus positional arguments without a word, so simply removing the parameters would have let editable(true, 'select', […]) — the exact call the docs taught — go on doing the silent nothing this removed. A named type: argument lands in the same variadic, so both call styles get the same message. editable(bool) stays and is not deprecated — on a dedicated column it is the switch that renders the editor or the plain value (TextInputColumn::make('name')->editable(false) shows text), and isEditable() has three real consumers: the write guard, isFillable(), and suppressing the row link on an editable cell. The docs that taught the dead form — table/columns/editing.md and authorization.md, in both languages — are rewritten, which mattered most: they were the only place the pattern was coming from.
  • Column::authorizeInline() was a silent no-op — the ability it names was never checked, and every inline edit went through. permission() guards seeing a column; authorizeInline() was added to guard writing it inline, which is a different and narrower question (show a price to everyone, let only a manager edit it). CellEditPipeline::guard() consulted the first and never the second, so canInlineEdit() had zero callers in the whole repository: an author who wrote ->authorizeInline('edit-prices') believed the cell was protected, the UI rendered an editable cell, and the write was accepted. Now checked alongside the other guards, and refused with the same message. Note the fail-closed consequence, which is intended: Gate::allows() denies a guest unless the ability accepts a nullable user, so an unauthenticated visitor cannot edit an ability-guarded cell. Found by an audit that walked every fluent setter on every built-in component type and asked whether the value it stores ever reaches anything that renders or acts on it.
  • hintIcon() and hintColor() did nothing, on all 41 field types. The hint vocabulary is hint() + an icon + a color, but the shared field wrapper rendered only the text — both other setters stored their value and nothing ever read it. The wrapper now renders the icon next to the hint and colors the row through the canonical HasColor palette, defaulting to the muted gray it always used. Closures and Color enums resolve as they do everywhere else.
  • extraInputAttributes() moved to the fields that actually have an input, and now works there. It lived on HasExtraAttributes, which every component shares, so 49 types offered it — widgets, infolist entries, Placeholder, Alert — and not one view implemented it. Counting the field views showed why a blanket implementation was the wrong answer: 13 have exactly one input, 8 have two to four (a Radio has one per option, KeyValue one per row — no single element the attributes could mean), and 10 have none at all. So the setter moved to its own HasExtraInputAttributes concern, mixed into the ten fields where one element carries the value (TextInput, Textarea, Checkbox, Hidden, Toggle, Slider, Select — and through it BelongsToSelect/MorphToSelect — ColorPicker, DateTimePicker, TimePicker), and is rendered on that element. The attribute fragment is built and escaped once in PHP (getExtraInputAttributesHtml()) rather than looped in ten views, true renders as a bare boolean attribute and false/null are dropped. Removed from every other type — where calling it was already a no-op, so no working code can break. The shared combobox partial takes the fragment as a parameter, since more than one host renders it. extraAttributes() (the outer element) stays universal.
  • extraAttributes() reached nothing on a form field, and neither extraAttributes() nor extraHeaderAttributes() reached anything on a table column. Three setters, declared and documented, whose values no view read: on a field the attributes now land on the wrapper element (one place, every field type), and on a column they land on the cell and on the header cell respectively. Both column values are resolved once per column in the render-once preamble rather than per cell, so a table of N rows does not pay for them N times; header attribute values are escaped on the way out, while the cell setter stays the raw attribute string its signature promises.
  • A Select whose column is cast to an enum threw the moment a user cleared it. The empty choice is what a native <select> submits for the placeholder and it arrives as '' — not a valid backing value for any enum, so the cast raised "" is not a valid backing value for enum … on save; on a plain nullable column it quietly wrote an empty string where the author meant "nothing". Select now implements DehydratesState and stores an unselected single select as null. Multi-selects are untouched: their empty state is already [], which the array cast stores correctly.
  • TrashedFilter::options() accepted a value it could never apply. Inherited from SelectFilter, it wrote an option map that getOptions() overrides — the filter switches a soft-delete scope rather than matching values, so there is nothing for an arbitrary option to do. It now throws TableConfigurationException::fixedFilterOptions() naming the filter and pointing at withTrashedLabel() / onlyTrashedLabel(), which are the two labels that can change.
  • An inline-editable cell never picked up a server-side change — not from a modal, not from polling — and then falsely accused the user of a conflict. The cell root carries wire:ignore.self so a morph cannot reset its Alpine state; what was not accounted for is that Livewire then stops updating that element's own attributes for the rest of the page's life. data-server-value and data-record-version were therefore whatever the FIRST render wrote, the MutationObserver watching them had nothing to wake it, and syncFromServer() could not run. Confirmed in a real browser rather than reasoned about: after a header action rewrote the same column, a plain TextColumn in that row refreshed while the editable cell kept the old value, kept the old version, and the user's own next inline edit came back "Record was modified by another user". The fresh value now arrives on a sync node — a small child element the morph does update and the cell watches — so a poll tick, a modal write or another session's change all reconcile the value and the lock version. Two comments and docs/table/columns/editing.md had asserted the opposite ("Polling refreshes each cell's version on the next cycle"); the claim was never true.
  • Inline edits no longer skip the table render, because everything else on the row was going stale. updateTableCell() skipped unconditionally to protect the cell's Alpine state — which the sync node above now does properly — so summaries, rollups, any column derived from the edited value and the row's own position under the current sort all kept their pre-write values until something else forced a render. A write renders by default; Table::refreshAfterEdit(false) opts back out for a table where the query is expensive and nothing on screen depends on the edited value.
  • The optimistic lock was silently OFF for any model naming its timestamp column something other than updated_at. Three hand-rolled copies of the version stamp survived on the table side — the HasRecordVersion trait plus inline @php in the text-input and select cell views — all reading the literal ->updated_at. On a model with const UPDATED_AT that attribute is absent, so the client rendered the '0' sentinel, and '0' means "the client never had a version" to RecordVersion::conflicts(): the check was skipped and the edit went through unguarded. Demonstrated end to end — a concurrent change overwritten with success: true and no warning, while the same edit carrying the real stamp is correctly refused as a conflict. All three now delegate to RecordVersion, the canonical owner, which resolves the column via getUpdatedAtColumn(); the panel side had already been moved onto it when this was first found, and the table side had not.
  • cacheQuery() served stale rows for the whole TTL after any write. invalidateTable() cleared the in-memory caches and never touched the Cache::remember() entry, so a modal action, an inline edit or a fill left the table showing pre-write data — through a full page reload, not merely until the next poll. Cache keys now carry the write generation, so one counter bump retires every cached slice at once.
  • A page change riding in the same Livewire commit as a cell edit was swallowed. Same class of bug as the per-page one fixed earlier, but not fixable the same way: a per-page change is a property update, and Livewire applies every update before any call, so a flag consulted by skipTableRender() was enough. setPage() is a call, ordered by when the browser queued it, and the browser queues the edit first — clicking a pagination link blurs the input on the way, and the blur is what commits the cell. The skip was already granted by then. markTableViewChanged() therefore takes a skip back rather than merely refusing to grant one, via the same store skipRender() writes to; it is now the one way to say "this request changed what the table renders", and covers the page, column visibility and resetColumns() alongside the paths that already worked.
  • A save that failed without a server reply reported nothing at all. wireEditableCell.init() read its three messages out of this.messages and assigned them straight back to it, so all three were undefined for the life of the component while the data-msg-* attributes carrying the real translations were never read by anyone. The visible consequence was in the catch branch: offline, or on a 500, the value rolled back and error was set to undefined, which x-show treats as false — the user's edit disappeared with no message. Read off the DOM now.

An inline-editable cell was a write-only surface. Its root carries
`wire:ignore.self` so a morph cannot reset the optimistic state it holds
mid-edit — and Livewire then stops updating that element's own attributes
for the rest of the page's life. `data-server-value` and
`data-record-version` were whatever the FIRST render wrote, the
MutationObserver watching them had nothing to wake it, and the documented
reconcile could not run. Confirmed in a browser: after a header action
rewrote the same column, a plain TextColumn in that row refreshed while the
editable cell kept the old value, kept the old version, and the user's own
next edit came back "Record was modified by another user". The value now
arrives on a sync node — a child element the morph does reach — so a poll
tick, a modal write or another session's change reconcile the value and the
lock version together.

The optimistic lock was silently OFF for any model naming its timestamp
column something other than `updated_at`. Three hand-rolled copies of the
stamp survived on the table side, all reading the literal attribute; on such
a model the client rendered the '0' sentinel, which RecordVersion reads as
"the client never had a version", so the check was skipped and the write went
through unguarded. All three now delegate to RecordVersion.

Inline edits no longer skip the render — summaries, rollups and anything
derived from the edited value were stale until something else forced one.
`refreshAfterEdit(false)` opts back out. `cacheQuery()` served pre-write rows
for the whole TTL after any write; keys now carry a write generation that one
bump retires. A page change riding in the same commit as a cell edit was
swallowed: `setPage()` is a call, the browser queues the edit first, so
marking the request has to take a granted skip back rather than refuse a
later one. And a save that failed without a server reply reported nothing at
all, because init() read its messages out of itself.

New: `Table::live()` (poll + change detection, plus the write generation that
lets detection see a write landing in the same second as the last checksum),
`live(broadcast: true)` for push, and `Action::optimisticLock()` for the modal
window, which was the longer and entirely unguarded one.

`TableRecordsChanged` is `ShouldBroadcastNow` deliberately: as a queued
broadcast it was swallowed whole by a configured queue with no worker, found
against a real Reverb where the socket connected, the channel authorized, and
no event ever arrived — silently, because polling covered for it. No
broadcaster is a dependency; the client half calls only `window.Echo.private()`
and `.leave()`, pinned by BroadcasterAgnosticTest.

Verified by the browser drivers (verify-live-refresh, verify-live-broadcast,
and verify-live-broadcast-real against a real Reverb, which installs on demand
and skips otherwise).
The channel named the model with dots (`wire-table.App.Models.Invoice`),
chosen for readability, and that made a wildcard impossible: Laravel compiles
a `{placeholder}` to `([^.]+)`, so the pattern could never match a dotted
class. Every model therefore needed its own hand-written
`Broadcast::channel()` line — and a typo in one raises nothing, because the
subscription is simply refused, the push stops arriving, and polling covers
for it. The broadcast half dies and the table looks fine.

The scope is now one segment (`wire-table.App-Models-Invoice`, `-` for `\`,
which is legal in a Pusher-protocol channel name and cannot occur in a PHP
class name, so it round-trips). `LiveChannel` owns the naming, the decoding
and the registration together, since those three disagreeing is exactly the
failure above:

    LiveChannel::authorize(fn ($user, string $model) => $user->can('viewAny', $model));

The callback receives the class name, already decoded, so the wire format
stays inside the package.
Authorizing stays the application's decision, in routes/channels.php, where
Laravel expects it: the package registers no channel and calls no policy of
its own. Making that automatic would mean either allowing everyone — a
security choice nobody asked it to make — or reaching for a `viewAny` policy,
which returns false when none exists and so reproduces exactly the silent
death this closes.

What is automatic now is the reporting. A refused subscription is the one
failure that looks like success: the table keeps refreshing on its interval,
so nothing appears broken while the push half is dead, and there is no way to
notice short of timing the refreshes. Echo's error callback now writes one
console warning naming the channel, the status and the call that fixes it.

A warning rather than an error, because the fallback is working as designed;
and a report only — nothing retries, downgrades the channel or works around a
policy.

The agnosticism guard matched bare words and failed on a comment using
"connector" correctly to explain what hands back the refusal. It matches code
shapes now: a guard that punishes writing about the thing it guards gets
weakened rather than obeyed. Verified it still catches real coupling by
injecting Echo.connector.pusher and watching it fail.
Review of my own code, and this is the one that mattered.
`announceTableWrite()` runs after the transaction has committed, and all three
callers wrap it in a try/catch that turns a throw into "the save failed".
A broadcaster that is down, slow or misconfigured therefore made
`updateTableCell()` answer `success: false` for a write that had already
landed — and the cell rolls back to the value the database no longer holds,
so the user retypes an edit that was never lost. `fillTableCells()` did the
same for a whole dragged range.

Switching the event to ShouldBroadcastNow is what made it likely: that put the
broadcaster's HTTP call inline in the same try. The push is an optimisation
with a working fallback, so it is never worth a wrong answer about whether the
write landed. Reported rather than swallowed — it belongs in the log, it just
does not belong in the response. Both paths are pinned by tests that fail
without the guard.

Three smaller ones from the same pass:

- LiveChannel::authorize() handed the app callback a class-SHAPED string
  straight off the wire. The documented callback passes it to Gate and a
  plausible one would do `$model::query()`, so anything that does not name a
  loadable class is refused before it gets there.
- The refusal warning fired on every retry, while its own comment promised
  one. A console filling with the same paragraph is a console people stop
  reading.
- refresh() rescheduled itself for as long as any cell reported `saving`,
  which a request that never settles would make permanent: a timer that never
  stops over a table that never refreshes. Bounded to ~2s, after which the
  interval has it anyway.
Second review pass, on the parts the first one did not look at.

The counter is stored forever, and the docblock said that made it safe. Forever
is not a promise a store under memory pressure keeps: `allkeys-lru` evicts a
key with no TTL like any other, and the old fallback then handed every key back
to generation 0 — a namespace that may still hold slices cached before the
first write, whose own TTL has not run out. Losing the counter brought stale
rows back.

It seeds a fresh generation on a miss instead, so eviction costs a cache miss
rather than a wrong answer. The first attempt seeded from `time()` and the test
caught it: bump() is seed+1, so re-seeding inside the same second lands exactly
on the namespace the first seed used. Random start, which cannot walk back into
a range already served.

Also documented, not changed: the live listener rides the polling wrapper, so
pausing the poll pauses the push. That is right for the Stop control, where
"stop the table changing under me" should mean all of it, and arguable for
pollWhen(), which is a cost condition rather than a statement of intent —
splitting them needs a second wrapper and a push-only host method, which is a
product decision rather than a defect.
Measured before touching anything: rendering 500 rows across three editable
columns took 453ms, 302µs per cell. It is 303ms now, 202µs — a third off.

Three things, in order of how much they cost:

- Every editable cell asked for its optimistic-lock stamp TWICE, once as view
  data and once to build the sync node, and each ask re-read a cast attribute.
  Mine, from the sync-node work; computing it once per cell is the bulk of the
  saving.
- RecordVersion was never registered, so `app()` built it by reflection on
  every one of those asks — ~14µs each, for a stateless object with no
  constructor. Registered as a singleton next to CellSync.
- Even a bound singleton walks the container's resolve path (~5µs), which is
  not free 1,500 times, so both resolvers are now held on the column for the
  render. The same memoisation the column already does for its input
  attributes and its skeleton.

Guarded by counting rather than by timing: a millisecond threshold fails on a
loaded CI box and gets deleted, while "how many times was it computed" is the
property that regressed. Lives in TablePrimitiveRenderTest, which is already
the per-row-waste fuse.

Not done, and worth measuring before anyone tries: at 202µs a cell the
remaining cost is the Blade render and the Eloquent attribute read, not the
plumbing around them.
The editable-cell optimisation was measured on 500 rows x 3 editable columns,
which is not the shape these tables have: 25 columns over 20 rows a page means
most cells are plain, so the saving is proportionally smaller and the
plain-column path is what dominates.

Interleaved ON/OFF/ON with the 0-editable row as a control group, because the
first attempt at this comparison was contaminated — it showed the control
moving 3.5x, which my change cannot cause, so the numbers were thrown away
rather than reported.

    editable   before   after
     0 of 25    46.3     39.8   <- control; the ~6ms between them is drift
     5 of 25    77.0     63.3
    10 of 25   107.3     87.4
    25 of 25   206.4    167.4

Reports, never asserts, and does not run in `composer test` — the root
phpunit.xml carries Unit and Feature only.
Typing "Ada Lovelace" could not find the row whose first name is in one column
and surname in another, and a number could be searched for but never compared.
Three capabilities are now opt-in per table through Table::search(): tokenize()
splits on spaces and ANDs the words, each still ORing across all columns, which
is what lets a name spanning two columns match; ranges() reads >100, 10..20 and
the same over dates; wildcards() lets * and ? stand in. Everything is off by
default, so an unconfigured table matches byte for byte what it always did.

A comparison is only ever asked of a column that can answer it — the value type
comes from the model's casts, or from Column::searchAs() where the casts cannot
speak for the column — and one no column can answer is searched as the literal
text typed, rather than contributing an empty WHERE group that matches every
row. searchAs('code') covers the structured reference (8866 01, 8866 02): the
space inside such a code is also what splits the term, and since 8866 01..08 and
praha 10..20 cannot be told apart syntactically, the range carries the word
typed before it and each column takes the reading it can answer. A code column
completes both bounds into one BETWEEN; a numeric column ignores the word.

Four things it was getting wrong underneath:

- A typed % was a live LIKE metacharacter, so searching "50%" returned the whole
  table. Escaping is back, with ! as the escape character rather than the
  backslash: ESCAPE '\' is a syntax error on MySQL and MariaDB, which SQLite and
  PostgreSQL accept happily — the reason the first attempt passed its tests and
  died on MariaDB.
- ILIKE against a numeric or date column is an error on PostgreSQL, not a miss,
  so a searchable amount column took the page down there while working
  everywhere else. Cast to text. Found by running the behaviour suite against a
  real PostgreSQL, not by reading the code.
- Column::searchable(['first_name', 'last_name']) was stored and never read on
  an ordinary column; only StackedColumn and SplitColumn declared the contract,
  while the docs described the array form as working.
- Searching for "0" searched for nothing.

The docs claimed MySQL used MATCH ... AGAINST and PostgreSQL to_tsvector. There
is no fulltext code in the repository and there never was; both languages now
describe the LIKE/ILIKE match that actually runs.

Verified against MySQL 8, MariaDB 11 and PostgreSQL 16 as well as SQLite.
A page is a slice of an ordering, and the query carried none unless the user
had sorted. LIMIT/OFFSET over an unordered result is undefined, and nothing
says two pages were sliced from the same order.

SQLite and MySQL/InnoDB hand back primary-key order, so it never showed.
PostgreSQL stores rows in a heap and an UPDATE writes a new tuple at the end
of it rather than in place, so editing a row on page one shifts everything
behind it forward by one:

    page 1 (LIMIT 2 OFFSET 0)   ->  T1, T2
    edit T1                     ->  UPDATE
    page 2 (LIMIT 2 OFFSET 2)   ->  T4, T5      <- T3 is simply gone

The user never sees T3 and nothing reports an error. The same hole exists on
every engine whenever the sort column has duplicate values, since ties come
back in whatever order the engine found them.

Every table query now ends with its primary key as a tiebreaker, following the
direction already in force so that newest-first stays newest-first among rows
the sort calls equal. It is appended after everything else that orders,
including a column's own sortUsing() callback, which runs outside the query
pipeline — applied any earlier it becomes the primary sort and silently
replaces the ordering it exists to stabilise, which is what the first attempt
at this did. Skipped where a key is not a legal ordering term: GROUP BY
(PostgreSQL rejects an ungrouped term, MySQL too under ONLY_FULL_GROUP_BY),
DISTINCT (PostgreSQL wants ordering terms in the select list) and unions.

Found while diagnosing seven PostgreSQL failures in PerPageAndQueryCacheTest
and PerPageMergedCommitTest that read like a caching bug and were not: the
write committed and the cache was retired correctly, the rows had moved. Those
seven now pass untouched, and the PostgreSQL suite is green for the first time.
… layouts

The 1.15.0 batch, committed together.

Added:

- Builder — a repeater whose every item picks its own block type. Extends
  Repeater deliberately: the form runtime identifies a repeated subtree by
  instanceof in ten places. Items store ['type' => …, 'data' => […]], so a
  field named "type" inside a block cannot collide with the discriminator.
- ColorColumn, CheckboxColumn, RatingColumn and TagsColumn, closing the gap
  where a table could not show what an infolist entry already could. None
  re-encodes a palette; the tag chip is the same badge chrome as BadgeColumn.
- TrashedFilter — soft deletes were not covered by any filter. It constrains
  no column: it switches which global scope applies.
- CheckboxList::segmented() / ::buttons(), the multiple-choice half of a
  vocabulary Radio already had. The shared part moved into HasChoiceVariants
  rather than being written twice.
- Repeater::table() — repeat short rows as a table instead of a card each.
  Hiding the per-cell label is a new canonical HasLabel::hiddenLabel(): the
  label still resolves, so it can head the column and serve accessibility.
- A shared harness for the CDP drivers (workbench/scripts/lib/cdp.mjs), so a
  driver file is only its checks. Existing drivers are left alone.

Fixed:

- Column::editable() stopped pretending it can choose an editor.
- Column::authorizeInline() was a silent no-op — the ability it names was
  never checked and every inline edit went through.
- hintIcon() and hintColor() did nothing, on all 41 field types.
- extraAttributes() reached nothing on a form field, and neither it nor
  extraHeaderAttributes() reached anything on a table column.
- extraInputAttributes() moved to the fields that actually have an input.
- A Select whose column is cast to an enum threw the moment a user cleared it.
- TrashedFilter::options() accepted a value it could never apply.

Docs: Builder::table() and TrashedFilter::options() are declared and throw, so
both are now on their pages with the reason. The Block API heading was renamed
to "Declaring a Block": it was the first heading matching the API check, which
therefore read Block's methods as Builder's and flagged Block::icon() — which
does exist, via HasIcon — as a method Builder does not have. docs:check is
green for the first time.
Twenty-eight entries had accumulated under `## [1.15.0]`, a section the 1.15.0
branch shows was released with twelve. Everything written on this branch since
went into it — Table::live(), Action::optimisticLock() and the TimePicker work
before this batch, then the Builder field, the four new columns, TrashedFilter
and the search work — so the changelog claimed a released version contained
features that were not in it.

1.16.0 rather than 1.15.1: nine of the twenty-eight are new features, and a
patch release advertising a new Builder field would mislead anyone who updates
only patches. The 1.15.0 section is restored verbatim from the release branch,
so the twelve entries it shipped with are exactly what it says again.

Note the branch is still named 1.15.1.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

The source page was rewritten in f6b86d8 but the copy wire-boost ships was
last touched in 1.11.0, so the docs-check drift guard has been failing on it
since. No content of its own — purely what boost:sync-docs mirrors.
Both were reproduced before being touched: an eight-run loop failed three
times, which is how a single run occasionally reported two.

FillHandleTest read the versions it holds BEFORE freezing the clock. A
version is the row's stored updated_at, so the test's premise — held version
and write stamp inside one second — only held while beforeEach's seeding and
the freeze happened to land in the same second. A real second boundary
between them made the second fill genuinely stale and the refusal fired, so
the test failed for the very reason it exists to document. Freeze first,
restamp onto the frozen second, and the premise holds by construction.

Its reset also moved to afterEach: both clock-driving tests reset as their
last statement, which a failing assertion skips, leaving the freeze in place
for everything after it.

The benchmarks asserted flat millisecond ceilings, which say as much about
the runner as the code: the tightest sat 61ms under a 100ms ceiling idle and
reached 87ms with the CPU contended. benchBudget() applies headroom for load
(calibration cannot help there — a trivial loop keeps its cache and its
scheduler slice while real work nearly doubles) and a calibration ratio for a
slower machine. Verified 12/12 under twelve-way contention, and still catching
a deliberately slowed getResponsiveClasses().
…n owners

Table was 2,847 lines: a god object by breadth, and the four clusters that
carried real logic rather than configuration are the ones worth moving. All
211 public signatures are unchanged, so no docs page moves with this.

Polling: PollingConfig owns the eight settings and the interval format, a thin
HasPolling exposes them. The wire:poll attribute itself turned out to have a
second hand-written copy in the widget concern — missing keep-alive — so both
now delegate to Foundation\ValueObjects\PollDirective. Whether polling is on
deliberately stays with each surface: a widget polls when it has an interval,
a table when it was told to.

Introspection: ~250 lines of column info, schema reads and query planning were
reachable only through the dump/dd sugar, and had never been covered by a
test. TableIntrospector takes the table as a parameter, so every answer can be
exercised without a Livewire host. dump()/dd() stay on Table, where a
developer types them. Writing the tests turned up that debugQueryPlan() falls
back to defaultSort()'s column but not its direction — preserved, and now
written down.

Data source: HasDataSource draws the line the two halves kept blurring —
where the rows come from, against TableQueryService's how they are narrowed.

Columns: ColumnSet answers the name lookup from a map built once instead of a
scan, and keeps first-declaration-wins on a duplicate name, which is what the
scan did. TableQueryService keeps its own private copy: it is handed an array
a plugin hook may have replaced, not the table's set, so folding it in would
build a map to do two lookups.
    ->perPageOptions([10, 25, 50, 'all'])
    ->perPage('all')                       // or as the table's own default

The word never survives configuration: it is stored as Table::PER_PAGE_ALL,
an integer, because the select, the per_page query-string parameter and the
query cache key all compare page sizes strictly as integers. It sorts last
however it was declared — the sentinel is negative, so sort() would otherwise
put it first.

The sentinel cannot be handed to the paginator. A negative limit is dropped
by the query builder, so the rows would be right, while the paginator still
divides the total by it, so the page count would be negative. paginateQuery()
counts first instead, and max(1) keeps an empty table from dividing by zero.

Not among the shipped options, and the gate is a guard that already existed:
normalizePerPage() falls back to perPage() for any size the table does not
offer — the same line that stops a forged perPage: 500000 — so a table reads
its whole source into memory only when it said 'all' itself. There is no
ceiling behind it, unlike bulkMaxRecords(), which the docs say plainly.

Verified in a browser, not only in tests: the editable-per-page driver now
also drives the 'all' option and asserts the server keeps the sentinel rather
than clamping it (14/14).

The table coverage floor rises 88 -> 90, earned mostly by the introspection
tests in the previous commit.
The docs-check workflow and the npm scripts both invoke
verify-docs-standard.mjs and verify-site-ui.mjs, and build.php and the
page/home templates include not-found.php and head-meta.php, but none of
those files were ever added to the index. CI got as far as the build
step — include() only warns on a missing partial — and then died on the
standard check with MODULE_NOT_FOUND.

Adds the two checkers, the 151-entry baseline of pages that predate
AI_DOCS_STANDARD.md, the standard itself, and the two templates.
@ONyklicek
ONyklicek merged commit 426d5d6 into 1.x Aug 2, 2026
31 of 33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant