@mulmoclaude/core 5.1.0 → 5.4.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 (47) hide show
  1. package/README.md +17 -1
  2. package/assets/helps/error-recovery.md +78 -0
  3. package/assets/helps/google-calendar-collection.md +92 -12
  4. package/dist/collection/core/schemaZ.d.ts +15 -2
  5. package/dist/collection/core/viewChatPolicy.d.ts +15 -3
  6. package/dist/collection/firestore.cjs +2 -1
  7. package/dist/collection/firestore.cjs.map +1 -1
  8. package/dist/collection/firestore.js +3 -2
  9. package/dist/collection/firestore.js.map +1 -1
  10. package/dist/collection/index.cjs +15 -3
  11. package/dist/collection/index.cjs.map +1 -1
  12. package/dist/collection/index.js +15 -3
  13. package/dist/collection/index.js.map +1 -1
  14. package/dist/collection/registry/server/index.cjs +2 -2
  15. package/dist/collection/registry/server/index.js +2 -2
  16. package/dist/collection/server/firestoreDocs.d.ts +10 -0
  17. package/dist/collection/server/index.cjs +2 -2
  18. package/dist/collection/server/index.js +2 -2
  19. package/dist/collection-watchers/index.cjs +2 -2
  20. package/dist/collection-watchers/index.js +2 -2
  21. package/dist/{discovery-DCMW05Bp.cjs → discovery-B79IxEM6.cjs} +15 -5
  22. package/dist/discovery-B79IxEM6.cjs.map +1 -0
  23. package/dist/{discovery-CBjI1lx9.js → discovery-DIGau7M8.js} +15 -5
  24. package/dist/discovery-DIGau7M8.js.map +1 -0
  25. package/dist/feeds/server/index.cjs +2 -2
  26. package/dist/feeds/server/index.js +2 -2
  27. package/dist/google/calendar.d.ts +24 -0
  28. package/dist/google/collectionPush.d.ts +45 -2
  29. package/dist/google/deletePlan.d.ts +32 -0
  30. package/dist/google/eventDerived.d.ts +19 -0
  31. package/dist/google/index.cjs +179 -18
  32. package/dist/google/index.cjs.map +1 -1
  33. package/dist/google/index.d.ts +4 -2
  34. package/dist/google/index.js +174 -19
  35. package/dist/google/index.js.map +1 -1
  36. package/dist/remote-view/index.cjs +9 -7
  37. package/dist/remote-view/index.cjs.map +1 -1
  38. package/dist/remote-view/index.d.ts +3 -3
  39. package/dist/remote-view/index.js +9 -7
  40. package/dist/remote-view/index.js.map +1 -1
  41. package/dist/{server-rA9FSkl2.cjs → server-BUbU9G-J.cjs} +2 -2
  42. package/dist/{server-rA9FSkl2.cjs.map → server-BUbU9G-J.cjs.map} +1 -1
  43. package/dist/{server-BMZ_BcPH.js → server-DZ7Cq7s_.js} +2 -2
  44. package/dist/{server-BMZ_BcPH.js.map → server-DZ7Cq7s_.js.map} +1 -1
  45. package/package.json +1 -1
  46. package/dist/discovery-CBjI1lx9.js.map +0 -1
  47. package/dist/discovery-DCMW05Bp.cjs.map +0 -1
package/README.md CHANGED
@@ -13,7 +13,7 @@ rather than imported, which is what lets the same code run under either.
13
13
 
14
14
  | Area | Entries |
15
15
  | --- | --- |
16
- | Collections | `./collection`, `./collection/server`, `./collection/paths`, `./collection/registry`, `./collection/registry/server`, `./collection-watchers` |
16
+ | Collections | `./collection`, `./collection/server`, `./collection/paths`, `./collection/firestore`, `./collection/registry`, `./collection/registry/server`, `./collection-watchers` |
17
17
  | Knowledge | `./wiki`, `./wiki/server`, `./wiki/paths`, `./feeds`, `./feeds/server`, `./feeds/paths` |
18
18
  | Google | `./google` — OAuth (loopback + PKCE), token store, Calendar / Tasks / Drive REST |
19
19
  | Runtime | `./scheduler`, `./notifier`, `./skill-bridge`, `./file-change`, `./workspace-setup`, `./artifacts` |
@@ -26,6 +26,22 @@ rather than imported, which is what lets the same code run under either.
26
26
  `./whisper/client`, `./workspace-setup/slug`, `./translation/client`,
27
27
  `./remote-view`, `./remote-host` and `./plugin-vue`.
28
28
 
29
+ `firebase` is an **optional** peer, and only the entries named for it need it:
30
+ `./collection/firestore`, `./remote-host` and `./remote-host/server`. Every
31
+ other entry must LOAD with the package absent — a host that uses no Firestore
32
+ installs nothing — under `import` and `require` alike.
33
+ `test/workspace/collections/test_optionalFirebasePeer.ts` in the MulmoClaude
34
+ repository holds that line: it loads every entry in the exports map, under both
35
+ conditions, with `firebase` made unresolvable. It is a sweep, not a static
36
+ guarantee — an import added to a module an entry reaches is caught by running
37
+ that test, not by the type checker.
38
+
39
+ A host wires shared collections by handing `setFirestoreAccessor` a
40
+ `FirestoreDocs`. Build it with `createFirestoreDocs` from
41
+ `./collection/firestore` — that adapter tracks the interface. **Hand-writing an
42
+ implementation means a new member is a compile break on upgrade** — which is
43
+ what `timestamp` was for anyone who had one.
44
+
29
45
  The package also ships `assets/helps/*` — the help documents the agent reads at
30
46
  runtime — which is why a change there alone still warrants a release.
31
47
 
@@ -657,6 +657,84 @@ workspace is already on a real filesystem and a conflict still will not
657
657
  clear, that is a new bug — report it with the calendar id and the
658
658
  record.
659
659
 
660
+ ## Push skips a record: "the record id cannot be used as a Google event id"
661
+
662
+ ### Symptoms
663
+
664
+ Push reports a record as skipped and names its id. It never reaches
665
+ Google, however many times the user presses the button. Editing the
666
+ record's other fields changes nothing.
667
+
668
+ ### Cause
669
+
670
+ A push CREATES the Google event with the record's own primary-key value
671
+ as the event id, so a record keeps its identity across the round trip.
672
+ Google constrains an event id to **lower-case base32hex**: 5-1024
673
+ characters from `0-9a-v` only. So no `w`, `x`, `y` or `z`, no upper
674
+ case, no hyphen, no underscore, no dot.
675
+
676
+ A semantic id anyone would reach for — `team-standup`, `Weekly_Sync`,
677
+ `2026-07-17` — breaks the rule on the hyphen, the case or the letters.
678
+ Records created through the collection UI get a generated id that
679
+ satisfies it; only a primary key someone typed or imported can fail.
680
+
681
+ ### Fix
682
+
683
+ Recreate the record without setting the primary field, and let the UI
684
+ generate the id. If the id has to be meaningful, it can be — as long as
685
+ every character is a digit or a letter `a` through `v` and there are at
686
+ least five of them (`teamstandup` passes, `team-standup` does not).
687
+
688
+ Renaming the primary key of an EXISTING record means deleting it and
689
+ adding it again: the primary key is the record's identity, so nothing
690
+ else reassigns it.
691
+
692
+ ## A record keeps showing as "edited" and the same push repeats forever
693
+
694
+ ### Symptoms
695
+
696
+ The same record is pushed on every cycle with content that never
697
+ changes, and the Google event's `updated` moves each time. No conflict
698
+ is reported and no data is lost — the calendar just receives the same
699
+ write over and over.
700
+
701
+ ### Cause
702
+
703
+ The push compares the record against the BASELINE in
704
+ `<workspace>/data/calendar/.push-state.json`, and the baseline advances
705
+ only when a PULL reports the event as changed. If a write reaches Google
706
+ but does NOT change the event's stored value — Google normalising the
707
+ value it was sent, or a write of the value the event already held — then
708
+ Google reports no change, the pull never sees the event, and the
709
+ baseline stays behind. The next push sees the same difference again.
710
+
711
+ This was checked on a live calendar and the Events API did **not**
712
+ normalise `description` HTML (empty `<div>`, `style` attributes and all
713
+ survived a round trip byte-for-byte), so it is not the common case. It
714
+ is written down because the SYMPTOM is indistinguishable from a real
715
+ repeated edit, and the two are told apart the same way.
716
+
717
+ ### Fix
718
+
719
+ Look at the baseline, not at the record. Read the event's entry in
720
+ `.push-state.json` and compare it with what `google` (`kind:
721
+ "calendarListEvents"`) reports for that event id:
722
+
723
+ - **Baseline matches Google, record differs** — an ordinary unpushed
724
+ edit. Nothing is wrong; the next push sends it.
725
+ - **Baseline differs from Google** — the baseline is stale. `autoPush`
726
+ does NOT fix this one: it converges only when the push actually
727
+ changed the stored value, because only then does the pull carry the
728
+ event. Press Sync to force a pull; if the event is still not reported,
729
+ the value Google holds is already what the push keeps sending, and the
730
+ baseline has to be rebuilt.
731
+
732
+ Rebuilding: delete the calendar's entry from `.push-state.json` (or the
733
+ file, if it covers only that calendar) and press Sync. The next pull
734
+ writes a fresh baseline from Google's current values. Tell the user
735
+ first — until that pull lands, a genuine unpushed local edit would be
736
+ indistinguishable from a mirrored one.
737
+
660
738
  ## A calendar collection only ever holds a handful of records
661
739
 
662
740
  ### Symptoms
@@ -17,6 +17,7 @@ author the schema when the user asks for one.
17
17
  | Google → collection | the `googleCalendar` block | hourly, on creation, and on the **Sync** button |
18
18
  | collection → Google | the **Push to Google** button | whenever the user clicks it |
19
19
  | collection → Google | `"autoPush": true` in the block | hourly, immediately before each pull |
20
+ | collection → Google, deletions | `"propagateDeletes": true` in the block | with every push. Off by default; see "Deleting" |
20
21
 
21
22
  When a user asks for "two-way sync", `autoPush` is the answer: set it and each
22
23
  scheduled run pushes local edits up and then pulls Google's changes down, as one
@@ -66,6 +67,9 @@ it isn't, sync silently does nothing until they link it in settings.
66
67
  would see rows with no content.
67
68
  - `autoPush` — push local edits on the sync schedule, just before each pull.
68
69
  Omit it (the default) and the push stays a button. See "Both directions".
70
+ - `propagateDeletes` — delete the Google event when its record is deleted here.
71
+ Omit it (the default) and a local deletion is reported and nothing else. See
72
+ "Deleting".
69
73
 
70
74
  Mappable event fields, two-way first: `summary`, `start`, `end`, `description`,
71
75
  `location`, `colorId`.
@@ -78,15 +82,56 @@ time),
78
82
  `transparency` (`"transparent"` when the event does not consume the attendee's
79
83
  time; `""` means opaque), `eventType` (all six Google returns: `default` / `birthday` / `focusTime` /
80
84
  `fromGmail` / `outOfOffice` / `workingLocation`), `hangoutLink` (the Meet URL),
81
- `recurringEventId` and `originalStartTime`.
85
+ `recurringEventId`, `originalStartTime`, `selfResponseStatus` and
86
+ `conferenceVideoUri`.
82
87
 
83
- The last two are how a recurring series stays legible. The sync asks Google to
84
- expand recurrences, so a weekly meeting arrives as one event per occurrence;
85
- `recurringEventId` names the series each occurrence came from (`""` for a
86
- one-off), and `originalStartTime` is the slot the occurrence held before anyone
87
- dragged it — so a moved occurrence reads as a move rather than as a deletion
88
- plus a new event. Map them when the user asks why one calendar edit produced a
89
- large batch of record changes.
88
+ `recurringEventId` and `originalStartTime` are how a recurring series stays
89
+ legible. The sync asks Google to expand recurrences, so a weekly meeting arrives
90
+ as one event per occurrence; `recurringEventId` names the series each occurrence
91
+ came from (`""` for a one-off), and `originalStartTime` is the slot the
92
+ occurrence held before anyone dragged it — so a moved occurrence reads as a move
93
+ rather than as a deletion plus a new event. Map them when the user asks why one
94
+ calendar edit produced a large batch of record changes.
95
+
96
+ ### `selfResponseStatus` and `conferenceVideoUri`
97
+
98
+ These two are not Google field names. Google answers `attendees` as an ARRAY and
99
+ `conferenceData` as an array inside an object, and a collection field holds one
100
+ value — so each is folded down to the single scalar the collection asks it for.
101
+ The rest of those structures is dropped; there is no way to map the attendee
102
+ list itself.
103
+
104
+ **`selfResponseStatus`** — the signed-in user's own `responseStatus`:
105
+ `needsAction`, `declined`, `tentative` or `accepted`.
106
+
107
+ `""` means Google reported none, and that is the COMMON case, not an edge one:
108
+ an event with no attendees has no entry to mark as the user, which is most of a
109
+ personal calendar. So it reads as "nothing said", never as "not going".
110
+
111
+ Filter with **`!= "declined"`**, never with `== "accepted"` — the second hides
112
+ every solo event too. A `flag` field is the usual way:
113
+
114
+ ```jsonc
115
+ "fields": {
116
+ "rsvp": { "type": "string", "label": "RSVP" },
117
+ "onMySchedule": {
118
+ "type": "flag",
119
+ "label": "Mine",
120
+ "where": [{ "field": "rsvp", "op": "ne", "value": "declined" }]
121
+ }
122
+ },
123
+ "googleCalendar": { "map": { "rsvp": "selfResponseStatus" } }
124
+ ```
125
+
126
+ **`conferenceVideoUri`** — the URL that joins the meeting, taken from the
127
+ `video` entry point. `""` when the event has no conference, and also when its
128
+ only entry points are a phone number or a dial-in page: a column named for
129
+ joining that sometimes held `tel:` would be worse than one the caller can see is
130
+ empty.
131
+
132
+ `hangoutLink` already carries this for Google Meet. `conferenceVideoUri` is what
133
+ reaches a calendar whose meetings are Zoom or Teams. A Meet event fills both, so
134
+ map whichever the user's calendar actually uses.
90
135
 
91
136
  `description` is the event body, and Google stores limited **HTML** in it. It is
92
137
  kept verbatim — mirroring it through a plain-text field and pushing it back would
@@ -190,10 +235,9 @@ What the button does and deliberately does not do:
190
235
  - **Creates** an event for a record that never came from a sync.
191
236
  - **Updates** only the fields the user actually changed, so attendees,
192
237
  reminders and recurrence rules stay untouched.
193
- - **Never deletes.** A record deleted locally leaves its Google event alone —
194
- a Google delete removes the event for every attendee and cannot be undone.
195
- The count is reported so the user knows it was skipped; deleting for real is
196
- the `google` tool's `calendarDeleteEvent`, after confirming with them.
238
+ - **Never deletes, unless the collection asked it to.** See "Deleting" below;
239
+ without `propagateDeletes` a record deleted locally leaves its Google event
240
+ alone and the count is reported so the user knows.
197
241
  - **Skips a record edited on both sides** and reports it, rather than picking a
198
242
  winner. The user resolves it by editing one side to match. Under `autoPush`
199
243
  the pull that follows leaves that record alone too, so the local edit is not
@@ -225,6 +269,42 @@ reach by id but has not added to their calendar list has no role to check, so
225
269
  the push goes ahead and reports Google's own refusal if the write turns out not
226
270
  to be allowed — being unlisted is not treated as being read-only.
227
271
 
272
+ ## Deleting
273
+
274
+ By default, a record deleted in the collection leaves its Google event alone.
275
+ The push reports the count and does nothing else, and the next sync brings the
276
+ record back — which is correct for a calendar Google owns, and wrong for one
277
+ where the collection is the primary copy.
278
+
279
+ `"propagateDeletes": true` in the `googleCalendar` block makes the push delete
280
+ those events too. **Ask the user before adding it**, the same as `autoPush` and
281
+ for a stronger reason: `autoPush` changes WHEN a write happens, this makes a
282
+ write irreversible.
283
+
284
+ Even on, the push **refuses an event that carries attendees** and reports it
285
+ instead. Deleting an invited event withdraws it from the guests' calendars,
286
+ which is a different act from tidying your own. **Any** attendee entry refuses,
287
+ including the one Google adds for the organiser — so an event only the user was
288
+ ever on is refused too, deliberately: telling the two apart means deciding which
289
+ entry is the user from a payload that may not say, and being wrong there
290
+ withdraws a real invitation. Either way, an event the user actually wants gone
291
+ is deleted with the `google` tool's `calendarDeleteEvent`, after confirming with
292
+ them.
293
+
294
+ The delete also carries the version the check was made against, so an attendee
295
+ added while the push was running makes Google refuse it rather than letting a
296
+ decision taken a moment earlier stand. That is reported like any other refusal;
297
+ pressing Push again re-checks.
298
+
299
+ There is no undo here and this app keeps no copy of what it deleted. Google
300
+ Calendar's own Trash holds a deleted event for a while, and that is where a
301
+ mistake is recovered from.
302
+
303
+ A deletion that carried stops being reported, because its baseline entry goes
304
+ with it. A deletion that was REFUSED keeps being reported on every push — the
305
+ event is still standing in Google, and the report is the only thing that says
306
+ so.
307
+
228
308
  ## Not for this
229
309
 
230
310
  A `dataSource` (CSV-backed) collection is read-only and cannot declare
@@ -513,8 +513,12 @@ export declare const AgentIngestZ: z.ZodObject<{
513
513
  * limitation: `transparency` is writable and `eventType` is writable at
514
514
  * creation, yet neither is sent. That is also why
515
515
  * widening this enum needs no `.push-state.json` migration: the push baseline
516
- * (`ShadowEvent`) is keyed off the pushable list, not off this one. */
517
- export declare const GOOGLE_CALENDAR_SOURCE_FIELDS: readonly ["summary", "start", "end", "description", "location", "colorId", "htmlLink", "status", "recurringEventId", "originalStartTime", "updated", "transparency", "eventType", "hangoutLink"];
516
+ * (`ShadowEvent`) is keyed off the pushable list, not off this one.
517
+ *
518
+ * The last two are not Google field names but DERIVED scalars
519
+ * (`google/eventDerived.ts`): the structures they come from are arrays, and a
520
+ * collection field holds one value. */
521
+ export declare const GOOGLE_CALENDAR_SOURCE_FIELDS: readonly ["summary", "start", "end", "description", "location", "colorId", "htmlLink", "status", "recurringEventId", "originalStartTime", "updated", "transparency", "eventType", "hangoutLink", "selfResponseStatus", "conferenceVideoUri"];
518
522
  /** Marks a collection as the destination of the LLM-free Google Calendar
519
523
  * sync (#2095). `map` is collectionField → Google event field, so the user's
520
524
  * collection keeps whatever field names it already uses. */
@@ -535,8 +539,11 @@ export declare const GoogleCalendarSyncZ: z.ZodObject<{
535
539
  transparency: "transparency";
536
540
  eventType: "eventType";
537
541
  hangoutLink: "hangoutLink";
542
+ selfResponseStatus: "selfResponseStatus";
543
+ conferenceVideoUri: "conferenceVideoUri";
538
544
  }>>;
539
545
  autoPush: z.ZodOptional<z.ZodBoolean>;
546
+ propagateDeletes: z.ZodOptional<z.ZodBoolean>;
540
547
  }, z.core.$strip>;
541
548
  /** `ingest` is a discriminated union on `kind`: the three declarative
542
549
  * retrievers fetch-and-map; `agent` dispatches a hidden worker. Optional on
@@ -1148,8 +1155,11 @@ declare const CollectionObjectZ: z.ZodObject<{
1148
1155
  transparency: "transparency";
1149
1156
  eventType: "eventType";
1150
1157
  hangoutLink: "hangoutLink";
1158
+ selfResponseStatus: "selfResponseStatus";
1159
+ conferenceVideoUri: "conferenceVideoUri";
1151
1160
  }>>;
1152
1161
  autoPush: z.ZodOptional<z.ZodBoolean>;
1162
+ propagateDeletes: z.ZodOptional<z.ZodBoolean>;
1153
1163
  }, z.core.$strip>>;
1154
1164
  dynamicIcon: z.ZodOptional<z.ZodObject<{
1155
1165
  source: z.ZodObject<{
@@ -1646,8 +1656,11 @@ export declare const CollectionSchemaZ: z.ZodPreprocess<z.ZodObject<{
1646
1656
  transparency: "transparency";
1647
1657
  eventType: "eventType";
1648
1658
  hangoutLink: "hangoutLink";
1659
+ selfResponseStatus: "selfResponseStatus";
1660
+ conferenceVideoUri: "conferenceVideoUri";
1649
1661
  }>>;
1650
1662
  autoPush: z.ZodOptional<z.ZodBoolean>;
1663
+ propagateDeletes: z.ZodOptional<z.ZodBoolean>;
1651
1664
  }, z.core.$strip>>;
1652
1665
  dynamicIcon: z.ZodOptional<z.ZodObject<{
1653
1666
  source: z.ZodObject<{
@@ -7,7 +7,19 @@ import { CollectionCustomView } from './schema';
7
7
  * code composes the prompt text; letting it also decide whether that text runs
8
8
  * would put both halves of the decision inside the sandbox.
9
9
  *
10
- * The phone runtime does not consult this: it always sends, because a phone has
11
- * no Enter key for the user to press (receptron/mulmoterminal#1253). The flag
12
- * governs the desktop custom view and the desktop phone-frame preview. */
10
+ * EVERY surface honours this declaration, the phone included. The phone once
11
+ * always sent, on the grounds that it has no Enter key to press — but that made
12
+ * the same view behave differently depending on where it was opened, and it
13
+ * silently overrode default-deny on a device the author never tested.
14
+ *
15
+ * The phone does not call THIS function: it reads `allowSendChat` out of the
16
+ * collection schema it already receives (`toDetail` sends the schema whole) and
17
+ * applies the same `=== true` rule in its own copy. Two reasons for that copy
18
+ * have been written down and both were wrong, so this states none — if you are
19
+ * touching it, check whether the phone can import this subpath and delete the
20
+ * copy if it can. Until then, keep the two in step.
21
+ *
22
+ * It must also ask about the view whose document is RENDERED, not the selected
23
+ * one — during a view switch those differ, and the selected view's flag would
24
+ * decide for the previous view's still-running sandbox. */
13
25
  export declare function customViewSendsChat(view: Pick<CollectionCustomView, "allowSendChat">): boolean;
@@ -50,7 +50,8 @@ function createFirestoreDocs(database) {
50
50
  seen = true;
51
51
  onChanged(snapshot.docChanges().map((change) => change.doc.id), { initial });
52
52
  }, onError);
53
- }
53
+ },
54
+ timestamp: (seconds, nanoseconds) => new firebase_firestore.Timestamp(seconds, nanoseconds)
54
55
  };
55
56
  }
56
57
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"firestore.cjs","names":[],"sources":["../../src/collection/server/firestoreDocs.ts"],"sourcesContent":["// The seam between the firestore store and the Firestore SDK.\n//\n// The modular SDK is function-based (`getDocs(query(collection(db, …)))`), so a\n// store that imported those functions directly could not be tested without a\n// real backend — a fake `db` would still be handed to the real functions.\n// Narrowing to this interface makes the backend swappable: core ships\n// `createFirestoreDocs` over the real SDK, and the tests inject an in-memory\n// fake that satisfies the same shape. That is what lets this repository's tests\n// run with no API key and no network.\n//\n// Deliberately minimal and id-keyed: no query builder, no field ordering, no\n// cursors. Everything the store needs is \"the documents of one collection path,\n// ordered by document id\" — see the store header for why field ordering is\n// avoided entirely.\n//\n// The collection path is an ARGUMENT, not something this module composes. It\n// owns the SDK calls; `firestoreStore` owns where the documents live.\n\nimport {\n collection as firestoreCollection,\n doc,\n getDoc,\n getDocs,\n orderBy,\n query as firestoreQuery,\n onSnapshot,\n runTransaction,\n setDoc,\n type Firestore,\n} from \"firebase/firestore\";\n\n/** One stored record document. `data` is the document's own fields — the\n * record itself, not a wrapper around it; see `set` below. The store\n * validates its shape, because a document written by hand could be\n * anything. */\nexport interface FirestoreDoc {\n id: string;\n data: unknown;\n}\n\nexport interface FirestoreDocs {\n /** Every document under `collectionPath`, ordered by document id. */\n list: (collectionPath: string) => Promise<FirestoreDoc[]>;\n /** One document's fields, or null when it doesn't exist. */\n get: (collectionPath: string, docId: string) => Promise<unknown | null>;\n /** Create or replace. */\n set: (collectionPath: string, docId: string, data: Record<string, unknown>) => Promise<void>;\n /** Create only. Returns false when the id already exists — atomic, so two\n * concurrent creates can't both observe \"missing\". */\n create: (collectionPath: string, docId: string, data: Record<string, unknown>) => Promise<boolean>;\n /** Delete. Returns false when the id didn't exist, so a caller can tell a\n * real delete from a typo'd id. */\n delete: (collectionPath: string, docId: string) => Promise<boolean>;\n /** Listen to `collectionPath`. Every snapshot reports the ids that changed\n * in it, and whether it is the FIRST one.\n *\n * `initial` is not a nicety. `onSnapshot` delivers the current contents\n * immediately, as one snapshot in which every existing document reads as\n * `added` — so a listener that treats snapshots uniformly announces the\n * whole collection as changed the moment it arms, on every mount. The flag\n * is passed up rather than swallowed here so the decision (and its test)\n * lives with the store's policy, next to the rest of it.\n *\n * `onError` fires at most once per subscription: a Firestore listen error\n * TERMINATES the listener and never recovers on its own. Re-subscribing is\n * the caller's job (`firestore/listen.ts` holds the policy).\n *\n * Returns the detach function synchronously — `onSnapshot` is not async. */\n watch: (collectionPath: string, onChanged: (ids: string[], meta: { initial: boolean }) => void, onError: (error: unknown) => void) => () => void;\n}\n\n/** The real implementation over the modular SDK.\n *\n * THE RECORD IS THE DOCUMENT. Its fields are written at the top level, not\n * nested under a `data` key. This is not a matter of taste: the deployed\n * security rules read `request.resource.data.<field>` and\n * `resource.data[submit.emailField]` — a wrapper would put every field one\n * level down, so the required-field checks, the status state machine and the\n * \"your own row\" predicate would all read absent values and fail closed. A\n * shared record's shape is part of the authorization contract.\n *\n * `orderBy(\"__name__\")` orders by DOCUMENT ID. Ordering by a record field\n * would silently EXCLUDE documents missing that field (the trap\n * `remote-host/server/hostRunner.ts:158-169` documents), turning a read into a\n * partial one; the document id is always present and gives the stable order\n * the store contract requires. */\nexport function createFirestoreDocs(database: Firestore): FirestoreDocs {\n return {\n list: async (collectionPath) => {\n const snapshot = await getDocs(firestoreQuery(firestoreCollection(database, collectionPath), orderBy(\"__name__\")));\n return snapshot.docs.map((entry) => ({ id: entry.id, data: entry.data() }));\n },\n get: async (collectionPath, docId) => {\n const snapshot = await getDoc(doc(database, collectionPath, docId));\n return snapshot.exists() ? snapshot.data() : null;\n },\n set: async (collectionPath, docId, data) => {\n await setDoc(doc(database, collectionPath, docId), data);\n },\n create: (collectionPath, docId, data) =>\n runTransaction(database, async (transaction) => {\n const ref = doc(database, collectionPath, docId);\n const existing = await transaction.get(ref);\n if (existing.exists()) return false;\n transaction.set(ref, data);\n return true;\n }),\n // Firestore's deleteDoc succeeds on a missing document, so an existence\n // check is what makes \"deleted\" distinguishable from \"there was nothing\".\n // It runs INSIDE the transaction (like `create`): a plain get-then-delete\n // would report `true` for a document a concurrent client had already\n // removed, i.e. claim a delete this call never performed.\n delete: (collectionPath, docId) =>\n runTransaction(database, async (transaction) => {\n const ref = doc(database, collectionPath, docId);\n const existing = await transaction.get(ref);\n if (!existing.exists()) return false;\n transaction.delete(ref);\n return true;\n }),\n // `docChanges()` rather than the whole snapshot: it names the documents\n // that moved, which is what lets the store report per-record changes\n // instead of \"something in this collection changed\". Added, modified and\n // removed are all reported the same way — the store's listener takes an\n // id, and re-reads; what KIND of change it was is not something a\n // reconcile pass needs to be told.\n //\n // `includeMetadataChanges` is deliberately left off: local writes and\n // has-pending-writes flips would otherwise wake every listener for\n // changes this process just made.\n watch: (collectionPath, onChanged, onError) => {\n let seen = false;\n return onSnapshot(\n firestoreCollection(database, collectionPath),\n (snapshot) => {\n const initial = !seen;\n seen = true;\n onChanged(\n snapshot.docChanges().map((change) => change.doc.id),\n { initial },\n );\n },\n onError,\n );\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAsFA,SAAgB,oBAAoB,UAAoC;CACtE,OAAO;EACL,MAAM,OAAO,mBAAmB;GAE9B,QAAO,OAAA,GADgB,mBAAA,QAAA,EAAA,GAAQ,mBAAA,MAAA,EAAA,GAAe,mBAAA,WAAA,CAAoB,UAAU,cAAc,IAAA,GAAG,mBAAA,QAAA,CAAQ,UAAU,CAAC,CAAC,EAAA,CACjG,KAAK,KAAK,WAAW;IAAE,IAAI,MAAM;IAAI,MAAM,MAAM,KAAK;GAAE,EAAE;EAC5E;EACA,KAAK,OAAO,gBAAgB,UAAU;GACpC,MAAM,WAAW,OAAA,GAAM,mBAAA,OAAA,EAAA,GAAO,mBAAA,IAAA,CAAI,UAAU,gBAAgB,KAAK,CAAC;GAClE,OAAO,SAAS,OAAO,IAAI,SAAS,KAAK,IAAI;EAC/C;EACA,KAAK,OAAO,gBAAgB,OAAO,SAAS;GAC1C,OAAA,GAAM,mBAAA,OAAA,EAAA,GAAO,mBAAA,IAAA,CAAI,UAAU,gBAAgB,KAAK,GAAG,IAAI;EACzD;EACA,SAAS,gBAAgB,OAAO,UAAA,GAC9B,mBAAA,eAAA,CAAe,UAAU,OAAO,gBAAgB;GAC9C,MAAM,OAAA,GAAM,mBAAA,IAAA,CAAI,UAAU,gBAAgB,KAAK;GAE/C,KAAI,MADmB,YAAY,IAAI,GAAG,EAAA,CAC7B,OAAO,GAAG,OAAO;GAC9B,YAAY,IAAI,KAAK,IAAI;GACzB,OAAO;EACT,CAAC;EAMH,SAAS,gBAAgB,WAAA,GACvB,mBAAA,eAAA,CAAe,UAAU,OAAO,gBAAgB;GAC9C,MAAM,OAAA,GAAM,mBAAA,IAAA,CAAI,UAAU,gBAAgB,KAAK;GAE/C,IAAI,EAAC,MADkB,YAAY,IAAI,GAAG,EAAA,CAC5B,OAAO,GAAG,OAAO;GAC/B,YAAY,OAAO,GAAG;GACtB,OAAO;EACT,CAAC;EAWH,QAAQ,gBAAgB,WAAW,YAAY;GAC7C,IAAI,OAAO;GACX,QAAA,GAAO,mBAAA,WAAA,EAAA,GACL,mBAAA,WAAA,CAAoB,UAAU,cAAc,IAC3C,aAAa;IACZ,MAAM,UAAU,CAAC;IACjB,OAAO;IACP,UACE,SAAS,WAAW,CAAC,CAAC,KAAK,WAAW,OAAO,IAAI,EAAE,GACnD,EAAE,QAAQ,CACZ;GACF,GACA,OACF;EACF;CACF;AACF"}
1
+ {"version":3,"file":"firestore.cjs","names":[],"sources":["../../src/collection/server/firestoreDocs.ts"],"sourcesContent":["// The seam between the firestore store and the Firestore SDK.\n//\n// The modular SDK is function-based (`getDocs(query(collection(db, …)))`), so a\n// store that imported those functions directly could not be tested without a\n// real backend — a fake `db` would still be handed to the real functions.\n// Narrowing to this interface makes the backend swappable: core ships\n// `createFirestoreDocs` over the real SDK, and the tests inject an in-memory\n// fake that satisfies the same shape. That is what lets this repository's tests\n// run with no API key and no network.\n//\n// Deliberately minimal and id-keyed: no query builder, no field ordering, no\n// cursors. Everything the store needs is \"the documents of one collection path,\n// ordered by document id\" — see the store header for why field ordering is\n// avoided entirely.\n//\n// The collection path is an ARGUMENT, not something this module composes. It\n// owns the SDK calls; `firestoreStore` owns where the documents live.\n\nimport {\n collection as firestoreCollection,\n doc,\n getDoc,\n getDocs,\n orderBy,\n query as firestoreQuery,\n onSnapshot,\n runTransaction,\n setDoc,\n Timestamp,\n type Firestore,\n} from \"firebase/firestore\";\n\n/** One stored record document. `data` is the document's own fields — the\n * record itself, not a wrapper around it; see `set` below. The store\n * validates its shape, because a document written by hand could be\n * anything. */\nexport interface FirestoreDoc {\n id: string;\n data: unknown;\n}\n\nexport interface FirestoreDocs {\n /** Every document under `collectionPath`, ordered by document id. */\n list: (collectionPath: string) => Promise<FirestoreDoc[]>;\n /** One document's fields, or null when it doesn't exist. */\n get: (collectionPath: string, docId: string) => Promise<unknown | null>;\n /** Create or replace. */\n set: (collectionPath: string, docId: string, data: Record<string, unknown>) => Promise<void>;\n /** Create only. Returns false when the id already exists — atomic, so two\n * concurrent creates can't both observe \"missing\". */\n create: (collectionPath: string, docId: string, data: Record<string, unknown>) => Promise<boolean>;\n /** Delete. Returns false when the id didn't exist, so a caller can tell a\n * real delete from a typo'd id. */\n delete: (collectionPath: string, docId: string) => Promise<boolean>;\n /** Listen to `collectionPath`. Every snapshot reports the ids that changed\n * in it, and whether it is the FIRST one.\n *\n * `initial` is not a nicety. `onSnapshot` delivers the current contents\n * immediately, as one snapshot in which every existing document reads as\n * `added` — so a listener that treats snapshots uniformly announces the\n * whole collection as changed the moment it arms, on every mount. The flag\n * is passed up rather than swallowed here so the decision (and its test)\n * lives with the store's policy, next to the rest of it.\n *\n * `onError` fires at most once per subscription: a Firestore listen error\n * TERMINATES the listener and never recovers on its own. Re-subscribing is\n * the caller's job (`firestore/listen.ts` holds the policy).\n *\n * Returns the detach function synchronously — `onSnapshot` is not async. */\n watch: (collectionPath: string, onChanged: (ids: string[], meta: { initial: boolean }) => void, onError: (error: unknown) => void) => () => void;\n /** Firestore's own instant, for a field the store must hand back as the type\n * it was stored as.\n *\n * Here rather than in the store because this is the seam that owns the SDK:\n * `firebase` is an OPTIONAL peer, and the store is reachable from\n * `collection/server` (the factory registry in `store.ts` names it), so a\n * top-level `import { Timestamp }` there makes the peer required for every\n * consumer of that entry — including hosts with no Firestore at all. Typed\n * `unknown` so no SDK type crosses back out. */\n timestamp: (seconds: number, nanoseconds: number) => unknown;\n}\n\n/** The real implementation over the modular SDK.\n *\n * THE RECORD IS THE DOCUMENT. Its fields are written at the top level, not\n * nested under a `data` key. This is not a matter of taste: the deployed\n * security rules read `request.resource.data.<field>` and\n * `resource.data[submit.emailField]` — a wrapper would put every field one\n * level down, so the required-field checks, the status state machine and the\n * \"your own row\" predicate would all read absent values and fail closed. A\n * shared record's shape is part of the authorization contract.\n *\n * `orderBy(\"__name__\")` orders by DOCUMENT ID. Ordering by a record field\n * would silently EXCLUDE documents missing that field (the trap\n * `remote-host/server/hostRunner.ts:158-169` documents), turning a read into a\n * partial one; the document id is always present and gives the stable order\n * the store contract requires. */\nexport function createFirestoreDocs(database: Firestore): FirestoreDocs {\n return {\n list: async (collectionPath) => {\n const snapshot = await getDocs(firestoreQuery(firestoreCollection(database, collectionPath), orderBy(\"__name__\")));\n return snapshot.docs.map((entry) => ({ id: entry.id, data: entry.data() }));\n },\n get: async (collectionPath, docId) => {\n const snapshot = await getDoc(doc(database, collectionPath, docId));\n return snapshot.exists() ? snapshot.data() : null;\n },\n set: async (collectionPath, docId, data) => {\n await setDoc(doc(database, collectionPath, docId), data);\n },\n create: (collectionPath, docId, data) =>\n runTransaction(database, async (transaction) => {\n const ref = doc(database, collectionPath, docId);\n const existing = await transaction.get(ref);\n if (existing.exists()) return false;\n transaction.set(ref, data);\n return true;\n }),\n // Firestore's deleteDoc succeeds on a missing document, so an existence\n // check is what makes \"deleted\" distinguishable from \"there was nothing\".\n // It runs INSIDE the transaction (like `create`): a plain get-then-delete\n // would report `true` for a document a concurrent client had already\n // removed, i.e. claim a delete this call never performed.\n delete: (collectionPath, docId) =>\n runTransaction(database, async (transaction) => {\n const ref = doc(database, collectionPath, docId);\n const existing = await transaction.get(ref);\n if (!existing.exists()) return false;\n transaction.delete(ref);\n return true;\n }),\n // `docChanges()` rather than the whole snapshot: it names the documents\n // that moved, which is what lets the store report per-record changes\n // instead of \"something in this collection changed\". Added, modified and\n // removed are all reported the same way — the store's listener takes an\n // id, and re-reads; what KIND of change it was is not something a\n // reconcile pass needs to be told.\n //\n // `includeMetadataChanges` is deliberately left off: local writes and\n // has-pending-writes flips would otherwise wake every listener for\n // changes this process just made.\n watch: (collectionPath, onChanged, onError) => {\n let seen = false;\n return onSnapshot(\n firestoreCollection(database, collectionPath),\n (snapshot) => {\n const initial = !seen;\n seen = true;\n onChanged(\n snapshot.docChanges().map((change) => change.doc.id),\n { initial },\n );\n },\n onError,\n );\n },\n timestamp: (seconds, nanoseconds) => new Timestamp(seconds, nanoseconds),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAiGA,SAAgB,oBAAoB,UAAoC;CACtE,OAAO;EACL,MAAM,OAAO,mBAAmB;GAE9B,QAAO,OAAA,GADgB,mBAAA,QAAA,EAAA,GAAQ,mBAAA,MAAA,EAAA,GAAe,mBAAA,WAAA,CAAoB,UAAU,cAAc,IAAA,GAAG,mBAAA,QAAA,CAAQ,UAAU,CAAC,CAAC,EAAA,CACjG,KAAK,KAAK,WAAW;IAAE,IAAI,MAAM;IAAI,MAAM,MAAM,KAAK;GAAE,EAAE;EAC5E;EACA,KAAK,OAAO,gBAAgB,UAAU;GACpC,MAAM,WAAW,OAAA,GAAM,mBAAA,OAAA,EAAA,GAAO,mBAAA,IAAA,CAAI,UAAU,gBAAgB,KAAK,CAAC;GAClE,OAAO,SAAS,OAAO,IAAI,SAAS,KAAK,IAAI;EAC/C;EACA,KAAK,OAAO,gBAAgB,OAAO,SAAS;GAC1C,OAAA,GAAM,mBAAA,OAAA,EAAA,GAAO,mBAAA,IAAA,CAAI,UAAU,gBAAgB,KAAK,GAAG,IAAI;EACzD;EACA,SAAS,gBAAgB,OAAO,UAAA,GAC9B,mBAAA,eAAA,CAAe,UAAU,OAAO,gBAAgB;GAC9C,MAAM,OAAA,GAAM,mBAAA,IAAA,CAAI,UAAU,gBAAgB,KAAK;GAE/C,KAAI,MADmB,YAAY,IAAI,GAAG,EAAA,CAC7B,OAAO,GAAG,OAAO;GAC9B,YAAY,IAAI,KAAK,IAAI;GACzB,OAAO;EACT,CAAC;EAMH,SAAS,gBAAgB,WAAA,GACvB,mBAAA,eAAA,CAAe,UAAU,OAAO,gBAAgB;GAC9C,MAAM,OAAA,GAAM,mBAAA,IAAA,CAAI,UAAU,gBAAgB,KAAK;GAE/C,IAAI,EAAC,MADkB,YAAY,IAAI,GAAG,EAAA,CAC5B,OAAO,GAAG,OAAO;GAC/B,YAAY,OAAO,GAAG;GACtB,OAAO;EACT,CAAC;EAWH,QAAQ,gBAAgB,WAAW,YAAY;GAC7C,IAAI,OAAO;GACX,QAAA,GAAO,mBAAA,WAAA,EAAA,GACL,mBAAA,WAAA,CAAoB,UAAU,cAAc,IAC3C,aAAa;IACZ,MAAM,UAAU,CAAC;IACjB,OAAO;IACP,UACE,SAAS,WAAW,CAAC,CAAC,KAAK,WAAW,OAAO,IAAI,EAAE,GACnD,EAAE,QAAQ,CACZ;GACF,GACA,OACF;EACF;EACA,YAAY,SAAS,gBAAgB,IAAI,mBAAA,UAAU,SAAS,WAAW;CACzE;AACF"}
@@ -1,4 +1,4 @@
1
- import { collection, doc, getDoc, getDocs, onSnapshot, orderBy, query, runTransaction, setDoc } from "firebase/firestore";
1
+ import { Timestamp, collection, doc, getDoc, getDocs, onSnapshot, orderBy, query, runTransaction, setDoc } from "firebase/firestore";
2
2
  //#region src/collection/server/firestoreDocs.ts
3
3
  /** The real implementation over the modular SDK.
4
4
  *
@@ -49,7 +49,8 @@ function createFirestoreDocs(database) {
49
49
  seen = true;
50
50
  onChanged(snapshot.docChanges().map((change) => change.doc.id), { initial });
51
51
  }, onError);
52
- }
52
+ },
53
+ timestamp: (seconds, nanoseconds) => new Timestamp(seconds, nanoseconds)
53
54
  };
54
55
  }
55
56
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"firestore.js","names":[],"sources":["../../src/collection/server/firestoreDocs.ts"],"sourcesContent":["// The seam between the firestore store and the Firestore SDK.\n//\n// The modular SDK is function-based (`getDocs(query(collection(db, …)))`), so a\n// store that imported those functions directly could not be tested without a\n// real backend — a fake `db` would still be handed to the real functions.\n// Narrowing to this interface makes the backend swappable: core ships\n// `createFirestoreDocs` over the real SDK, and the tests inject an in-memory\n// fake that satisfies the same shape. That is what lets this repository's tests\n// run with no API key and no network.\n//\n// Deliberately minimal and id-keyed: no query builder, no field ordering, no\n// cursors. Everything the store needs is \"the documents of one collection path,\n// ordered by document id\" — see the store header for why field ordering is\n// avoided entirely.\n//\n// The collection path is an ARGUMENT, not something this module composes. It\n// owns the SDK calls; `firestoreStore` owns where the documents live.\n\nimport {\n collection as firestoreCollection,\n doc,\n getDoc,\n getDocs,\n orderBy,\n query as firestoreQuery,\n onSnapshot,\n runTransaction,\n setDoc,\n type Firestore,\n} from \"firebase/firestore\";\n\n/** One stored record document. `data` is the document's own fields — the\n * record itself, not a wrapper around it; see `set` below. The store\n * validates its shape, because a document written by hand could be\n * anything. */\nexport interface FirestoreDoc {\n id: string;\n data: unknown;\n}\n\nexport interface FirestoreDocs {\n /** Every document under `collectionPath`, ordered by document id. */\n list: (collectionPath: string) => Promise<FirestoreDoc[]>;\n /** One document's fields, or null when it doesn't exist. */\n get: (collectionPath: string, docId: string) => Promise<unknown | null>;\n /** Create or replace. */\n set: (collectionPath: string, docId: string, data: Record<string, unknown>) => Promise<void>;\n /** Create only. Returns false when the id already exists — atomic, so two\n * concurrent creates can't both observe \"missing\". */\n create: (collectionPath: string, docId: string, data: Record<string, unknown>) => Promise<boolean>;\n /** Delete. Returns false when the id didn't exist, so a caller can tell a\n * real delete from a typo'd id. */\n delete: (collectionPath: string, docId: string) => Promise<boolean>;\n /** Listen to `collectionPath`. Every snapshot reports the ids that changed\n * in it, and whether it is the FIRST one.\n *\n * `initial` is not a nicety. `onSnapshot` delivers the current contents\n * immediately, as one snapshot in which every existing document reads as\n * `added` — so a listener that treats snapshots uniformly announces the\n * whole collection as changed the moment it arms, on every mount. The flag\n * is passed up rather than swallowed here so the decision (and its test)\n * lives with the store's policy, next to the rest of it.\n *\n * `onError` fires at most once per subscription: a Firestore listen error\n * TERMINATES the listener and never recovers on its own. Re-subscribing is\n * the caller's job (`firestore/listen.ts` holds the policy).\n *\n * Returns the detach function synchronously — `onSnapshot` is not async. */\n watch: (collectionPath: string, onChanged: (ids: string[], meta: { initial: boolean }) => void, onError: (error: unknown) => void) => () => void;\n}\n\n/** The real implementation over the modular SDK.\n *\n * THE RECORD IS THE DOCUMENT. Its fields are written at the top level, not\n * nested under a `data` key. This is not a matter of taste: the deployed\n * security rules read `request.resource.data.<field>` and\n * `resource.data[submit.emailField]` — a wrapper would put every field one\n * level down, so the required-field checks, the status state machine and the\n * \"your own row\" predicate would all read absent values and fail closed. A\n * shared record's shape is part of the authorization contract.\n *\n * `orderBy(\"__name__\")` orders by DOCUMENT ID. Ordering by a record field\n * would silently EXCLUDE documents missing that field (the trap\n * `remote-host/server/hostRunner.ts:158-169` documents), turning a read into a\n * partial one; the document id is always present and gives the stable order\n * the store contract requires. */\nexport function createFirestoreDocs(database: Firestore): FirestoreDocs {\n return {\n list: async (collectionPath) => {\n const snapshot = await getDocs(firestoreQuery(firestoreCollection(database, collectionPath), orderBy(\"__name__\")));\n return snapshot.docs.map((entry) => ({ id: entry.id, data: entry.data() }));\n },\n get: async (collectionPath, docId) => {\n const snapshot = await getDoc(doc(database, collectionPath, docId));\n return snapshot.exists() ? snapshot.data() : null;\n },\n set: async (collectionPath, docId, data) => {\n await setDoc(doc(database, collectionPath, docId), data);\n },\n create: (collectionPath, docId, data) =>\n runTransaction(database, async (transaction) => {\n const ref = doc(database, collectionPath, docId);\n const existing = await transaction.get(ref);\n if (existing.exists()) return false;\n transaction.set(ref, data);\n return true;\n }),\n // Firestore's deleteDoc succeeds on a missing document, so an existence\n // check is what makes \"deleted\" distinguishable from \"there was nothing\".\n // It runs INSIDE the transaction (like `create`): a plain get-then-delete\n // would report `true` for a document a concurrent client had already\n // removed, i.e. claim a delete this call never performed.\n delete: (collectionPath, docId) =>\n runTransaction(database, async (transaction) => {\n const ref = doc(database, collectionPath, docId);\n const existing = await transaction.get(ref);\n if (!existing.exists()) return false;\n transaction.delete(ref);\n return true;\n }),\n // `docChanges()` rather than the whole snapshot: it names the documents\n // that moved, which is what lets the store report per-record changes\n // instead of \"something in this collection changed\". Added, modified and\n // removed are all reported the same way — the store's listener takes an\n // id, and re-reads; what KIND of change it was is not something a\n // reconcile pass needs to be told.\n //\n // `includeMetadataChanges` is deliberately left off: local writes and\n // has-pending-writes flips would otherwise wake every listener for\n // changes this process just made.\n watch: (collectionPath, onChanged, onError) => {\n let seen = false;\n return onSnapshot(\n firestoreCollection(database, collectionPath),\n (snapshot) => {\n const initial = !seen;\n seen = true;\n onChanged(\n snapshot.docChanges().map((change) => change.doc.id),\n { initial },\n );\n },\n onError,\n );\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAsFA,SAAgB,oBAAoB,UAAoC;CACtE,OAAO;EACL,MAAM,OAAO,mBAAmB;GAE9B,QAAO,MADgB,QAAQ,MAAe,WAAoB,UAAU,cAAc,GAAG,QAAQ,UAAU,CAAC,CAAC,EAAA,CACjG,KAAK,KAAK,WAAW;IAAE,IAAI,MAAM;IAAI,MAAM,MAAM,KAAK;GAAE,EAAE;EAC5E;EACA,KAAK,OAAO,gBAAgB,UAAU;GACpC,MAAM,WAAW,MAAM,OAAO,IAAI,UAAU,gBAAgB,KAAK,CAAC;GAClE,OAAO,SAAS,OAAO,IAAI,SAAS,KAAK,IAAI;EAC/C;EACA,KAAK,OAAO,gBAAgB,OAAO,SAAS;GAC1C,MAAM,OAAO,IAAI,UAAU,gBAAgB,KAAK,GAAG,IAAI;EACzD;EACA,SAAS,gBAAgB,OAAO,SAC9B,eAAe,UAAU,OAAO,gBAAgB;GAC9C,MAAM,MAAM,IAAI,UAAU,gBAAgB,KAAK;GAE/C,KAAI,MADmB,YAAY,IAAI,GAAG,EAAA,CAC7B,OAAO,GAAG,OAAO;GAC9B,YAAY,IAAI,KAAK,IAAI;GACzB,OAAO;EACT,CAAC;EAMH,SAAS,gBAAgB,UACvB,eAAe,UAAU,OAAO,gBAAgB;GAC9C,MAAM,MAAM,IAAI,UAAU,gBAAgB,KAAK;GAE/C,IAAI,EAAC,MADkB,YAAY,IAAI,GAAG,EAAA,CAC5B,OAAO,GAAG,OAAO;GAC/B,YAAY,OAAO,GAAG;GACtB,OAAO;EACT,CAAC;EAWH,QAAQ,gBAAgB,WAAW,YAAY;GAC7C,IAAI,OAAO;GACX,OAAO,WACL,WAAoB,UAAU,cAAc,IAC3C,aAAa;IACZ,MAAM,UAAU,CAAC;IACjB,OAAO;IACP,UACE,SAAS,WAAW,CAAC,CAAC,KAAK,WAAW,OAAO,IAAI,EAAE,GACnD,EAAE,QAAQ,CACZ;GACF,GACA,OACF;EACF;CACF;AACF"}
1
+ {"version":3,"file":"firestore.js","names":[],"sources":["../../src/collection/server/firestoreDocs.ts"],"sourcesContent":["// The seam between the firestore store and the Firestore SDK.\n//\n// The modular SDK is function-based (`getDocs(query(collection(db, …)))`), so a\n// store that imported those functions directly could not be tested without a\n// real backend — a fake `db` would still be handed to the real functions.\n// Narrowing to this interface makes the backend swappable: core ships\n// `createFirestoreDocs` over the real SDK, and the tests inject an in-memory\n// fake that satisfies the same shape. That is what lets this repository's tests\n// run with no API key and no network.\n//\n// Deliberately minimal and id-keyed: no query builder, no field ordering, no\n// cursors. Everything the store needs is \"the documents of one collection path,\n// ordered by document id\" — see the store header for why field ordering is\n// avoided entirely.\n//\n// The collection path is an ARGUMENT, not something this module composes. It\n// owns the SDK calls; `firestoreStore` owns where the documents live.\n\nimport {\n collection as firestoreCollection,\n doc,\n getDoc,\n getDocs,\n orderBy,\n query as firestoreQuery,\n onSnapshot,\n runTransaction,\n setDoc,\n Timestamp,\n type Firestore,\n} from \"firebase/firestore\";\n\n/** One stored record document. `data` is the document's own fields — the\n * record itself, not a wrapper around it; see `set` below. The store\n * validates its shape, because a document written by hand could be\n * anything. */\nexport interface FirestoreDoc {\n id: string;\n data: unknown;\n}\n\nexport interface FirestoreDocs {\n /** Every document under `collectionPath`, ordered by document id. */\n list: (collectionPath: string) => Promise<FirestoreDoc[]>;\n /** One document's fields, or null when it doesn't exist. */\n get: (collectionPath: string, docId: string) => Promise<unknown | null>;\n /** Create or replace. */\n set: (collectionPath: string, docId: string, data: Record<string, unknown>) => Promise<void>;\n /** Create only. Returns false when the id already exists — atomic, so two\n * concurrent creates can't both observe \"missing\". */\n create: (collectionPath: string, docId: string, data: Record<string, unknown>) => Promise<boolean>;\n /** Delete. Returns false when the id didn't exist, so a caller can tell a\n * real delete from a typo'd id. */\n delete: (collectionPath: string, docId: string) => Promise<boolean>;\n /** Listen to `collectionPath`. Every snapshot reports the ids that changed\n * in it, and whether it is the FIRST one.\n *\n * `initial` is not a nicety. `onSnapshot` delivers the current contents\n * immediately, as one snapshot in which every existing document reads as\n * `added` — so a listener that treats snapshots uniformly announces the\n * whole collection as changed the moment it arms, on every mount. The flag\n * is passed up rather than swallowed here so the decision (and its test)\n * lives with the store's policy, next to the rest of it.\n *\n * `onError` fires at most once per subscription: a Firestore listen error\n * TERMINATES the listener and never recovers on its own. Re-subscribing is\n * the caller's job (`firestore/listen.ts` holds the policy).\n *\n * Returns the detach function synchronously — `onSnapshot` is not async. */\n watch: (collectionPath: string, onChanged: (ids: string[], meta: { initial: boolean }) => void, onError: (error: unknown) => void) => () => void;\n /** Firestore's own instant, for a field the store must hand back as the type\n * it was stored as.\n *\n * Here rather than in the store because this is the seam that owns the SDK:\n * `firebase` is an OPTIONAL peer, and the store is reachable from\n * `collection/server` (the factory registry in `store.ts` names it), so a\n * top-level `import { Timestamp }` there makes the peer required for every\n * consumer of that entry — including hosts with no Firestore at all. Typed\n * `unknown` so no SDK type crosses back out. */\n timestamp: (seconds: number, nanoseconds: number) => unknown;\n}\n\n/** The real implementation over the modular SDK.\n *\n * THE RECORD IS THE DOCUMENT. Its fields are written at the top level, not\n * nested under a `data` key. This is not a matter of taste: the deployed\n * security rules read `request.resource.data.<field>` and\n * `resource.data[submit.emailField]` — a wrapper would put every field one\n * level down, so the required-field checks, the status state machine and the\n * \"your own row\" predicate would all read absent values and fail closed. A\n * shared record's shape is part of the authorization contract.\n *\n * `orderBy(\"__name__\")` orders by DOCUMENT ID. Ordering by a record field\n * would silently EXCLUDE documents missing that field (the trap\n * `remote-host/server/hostRunner.ts:158-169` documents), turning a read into a\n * partial one; the document id is always present and gives the stable order\n * the store contract requires. */\nexport function createFirestoreDocs(database: Firestore): FirestoreDocs {\n return {\n list: async (collectionPath) => {\n const snapshot = await getDocs(firestoreQuery(firestoreCollection(database, collectionPath), orderBy(\"__name__\")));\n return snapshot.docs.map((entry) => ({ id: entry.id, data: entry.data() }));\n },\n get: async (collectionPath, docId) => {\n const snapshot = await getDoc(doc(database, collectionPath, docId));\n return snapshot.exists() ? snapshot.data() : null;\n },\n set: async (collectionPath, docId, data) => {\n await setDoc(doc(database, collectionPath, docId), data);\n },\n create: (collectionPath, docId, data) =>\n runTransaction(database, async (transaction) => {\n const ref = doc(database, collectionPath, docId);\n const existing = await transaction.get(ref);\n if (existing.exists()) return false;\n transaction.set(ref, data);\n return true;\n }),\n // Firestore's deleteDoc succeeds on a missing document, so an existence\n // check is what makes \"deleted\" distinguishable from \"there was nothing\".\n // It runs INSIDE the transaction (like `create`): a plain get-then-delete\n // would report `true` for a document a concurrent client had already\n // removed, i.e. claim a delete this call never performed.\n delete: (collectionPath, docId) =>\n runTransaction(database, async (transaction) => {\n const ref = doc(database, collectionPath, docId);\n const existing = await transaction.get(ref);\n if (!existing.exists()) return false;\n transaction.delete(ref);\n return true;\n }),\n // `docChanges()` rather than the whole snapshot: it names the documents\n // that moved, which is what lets the store report per-record changes\n // instead of \"something in this collection changed\". Added, modified and\n // removed are all reported the same way — the store's listener takes an\n // id, and re-reads; what KIND of change it was is not something a\n // reconcile pass needs to be told.\n //\n // `includeMetadataChanges` is deliberately left off: local writes and\n // has-pending-writes flips would otherwise wake every listener for\n // changes this process just made.\n watch: (collectionPath, onChanged, onError) => {\n let seen = false;\n return onSnapshot(\n firestoreCollection(database, collectionPath),\n (snapshot) => {\n const initial = !seen;\n seen = true;\n onChanged(\n snapshot.docChanges().map((change) => change.doc.id),\n { initial },\n );\n },\n onError,\n );\n },\n timestamp: (seconds, nanoseconds) => new Timestamp(seconds, nanoseconds),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AAiGA,SAAgB,oBAAoB,UAAoC;CACtE,OAAO;EACL,MAAM,OAAO,mBAAmB;GAE9B,QAAO,MADgB,QAAQ,MAAe,WAAoB,UAAU,cAAc,GAAG,QAAQ,UAAU,CAAC,CAAC,EAAA,CACjG,KAAK,KAAK,WAAW;IAAE,IAAI,MAAM;IAAI,MAAM,MAAM,KAAK;GAAE,EAAE;EAC5E;EACA,KAAK,OAAO,gBAAgB,UAAU;GACpC,MAAM,WAAW,MAAM,OAAO,IAAI,UAAU,gBAAgB,KAAK,CAAC;GAClE,OAAO,SAAS,OAAO,IAAI,SAAS,KAAK,IAAI;EAC/C;EACA,KAAK,OAAO,gBAAgB,OAAO,SAAS;GAC1C,MAAM,OAAO,IAAI,UAAU,gBAAgB,KAAK,GAAG,IAAI;EACzD;EACA,SAAS,gBAAgB,OAAO,SAC9B,eAAe,UAAU,OAAO,gBAAgB;GAC9C,MAAM,MAAM,IAAI,UAAU,gBAAgB,KAAK;GAE/C,KAAI,MADmB,YAAY,IAAI,GAAG,EAAA,CAC7B,OAAO,GAAG,OAAO;GAC9B,YAAY,IAAI,KAAK,IAAI;GACzB,OAAO;EACT,CAAC;EAMH,SAAS,gBAAgB,UACvB,eAAe,UAAU,OAAO,gBAAgB;GAC9C,MAAM,MAAM,IAAI,UAAU,gBAAgB,KAAK;GAE/C,IAAI,EAAC,MADkB,YAAY,IAAI,GAAG,EAAA,CAC5B,OAAO,GAAG,OAAO;GAC/B,YAAY,OAAO,GAAG;GACtB,OAAO;EACT,CAAC;EAWH,QAAQ,gBAAgB,WAAW,YAAY;GAC7C,IAAI,OAAO;GACX,OAAO,WACL,WAAoB,UAAU,cAAc,IAC3C,aAAa;IACZ,MAAM,UAAU,CAAC;IACjB,OAAO;IACP,UACE,SAAS,WAAW,CAAC,CAAC,KAAK,WAAW,OAAO,IAAI,EAAE,GACnD,EAAE,QAAQ,CACZ;GACF,GACA,OACF;EACF;EACA,YAAY,SAAS,gBAAgB,IAAI,UAAU,SAAS,WAAW;CACzE;AACF"}
@@ -368,9 +368,21 @@ function firstMissingRequiredField(draft, schema) {
368
368
  * code composes the prompt text; letting it also decide whether that text runs
369
369
  * would put both halves of the decision inside the sandbox.
370
370
  *
371
- * The phone runtime does not consult this: it always sends, because a phone has
372
- * no Enter key for the user to press (receptron/mulmoterminal#1253). The flag
373
- * governs the desktop custom view and the desktop phone-frame preview. */
371
+ * EVERY surface honours this declaration, the phone included. The phone once
372
+ * always sent, on the grounds that it has no Enter key to press — but that made
373
+ * the same view behave differently depending on where it was opened, and it
374
+ * silently overrode default-deny on a device the author never tested.
375
+ *
376
+ * The phone does not call THIS function: it reads `allowSendChat` out of the
377
+ * collection schema it already receives (`toDetail` sends the schema whole) and
378
+ * applies the same `=== true` rule in its own copy. Two reasons for that copy
379
+ * have been written down and both were wrong, so this states none — if you are
380
+ * touching it, check whether the phone can import this subpath and delete the
381
+ * copy if it can. Until then, keep the two in step.
382
+ *
383
+ * It must also ask about the view whose document is RENDERED, not the selected
384
+ * one — during a view switch those differ, and the selected view's flag would
385
+ * decide for the previous view's still-running sandbox. */
374
386
  function customViewSendsChat(view) {
375
387
  return view.allowSendChat === true;
376
388
  }