@mulmoclaude/core 0.14.0 → 0.18.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.
Files changed (49) hide show
  1. package/assets/helps/collection-skills.md +94 -11
  2. package/assets/helps/custom-view.md +28 -0
  3. package/assets/helps/egress-sync.md +158 -0
  4. package/assets/helps/index.md +1 -0
  5. package/dist/{calendarGrid-kO6rGfm0.js → calendarGrid-B3dtFN-m.js} +105 -2
  6. package/dist/calendarGrid-B3dtFN-m.js.map +1 -0
  7. package/dist/{calendarGrid-C1rfCXJ3.cjs → calendarGrid-CRaMAguM.cjs} +158 -1
  8. package/dist/calendarGrid-CRaMAguM.cjs.map +1 -0
  9. package/dist/collection/core/actionVisible.d.ts +15 -4
  10. package/dist/collection/core/backlinks.d.ts +40 -0
  11. package/dist/collection/core/mutateAction.d.ts +14 -0
  12. package/dist/collection/core/recordZ.d.ts +4 -5
  13. package/dist/collection/core/schema.d.ts +21 -4
  14. package/dist/collection/core/schemaZ.d.ts +209 -16
  15. package/dist/collection/core/uiTypes.d.ts +26 -0
  16. package/dist/collection/index.cjs +21 -36
  17. package/dist/collection/index.cjs.map +1 -1
  18. package/dist/collection/index.d.ts +1 -0
  19. package/dist/collection/index.js +12 -33
  20. package/dist/collection/index.js.map +1 -1
  21. package/dist/collection/registry/server/index.cjs +1 -1
  22. package/dist/collection/registry/server/index.js +1 -1
  23. package/dist/collection/server/index.cjs +8 -2
  24. package/dist/collection/server/index.d.ts +2 -0
  25. package/dist/collection/server/index.js +3 -2
  26. package/dist/collection/server/io.d.ts +9 -0
  27. package/dist/collection/server/mutate.d.ts +24 -0
  28. package/dist/collection/server/ontology.d.ts +35 -0
  29. package/dist/collection-watchers/index.cjs +5 -4
  30. package/dist/collection-watchers/index.cjs.map +1 -1
  31. package/dist/collection-watchers/index.js +3 -2
  32. package/dist/collection-watchers/index.js.map +1 -1
  33. package/dist/feeds/index.cjs +2 -2
  34. package/dist/feeds/index.js +2 -2
  35. package/dist/feeds/server/index.cjs +3 -3
  36. package/dist/feeds/server/index.js +3 -3
  37. package/dist/{ingestTypes-BIFXlw7M.cjs → ingestTypes-Cq65Bv-N.cjs} +2 -2
  38. package/dist/{ingestTypes-BIFXlw7M.cjs.map → ingestTypes-Cq65Bv-N.cjs.map} +1 -1
  39. package/dist/{ingestTypes-KuYPX9Ea.js → ingestTypes-DvsJhqMx.js} +2 -2
  40. package/dist/{ingestTypes-KuYPX9Ea.js.map → ingestTypes-DvsJhqMx.js.map} +1 -1
  41. package/dist/{server-Cc6XcyOA.js → server-0IVR-Y_G.js} +316 -35
  42. package/dist/server-0IVR-Y_G.js.map +1 -0
  43. package/dist/{server-Cd5WdM8a.cjs → server-BeoOWQ-a.cjs} +347 -42
  44. package/dist/server-BeoOWQ-a.cjs.map +1 -0
  45. package/package.json +1 -1
  46. package/dist/calendarGrid-C1rfCXJ3.cjs.map +0 -1
  47. package/dist/calendarGrid-kO6rGfm0.js.map +0 -1
  48. package/dist/server-Cc6XcyOA.js.map +0 -1
  49. package/dist/server-Cd5WdM8a.cjs.map +0 -1
@@ -148,7 +148,7 @@ skipped, never crashes the host):
148
148
 
149
149
  `string` · `text` (multi-line) · `email` · `number` · `date` (`YYYY-MM-DD`) ·
150
150
  `datetime` (`YYYY-MM-DDTHH:MM`) · `boolean` · `markdown` · `money` · `enum` ·
151
- `ref` · `embed` · `table` · `derived` · `image` · `file` · `toggle`
151
+ `ref` · `embed` · `backlinks` · `rollup` · `table` · `derived` · `image` · `file` · `toggle`
152
152
 
153
153
  Every field spec needs a `type` and a `label`. Extra keys by type:
154
154
 
@@ -179,9 +179,41 @@ Every field spec needs a `type` and a `label`. Extra keys by type:
179
179
  sibling as a dropdown picker in the editor and hides its own cell (the embed
180
180
  owns it). E.g. a multi-issuer invoice: a `ref` field `issuerId → profile`
181
181
  plus `{ "type": "embed", "to": "profile", "idField": "issuerId" }`.
182
+ - **`backlinks`** — `from: "<source-slug>"`, `via: "<ref-field-in-source>"`,
183
+ `display: ["<source-col>", ...]`, optional `filter: { "field": ..., "in": [...] }`.
184
+ The **reverse** side of a `ref`: a read-only sub-table (detail view only) of
185
+ the records in `from` whose `via` ref points at this record — each row links
186
+ to that record. **Nothing is stored** on this record (like `derived`/`embed`);
187
+ you never write it, and the rows update whenever the source records change.
188
+ `display` names the source columns to show — a derived source column works
189
+ when its formula is self-contained (e.g. an invoice `total` summing its own
190
+ line items), but one that derefs yet another collection renders em-dash;
191
+ `filter` narrows rows by a source field's value, same shape as `when`. E.g. a
192
+ client's open invoices:
193
+ `{ "type": "backlinks", "label": "Invoices", "from": "invoice", "via": "clientId", "display": ["issueDate", "total", "status"], "filter": { "field": "status", "in": ["draft", "sent"] } }`.
194
+ Resolution is fail-soft: an unknown `from` / `via` / `display` column just
195
+ renders an empty sub-table — no error, so author the `ref` side first.
196
+ - **`rollup`** — `from: "<source-slug>"`, `via: "<ref-field-in-source>"`,
197
+ `op: "sum" | "count"`, `column: "<source-col>"` (required for `sum`, omitted
198
+ for `count`), optional `filter` (same shape as `when`, against the source).
199
+ A **cross-collection aggregate**: a computed number — never stored — summed
200
+ (or counted) over the records in `from` whose `via` ref points at this
201
+ record. Backlinks show the rows; rollup collapses them to a scalar that
202
+ renders everywhere a number does (list column included). E.g. a client's
203
+ unbilled hours:
204
+ `{ "type": "rollup", "label": "Unbilled hours", "from": "worklog", "via": "clientId", "op": "sum", "column": "hours", "filter": { "field": "billed", "in": ["false"] } }`.
205
+ Fail-soft: an unresolvable `from` renders em-dash; an empty match set is a
206
+ real 0. Summing a source `derived` column works when its formula is
207
+ self-contained; non-numeric values are skipped. A `derived` formula ON THE
208
+ SAME schema may reference rollup fields as plain identifiers — rollups
209
+ resolve before the formula pass — e.g. two one-sided counts combined:
210
+ `"played": { "type": "derived", "formula": "homePlayed + awayPlayed" }`.
211
+ (Caveat: that works on the collection's own rows; a `<refField>.<col>`
212
+ deref FROM another collection reads the target without its rollups, so a
213
+ rollup-fed derived column is em-dash when viewed through a ref.)
182
214
  - **`table`** — `of: { <col>: <sub-field-spec>, ... }`. An array of rows. Each
183
- sub-field is a flat spec; sub-fields **cannot** be `table` or `derived`
184
- (no nested tables, no computed columns).
215
+ sub-field is a flat spec; sub-fields **cannot** be `table`, `derived`,
216
+ `backlinks`, or `rollup` (no nested tables, no computed columns).
185
217
  - **`derived`** — `formula: "<expr>"`, optional `display` (`number` default, or
186
218
  `money` / `string` / `date`) and `currency`. **Read-only, host-computed** —
187
219
  you NEVER write derived values into the JSON; the host recomputes them on
@@ -302,11 +334,50 @@ Rules and limits:
302
334
 
303
335
  ### Actions (per-record buttons)
304
336
 
305
- Each entry in `actions` renders a button in the read-only detail view. The only
306
- `kind` today is `"chat"`: clicking it starts a **new chat in a role**, seeded
307
- with a template + the record data — the role then does the work with its tools.
308
- This is how hard logic the schema can't express (PDF generation, bookkeeping
309
- journals, drafting an email) gets delegated to natural language.
337
+ Each entry in `actions` renders a button in the read-only detail view. Three
338
+ kinds:
339
+
340
+ - **`"chat"`** clicking it starts a **new visible chat in a role**, seeded
341
+ with a template + the record data the role then does the work with its
342
+ tools. Pick it when the output IS the conversation or the user may need to
343
+ steer: PDF generation, bookkeeping journals, drafting an email.
344
+ - **`"agent"`** — clicking it dispatches a **hidden background worker** with
345
+ the SAME seed; the worker edits the record via `manageCollection` and
346
+ finishes silently — no chat window, the record just updates. Pick it for
347
+ mechanical enrichment where a transcript would be noise: refresh a price,
348
+ fetch metadata, look something up and write it back. The button shows a
349
+ spinner while the worker runs; a failed run raises one bell notification
350
+ (cleared by the next success). **End an agent template with**: "edit the
351
+ record via manageCollection and stop — do not present anything."
352
+ - **`"mutate"`** — **no LLM at all**: the host applies a declarative write the
353
+ moment the button is clicked (after an optional mini-form). Pick it when the
354
+ write needs zero judgment — "Mark paid", "Assign", any fixed state
355
+ transition. Instant and token-free. Shape (no `role`/`template`):
356
+
357
+ ```json
358
+ {
359
+ "id": "assign", "label": "Assign", "icon": "person_add",
360
+ "kind": "mutate",
361
+ "require": { "field": "status", "in": ["open"] },
362
+ "params": { "assignee": { "type": "string", "label": "Assignee", "required": true } },
363
+ "set": { "assignee": "$params.assignee", "status": "assigned" }
364
+ }
365
+ ```
366
+
367
+ `set` merges into the record (only the named fields change) — values are
368
+ literals or `$params.<name>` references. `require` replaces `when` (same
369
+ shape, same visibility-is-authorization rule, re-checked server-side).
370
+ `params` declares an optional mini-form using the table sub-field DSL; the
371
+ submitted values are validated like record fields, and the write itself runs
372
+ through the same gate as `putItems` (a rejected write shows the `problem`).
373
+ Constraints (schema-validated): `set` keys must name declared, non-computed
374
+ fields (never the primaryKey); every `$params` reference must name a
375
+ declared param; mutate is **record-level only** (not in `collectionActions`).
376
+ Group several fields in one `set` so "paid" can mean `status` + `paidDate`
377
+ written together. `toggle` stays the right tool for a single checkbox.
378
+
379
+ This is how hard logic that the schema can't express gets delegated to natural
380
+ language (and, for `"mutate"`, how the schema-expressible part stays free).
310
381
 
311
382
  ```json
312
383
  {
@@ -350,7 +421,11 @@ text / markdown / html / file fields are left out, so the prompt stays small).
350
421
  ```
351
422
 
352
423
  - Same `id` uniqueness rule (within `collectionActions`); same path-safe
353
- `template`; same `role`-seeds-a-new-chat behavior.
424
+ `template`; same `role` + kind behavior (`"chat"` seeds a visible chat,
425
+ `"agent"` dispatches a silent worker over the whole collection — e.g. a
426
+ "Sync" button that pushes records to an external system via MCP; the
427
+ known-good sync recipe, including the snapshot-diff state file and the
428
+ `externalId` write-back, is **`config/helps/egress-sync.md`**).
354
429
  - `when` is **ignored** here — there is no record to gate on. Always shown.
355
430
 
356
431
  ### Completion tracking (bell notifications)
@@ -778,6 +853,13 @@ records through **`manageCollection`**, not raw file I/O:
778
853
  / `fields` on large collections to keep the result small — e.g.
779
854
  `fields: ["id"]` to check for an id collision before an add.
780
855
  - **Delete** — remove the record file (`manageCollection` has no delete).
856
+ - **Cross-collection questions — `getOntology`.** Returns every collection in
857
+ the workspace with its `primaryKey`, effective `displayField`, record count,
858
+ and its `ref` / `embed` / `backlinks` / `rollup` relations (field → related slug, including refs
859
+ inside `table` columns as `lines.clientId`). When a question spans
860
+ collections ("which clients have unpaid invoices AND unlogged hours?"),
861
+ call it first to see which collections exist and how they join, then
862
+ `getItems` only the ones involved — instead of reading every schema.json.
781
863
  - **Id charset** (enforced by `safeRecordId` in
782
864
  `packages/core/src/collection/server/paths.ts` — the single source of
783
865
  truth; `manageCollection` rejects ids that fail it): start and end with a
@@ -789,8 +871,9 @@ records through **`manageCollection`**, not raw file I/O:
789
871
  enforces this on every targeted read/write, so an id that only _looks_ fine in
790
872
  a full `getItems` listing but violates the rule can't be updated or deleted by
791
873
  id — fix the id, don't work around it with raw file I/O.
792
- - **Never write `derived` fields**, and never write an `embed` field — both are
793
- display-only / host-computed (`putItems` rejects rows that carry them).
874
+ - **Never write `derived` fields**, and never write an `embed`, `backlinks`,
875
+ or `rollup` field — all are display-only / host-computed (`putItems`
876
+ rejects rows that carry them).
794
877
  - Leave optional fields out of the row entirely rather than writing empty
795
878
  strings.
796
879
  - For a `ref` field, write the raw target slug, and make sure that record
@@ -123,6 +123,34 @@ const { written, rejected } = await res.json(); // fix & re-send any rejected ro
123
123
  Surface any `rejected` rows to the user with their `problem` text — don't fail
124
124
  silently.
125
125
 
126
+ ### Invoking a `kind: "mutate"` action (only with the `write` capability)
127
+
128
+ If the schema declares a `kind: "mutate"` action (a declarative state
129
+ transition — e.g. an `assign` button with `require`/`params`/`set`), invoke it
130
+ instead of re-encoding the same field writes as a PUT: the server re-checks the
131
+ action's `require` gate, validates the `params`, and applies the `set`
132
+ atomically — one source of truth for the transition, in the schema.
133
+
134
+ ```js
135
+ const res = await fetch(dataUrl + "/actions/assign", {
136
+ method: "POST",
137
+ headers: { Authorization: "Bearer " + token, "Content-Type": "application/json" },
138
+ body: JSON.stringify({ itemId: "task-3", params: { assignee: "kai" } }),
139
+ });
140
+ if (res.ok) {
141
+ const { item } = await res.json(); // the written record
142
+ } else {
143
+ const { error } = await res.json(); // 400 bad param / 409 require not met — show it
144
+ }
145
+ ```
146
+
147
+ - `itemId` is the record's primary-key value; `params` only if the action
148
+ declares them.
149
+ - **Mutate kind only.** `chat` / `agent` actions can never be invoked with a
150
+ view token (they start LLM work) — you get a 403.
151
+ - A 409 means the record's current state fails the action's `require` — treat
152
+ it as "button disabled", not an error to retry.
153
+
126
154
  ### Staying live — `onChange`
127
155
 
128
156
  By default a view paints once, on load. To keep it fresh, register a callback —
@@ -0,0 +1,158 @@
1
+ # Egress sync — push collection records to an external system
2
+
3
+ Read this when the user asks to **sync a collection outward** — "push my
4
+ invoices to freee", "mirror my clients into Notion", "keep this list in a
5
+ Google Sheet". It is the write-back counterpart of feeds (which pull data IN):
6
+ a **prose pattern**, not a host feature. There is no `egress` schema block, no
7
+ sync engine, no connector registry — you are the sync engine, and this file is
8
+ the known-good way to be one.
9
+
10
+ Read `config/helps/collection-skills.md` first for the schema DSL and the
11
+ actions system; this file assumes both.
12
+
13
+ ## Why this is a pattern, not host machinery
14
+
15
+ A host-side sync engine needs lifecycle triggers, debounce, retries, and —
16
+ hardest of all — a loop guard that can tell "the sync worker wrote this"
17
+ apart from "the user edited this". In this workspace that guard is
18
+ unimplementable: records are plain files with no provenance, and raw file
19
+ writes bypass any tool-layer flag. Meanwhile every judgment-shaped part of a
20
+ sync (which changes matter, how fields map to the remote system, what to do
21
+ on conflict) is exactly what you handle well and a schema can't express.
22
+
23
+ So the architecture is: **the host provides the trigger and the state
24
+ substrate; you provide the sync logic; this recipe keeps it correct.**
25
+
26
+ - Trigger — a collection-level `kind: "agent"` action (a "Sync" button), or
27
+ a scheduled skill for periodic sync.
28
+ - State — a snapshot file kept **inside the collection's skill dir** (the
29
+ workspace is the database; sync state is just another file).
30
+ - Transport — whatever MCP tool reaches the remote system (Notion, Google
31
+ Sheets/Drive, GitHub, a REST API via Bash+curl…).
32
+
33
+ ## The pattern in five rules
34
+
35
+ 1. **Diff against a snapshot, never "sync everything".** Keep
36
+ `data/skills/<slug>/sync/last-synced.json` — a map of record id →
37
+ the record as of the last successful push. On each run: read current
38
+ records (`manageCollection` getItems with `fields` — EXCLUDE computed
39
+ fields like `derived`/`rollup` unless the remote actually wants them),
40
+ compare against the snapshot, and push only creates / updates / deletes.
41
+ No snapshot file yet ⇒ first run ⇒ everything is a create.
42
+ 2. **Record remote identity on the record.** Add an `externalId` field
43
+ (plain `string`) to the schema. When the remote system assigns an id
44
+ (a Notion page id, a row number, an issue URL), write it back to the
45
+ record with `putItems` `mode: "merge"`. That write is the ONLY write
46
+ back into the collection a sync run may make — never round-trip remote
47
+ field values back into local records (the local collection is the
48
+ source of truth; pulling remote edits back is a *feed*, a different
49
+ pattern, and mixing the two directions in one worker is how sync loops
50
+ are born).
51
+ 3. **Update the snapshot per record, after each successful push — not once
52
+ at the end.** If the run dies halfway, the snapshot then reflects
53
+ exactly what was pushed, and the next run resumes with the remainder
54
+ instead of re-pushing everything (duplicates) or skipping the tail
55
+ (silent loss).
56
+ 4. **Fail partially, report honestly.** One record failing to push must not
57
+ abort the rest. Count pushed / failed / skipped; if anything failed,
58
+ leave it OUT of the snapshot so the next run retries it.
59
+ 5. **Deletes are opt-in.** A record present in the snapshot but missing from
60
+ the collection was deleted locally. Only propagate the delete if the
61
+ user asked for mirror semantics; the safer default is to leave the
62
+ remote row and note it in the run summary. (Say which behaviour you
63
+ chose in the template.)
64
+
65
+ ## Setting it up
66
+
67
+ 1. Make sure the schema has `externalId` (add it via `putSchema` if
68
+ missing): `{ "type": "string", "label": "External ID" }`.
69
+ 2. Add a collection-level agent action:
70
+
71
+ ```jsonc
72
+ "collectionActions": [{
73
+ "id": "sync", "label": "Sync to Notion", "icon": "cloud_upload",
74
+ "kind": "agent", "role": "general", "template": "templates/sync.md"
75
+ }]
76
+ ```
77
+
78
+ 3. Write `data/skills/<slug>/templates/sync.md` from the reference template
79
+ below, filling in the remote system's specifics (which MCP tool, the
80
+ field mapping, create-vs-update calls).
81
+ 4. For periodic sync, schedule it instead of (or in addition to) the
82
+ button — but start with the button: sync templates need a few visible
83
+ runs before they've earned a schedule.
84
+
85
+ ## Reference template (`templates/sync.md`)
86
+
87
+ Adapt the bracketed parts; keep the structure — every line of it exists
88
+ because of a failure mode.
89
+
90
+ ```markdown
91
+ # Sync <collection title> to <remote system>
92
+
93
+ You are a background worker. Push local changes in the `<slug>` collection
94
+ to <remote system>, then stop. Nobody is watching — do not call present*
95
+ tools, do not post a summary to the chat.
96
+
97
+ ## Steps
98
+
99
+ 1. Read the snapshot `data/skills/<slug>/sync/last-synced.json` (id → record
100
+ as of the last successful push). If the file does not exist, this is the
101
+ first run: treat every record as new, and create the `sync/` directory.
102
+
103
+ 2. Read the current records: `manageCollection` getItems (slug `<slug>`,
104
+ `fields`: [<the stored fields the remote needs — no derived/rollup>]).
105
+
106
+ 3. Diff current vs snapshot:
107
+ - id not in snapshot → CREATE remotely
108
+ - id in both, fields differ → UPDATE remotely (use the record's
109
+ `externalId` to address the remote row)
110
+ - id only in snapshot → deleted locally. [Choose one:]
111
+ [mirror: DELETE remotely] /
112
+ [default: leave the remote row; count it]
113
+ - id in both, fields identical → skip
114
+
115
+ 4. For each create/update, via <MCP tool>:
116
+ - Map fields: [<local field> → <remote property>, …]
117
+ - On CREATE, take the remote id from the response and write it back:
118
+ `putItems` `mode: "merge"` with `{ "<primaryKey>": "<id>",
119
+ "externalId": "<remote id>" }`. This is the only collection write
120
+ you may make — never copy remote values into local records.
121
+ - After EACH successful push, update `last-synced.json` with that
122
+ record's current values (atomic-ish: rewrite the whole file each time;
123
+ it is small). A failed push leaves that id untouched in the snapshot.
124
+
125
+ 5. If a push fails, log it and continue with the remaining records. Retry
126
+ nothing within this run.
127
+
128
+ 6. When done: if anything failed, raise ONE short error so the failure bell
129
+ fires (e.g. exit with an error message counting failures). If everything
130
+ succeeded, just stop — silently.
131
+
132
+ ## Rules
133
+
134
+ - Never write remote data back into local records (except `externalId`).
135
+ - Never push computed fields; the remote gets stored values only.
136
+ - Do not create duplicate remote rows: a record with an `externalId` is
137
+ always an UPDATE, even if it is missing from the snapshot.
138
+ - Keep `last-synced.json` valid JSON at all times.
139
+ ```
140
+
141
+ ## Caveats
142
+
143
+ - **Interactively-authenticated MCP servers may be absent for hidden
144
+ workers.** Connectors authenticated through the claude.ai UI (Notion,
145
+ Gmail, Google Drive as claude.ai connectors) are not necessarily
146
+ available in a hidden/headless worker session. Test the Sync button
147
+ once; if the MCP tool is missing, either use a server-configured MCP
148
+ (API-key based), or fall back to Bash + the remote's REST API with a
149
+ workspace-stored token.
150
+ - **The remote is a mirror, not a second master.** If the user wants
151
+ remote edits to flow back, that is a separate *feed* (`ingest`) reading
152
+ the remote into a different collection — or a deliberate two-way design
153
+ they should ask for explicitly. Do not improvise bidirectional sync
154
+ inside a sync template.
155
+ - **`externalId` makes re-linking survivable.** Even if the snapshot is
156
+ lost, records with an `externalId` update their existing remote rows
157
+ instead of duplicating them; only snapshot-less records without an
158
+ `externalId` risk a duplicate create.
@@ -81,6 +81,7 @@ See [Wiki](config/helps/wiki.md) for details on how it works.
81
81
  - [Clients + Worklog](config/helps/billing-clients-worklog.md) — recipe for a client database and a per-client timesheet (Bundle A of the billing suite); set this up before invoicing
82
82
  - [Invoicing](config/helps/billing-invoice.md) — recipe for an invoice ledger + business profile with line items, host-computed totals, and PDF / bookkeeping action buttons (Bundle B; references the clients + worklog from Bundle A)
83
83
  - [Portfolio tracker](config/helps/portfolio-tracker.md) — recipe for a paired stock-quotes watchlist + holdings portfolio whose price/value are computed live from the quotes via a cross-collection derived ref
84
+ - [Egress sync](config/helps/egress-sync.md) — push a collection's records OUT to an external system (Notion, Sheets, a REST API): the snapshot-diff pattern for a collection-level agent "Sync" action, `externalId` write-back, and a reference `templates/sync.md`
84
85
 
85
86
  ## Workspace Layout
86
87
 
@@ -23,6 +23,19 @@ var FEED_SCHEDULES = [
23
23
  "weekly",
24
24
  "on-demand"
25
25
  ];
26
+ /** derived/embed/backlinks/rollup/toggle are host-computed or projected —
27
+ * never written to the record JSON, so required / value checks and
28
+ * edit-draft slots must not apply to them. THE single source for
29
+ * "computed" — lives here (zod-free at runtime) so browser code
30
+ * (`./draft`) and the zod record compiler (`./recordZ`, which re-exports
31
+ * it) share one set instead of drifting copies. */
32
+ var COMPUTED_TYPES = /* @__PURE__ */ new Set([
33
+ "derived",
34
+ "embed",
35
+ "backlinks",
36
+ "rollup",
37
+ "toggle"
38
+ ]);
26
39
  /** Narrowing guard: true when `every` is the field-driven arm. */
27
40
  function isFieldDrivenEvery(every) {
28
41
  return "fromField" in every;
@@ -58,6 +71,96 @@ function isSafeRecordId(value) {
58
71
  return !value.includes("..");
59
72
  }
60
73
  //#endregion
74
+ //#region src/collection/core/actionVisible.ts
75
+ /** Core matcher:
76
+ * - no `when` ⇒ always true (visible);
77
+ * - otherwise true only when `record[when.field]` is present and its
78
+ * stringified value is one of `when.in`.
79
+ * A missing/undefined/null field is treated as "not a match"
80
+ * (hidden), so a status-gated target never shows on a record that
81
+ * lacks the status. */
82
+ function whenMatches(when, record) {
83
+ if (!when) return true;
84
+ const value = record[when.field];
85
+ if (value === void 0 || value === null) return false;
86
+ return when.in.includes(String(value));
87
+ }
88
+ /** True when the action's button should render against `record` — and,
89
+ * server-side, whether it may RUN (visibility is the authorization
90
+ * rule, for every kind). */
91
+ function actionVisible(action, record) {
92
+ return whenMatches(action.when ?? action.require, record);
93
+ }
94
+ /** The run key naming one in-flight `kind: "agent"` action button —
95
+ * written by the server's dispatch guard, read back by the client from
96
+ * the detail response's `runningActions` to drive the spinner. ONE
97
+ * builder (isomorphic) so the two sides can't drift. Collection-level
98
+ * and per-record actions live in distinct namespaces so an id collision
99
+ * between `actions` and `collectionActions` can't alias. */
100
+ function agentActionRunKey(actionId, itemId) {
101
+ return itemId === void 0 ? `collection/${actionId}` : `item/${itemId}/${actionId}`;
102
+ }
103
+ /** True when the field should render against `record`. A field with
104
+ * no `when` is always shown; otherwise it's shown only when the
105
+ * record matches (e.g. hide a rating field until `visited` is true).
106
+ * Purely presentational — a hidden field's stored value is never
107
+ * altered, so toggling the gate back on restores it. */
108
+ function fieldVisible(field, record) {
109
+ return whenMatches(field.when, record);
110
+ }
111
+ //#endregion
112
+ //#region src/collection/core/backlinks.ts
113
+ /** The SOURCE records whose `via` field stores `recordId` (compared as
114
+ * strings, like every ref deref), with the optional `filter` applied —
115
+ * in the source items' given order. Fail-soft by construction: a `via`
116
+ * key that doesn't exist on the source records simply matches nothing.
117
+ * Callers pass DERIVED source records, so a `filter`/`display` on a
118
+ * derived column works when its formula is SELF-CONTAINED (an invoice
119
+ * `total` = sum over its own line items); a source column that derefs
120
+ * yet another collection stays absent — the same each-record-derives-
121
+ * against-itself rule ref targets follow. */
122
+ function backlinkRows(spec, recordId, sourceItems) {
123
+ if (!recordId) return [];
124
+ return sourceItems.filter((item) => String(item[spec.via] ?? "") === recordId && whenMatches(spec.filter, item));
125
+ }
126
+ /** Project one backlink row to the keys consumers surface: the source
127
+ * collection's primaryKey (rows must stay addressable — it's the link
128
+ * target) plus the declared `display` columns. Keys the row doesn't
129
+ * carry are simply absent, mirroring `projectFields` in getItems. */
130
+ function projectBacklinkRow(row, display, primaryKey) {
131
+ const keys = display.includes(primaryKey) ? display : [primaryKey, ...display];
132
+ return Object.fromEntries(keys.filter((key) => key in row).map((key) => [key, row[key]]));
133
+ }
134
+ /** Numeric coercion shared by the strict record lint (`./recordZ`) and
135
+ * rollup sums: a plain number, or a non-blank numeric string (renderers
136
+ * coerce those via `Number(...)`, so they display fine). Anything else —
137
+ * arrays (`[]` stringifies to `""` = 0, `[42]` to `"42"`), booleans,
138
+ * objects — is NaN. Lives here (zod-free) so both consumers share one
139
+ * definition of "numeric". */
140
+ function coerceNumeric(value) {
141
+ if (typeof value === "number") return value;
142
+ if (typeof value === "string" && value.trim() !== "") return Number(value);
143
+ return NaN;
144
+ }
145
+ /** The rollup aggregate over the matching source rows (plan step ⑤):
146
+ * `count` = how many rows match; `sum` = the total of `column` over
147
+ * them, skipping non-numeric / absent values (a partially-filled source
148
+ * still sums what's there). An EMPTY match set is a real 0 — the
149
+ * fail-soft null lives at the caller, for a source collection that
150
+ * couldn't be resolved at all. Same derived-source-records contract as
151
+ * `backlinkRows`: pass records derived against themselves, so summing a
152
+ * self-contained derived column (an invoice `total`) works. */
153
+ function rollupValue(spec, recordId, sourceItems) {
154
+ const rows = backlinkRows(spec, recordId, sourceItems);
155
+ if (spec.op === "count") return rows.length;
156
+ let total = 0;
157
+ for (const row of rows) {
158
+ const value = coerceNumeric(spec.column === void 0 ? void 0 : row[spec.column]);
159
+ if (Number.isFinite(value)) total += value;
160
+ }
161
+ return total;
162
+ }
163
+ //#endregion
61
164
  //#region src/collection/core/where.ts
62
165
  /** True when `record[field]` is absent (`undefined`/`null`) — the only case
63
166
  * where `ne` and every other op disagree on the result. */
@@ -787,6 +890,6 @@ function monthAnchorDate(year, month) {
787
890
  return new Date(Date.UTC(year, month - 1, 1));
788
891
  }
789
892
  //#endregion
790
- export { isFieldDrivenEvery as A, SAFE_SLUG_PATTERN as C, FEED_SCHEDULES as D, AGENT_INGEST_KIND as E, INGEST_KINDS as O, SAFE_RECORD_ID_PATTERN as S, isSafeSlug as T, evaluateDerived as _, compareYmd as a, selectDynamicRecord as b, monthAnchorDate as c, parseTimeRange as d, recordSpan as f, resolveRowRefs as g, deriveAll as h, buildMonthGrid as i, embedTargetId as k, parseIsoDate as l, ymdKey as m, assignLanes as n, dateOf as o, spanCoversDay as p, bucketRecords as r, daySlice as s, MINUTES_PER_DAY as t, parseIsoDateTime as u, firstDateField as v, isSafeRecordId as w, matchesWhere as x, resolveIcon as y };
893
+ export { SAFE_RECORD_ID_PATTERN as A, coerceNumeric as C, agentActionRunKey as D, actionVisible as E, COMPUTED_TYPES as F, FEED_SCHEDULES as I, INGEST_KINDS as L, isSafeRecordId as M, isSafeSlug as N, fieldVisible as O, AGENT_INGEST_KIND as P, embedTargetId as R, backlinkRows as S, rollupValue as T, evaluateDerived as _, compareYmd as a, selectDynamicRecord as b, monthAnchorDate as c, parseTimeRange as d, recordSpan as f, resolveRowRefs as g, deriveAll as h, buildMonthGrid as i, SAFE_SLUG_PATTERN as j, whenMatches as k, parseIsoDate as l, ymdKey as m, assignLanes as n, dateOf as o, spanCoversDay as p, bucketRecords as r, daySlice as s, MINUTES_PER_DAY as t, parseIsoDateTime as u, firstDateField as v, projectBacklinkRow as w, matchesWhere as x, resolveIcon as y, isFieldDrivenEvery as z };
791
894
 
792
- //# sourceMappingURL=calendarGrid-kO6rGfm0.js.map
895
+ //# sourceMappingURL=calendarGrid-B3dtFN-m.js.map