@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.
- package/assets/helps/collection-skills.md +94 -11
- package/assets/helps/custom-view.md +28 -0
- package/assets/helps/egress-sync.md +158 -0
- package/assets/helps/index.md +1 -0
- package/dist/{calendarGrid-kO6rGfm0.js → calendarGrid-B3dtFN-m.js} +105 -2
- package/dist/calendarGrid-B3dtFN-m.js.map +1 -0
- package/dist/{calendarGrid-C1rfCXJ3.cjs → calendarGrid-CRaMAguM.cjs} +158 -1
- package/dist/calendarGrid-CRaMAguM.cjs.map +1 -0
- package/dist/collection/core/actionVisible.d.ts +15 -4
- package/dist/collection/core/backlinks.d.ts +40 -0
- package/dist/collection/core/mutateAction.d.ts +14 -0
- package/dist/collection/core/recordZ.d.ts +4 -5
- package/dist/collection/core/schema.d.ts +21 -4
- package/dist/collection/core/schemaZ.d.ts +209 -16
- package/dist/collection/core/uiTypes.d.ts +26 -0
- package/dist/collection/index.cjs +21 -36
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +1 -0
- package/dist/collection/index.js +12 -33
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +1 -1
- package/dist/collection/registry/server/index.js +1 -1
- package/dist/collection/server/index.cjs +8 -2
- package/dist/collection/server/index.d.ts +2 -0
- package/dist/collection/server/index.js +3 -2
- package/dist/collection/server/io.d.ts +9 -0
- package/dist/collection/server/mutate.d.ts +24 -0
- package/dist/collection/server/ontology.d.ts +35 -0
- package/dist/collection-watchers/index.cjs +5 -4
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +3 -2
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/index.cjs +3 -3
- package/dist/feeds/server/index.js +3 -3
- package/dist/{ingestTypes-BIFXlw7M.cjs → ingestTypes-Cq65Bv-N.cjs} +2 -2
- package/dist/{ingestTypes-BIFXlw7M.cjs.map → ingestTypes-Cq65Bv-N.cjs.map} +1 -1
- package/dist/{ingestTypes-KuYPX9Ea.js → ingestTypes-DvsJhqMx.js} +2 -2
- package/dist/{ingestTypes-KuYPX9Ea.js.map → ingestTypes-DvsJhqMx.js.map} +1 -1
- package/dist/{server-Cc6XcyOA.js → server-0IVR-Y_G.js} +316 -35
- package/dist/server-0IVR-Y_G.js.map +1 -0
- package/dist/{server-Cd5WdM8a.cjs → server-BeoOWQ-a.cjs} +347 -42
- package/dist/server-BeoOWQ-a.cjs.map +1 -0
- package/package.json +1 -1
- package/dist/calendarGrid-C1rfCXJ3.cjs.map +0 -1
- package/dist/calendarGrid-kO6rGfm0.js.map +0 -1
- package/dist/server-Cc6XcyOA.js.map +0 -1
- 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
|
|
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.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
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`
|
|
793
|
-
display-only / host-computed (`putItems`
|
|
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.
|
package/assets/helps/index.md
CHANGED
|
@@ -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 {
|
|
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-
|
|
895
|
+
//# sourceMappingURL=calendarGrid-B3dtFN-m.js.map
|