@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.
- package/README.md +17 -1
- package/assets/helps/error-recovery.md +78 -0
- package/assets/helps/google-calendar-collection.md +92 -12
- package/dist/collection/core/schemaZ.d.ts +15 -2
- package/dist/collection/core/viewChatPolicy.d.ts +15 -3
- package/dist/collection/firestore.cjs +2 -1
- package/dist/collection/firestore.cjs.map +1 -1
- package/dist/collection/firestore.js +3 -2
- package/dist/collection/firestore.js.map +1 -1
- package/dist/collection/index.cjs +15 -3
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +15 -3
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/server/index.cjs +2 -2
- package/dist/collection/registry/server/index.js +2 -2
- package/dist/collection/server/firestoreDocs.d.ts +10 -0
- package/dist/collection/server/index.cjs +2 -2
- package/dist/collection/server/index.js +2 -2
- package/dist/collection-watchers/index.cjs +2 -2
- package/dist/collection-watchers/index.js +2 -2
- package/dist/{discovery-DCMW05Bp.cjs → discovery-B79IxEM6.cjs} +15 -5
- package/dist/discovery-B79IxEM6.cjs.map +1 -0
- package/dist/{discovery-CBjI1lx9.js → discovery-DIGau7M8.js} +15 -5
- package/dist/discovery-DIGau7M8.js.map +1 -0
- package/dist/feeds/server/index.cjs +2 -2
- package/dist/feeds/server/index.js +2 -2
- package/dist/google/calendar.d.ts +24 -0
- package/dist/google/collectionPush.d.ts +45 -2
- package/dist/google/deletePlan.d.ts +32 -0
- package/dist/google/eventDerived.d.ts +19 -0
- package/dist/google/index.cjs +179 -18
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +4 -2
- package/dist/google/index.js +174 -19
- package/dist/google/index.js.map +1 -1
- package/dist/remote-view/index.cjs +9 -7
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.d.ts +3 -3
- package/dist/remote-view/index.js +9 -7
- package/dist/remote-view/index.js.map +1 -1
- package/dist/{server-rA9FSkl2.cjs → server-BUbU9G-J.cjs} +2 -2
- package/dist/{server-rA9FSkl2.cjs.map → server-BUbU9G-J.cjs.map} +1 -1
- package/dist/{server-BMZ_BcPH.js → server-DZ7Cq7s_.js} +2 -2
- package/dist/{server-BMZ_BcPH.js.map → server-DZ7Cq7s_.js.map} +1 -1
- package/package.json +1 -1
- package/dist/discovery-CBjI1lx9.js.map +0 -1
- 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
|
|
85
|
+
`recurringEventId`, `originalStartTime`, `selfResponseStatus` and
|
|
86
|
+
`conferenceVideoUri`.
|
|
82
87
|
|
|
83
|
-
|
|
84
|
-
expand recurrences, so a weekly meeting arrives
|
|
85
|
-
`recurringEventId` names the series each occurrence
|
|
86
|
-
one-off), and `originalStartTime` is the slot the
|
|
87
|
-
dragged it — so a moved occurrence reads as a move
|
|
88
|
-
plus a new event. Map them when the user asks why one
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
11
|
-
* no Enter key
|
|
12
|
-
*
|
|
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":";;;;;;;;;;;;;;;;;;
|
|
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":";;;;;;;;;;;;;;;;;
|
|
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
|
-
*
|
|
372
|
-
* no Enter key
|
|
373
|
-
*
|
|
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
|
}
|