Skip to content

fix(kits): restore the extensions' event payload shapes - #3098

Open
CorieW wants to merge 4 commits into
kitsfrom
fix/kits-event-payload-shape
Open

fix(kits): restore the extensions' event payload shapes#3098
CorieW wants to merge 4 commits into
kitsfrom
fix/kits-event-payload-shape

Conversation

@CorieW

@CorieW CorieW commented Sep 2, 2026

Copy link
Copy Markdown
Member

Closes #3027.
Closes #3125.

Investigation

The decision in #3027 was whether the payload rename is intentional 2nd gen design. It is not, and nothing about 2nd gen forces it:

  • A custom event's payload is whatever the kit hands channel.publishfirebase-admin just JSON.stringifys it. There is no 2nd gen wire format for custom events, so {data, params} is only the 2nd gen handler's local variable names leaking into the payload.
  • FirestoreEvent carries every 1st gen EventContext field under a different name (id, time, project/database/document, params), so the 1st gen context is fully reconstructible.
  • 1st gen change and 2nd gen event.data are the same firebase-admin Change<DocumentSnapshot> and serialize identically, so keeping the change key costs nothing.
  • The kits were not just renaming: context.eventId, context.timestamp, context.eventType and context.resource were dropped from the payload entirely, so this was a regression on top of a consumer break.

For speech-to-text, the extension published the caught Error itself. message and stack are not enumerable, so subscribers received {"error":{}}; the kit published {message, stack} instead. That is an upgrade, not parity, and it also dropped the name that errorFromAny puts on a thrown non-error. Restored to parity, with the improvement left to be tracked separately.

Changes

  • Added src/event-context.ts to firestore-counter and firestore-translate-text, with toEventContext rebuilding the 1st gen EventContext (eventId, timestamp, eventType, resource.service/resource.name, params) from a FirestoreEvent. eventType is the 1st gen constant google.firestore.document.write that every 1st gen Firestore onWrite trigger reported.
  • firestore-counter handleShardWrite publishes onStart as {change, context} and onCompletion as {context} again, instead of {data, params} and {params}.
  • firestore-translate-text handleDocumentWrite does the same, across all three onCompletion call sites.
  • speech-to-text recordErrorEvent publishes { error } again instead of { error: { message, stack } }.
  • Dropped the "Event payloads have a different shape" note from the firestore-counter and firestore-translate-text READMEs and the "fail events for unexpected errors now say what went wrong" note from the speech-to-text README, and recorded the payloads under each kit's "Unchanged" list instead.
  • Tests: added event-context.test.ts to both kits; pinned the rebuilt payloads in the handlers and events suites, including a JSON round-trip assertion on what a subscriber reads off the wire; added a speech-to-text case for the thrown-non-error payload keeping its name, and reworked the raw-Error case to assert the {"error":{}} the extension produced.

Testing

Unit: 81 tests in firestore-counter, 94 in firestore-translate-text, 34 in speech-to-text, all passing. tsc -b clean for all three, and prettier@2.8.8 --list-different clean over every file touched. The new wire-level assertion was checked as non-vacuous by breaking eventType and confirming it fails.

Deployed: all three kits were npm packed into project/function-kits/*/source/vendor and the installed bundles confirmed to contain the new code. The four overlapping kit-firestore-translate-text-*-fstranslate functions in corie-testing were deleted and confirmed gone from functions:list before deploying firestore-counter-a91f6c2e, firestore-translate-text-a91f6c2e and speech-to-text-a91f6c2e.

Payload, from real platform events. Eventarc publishing 403s in this project, so a subscriber cannot see the payload (see below); instead a temporary codebase held Firestore triggers on the same document patterns, running the kit's own compiled toEventContext on the real events. firestore-counter, on a shard write:

{"change":{"before":{...},"after":{"_fieldsProto":{"counter":{"integerValue":"1"}},...}},
 "context":{"eventId":"84b2826b-9cbb-488c-8db3-098edb1b02b0",
            "timestamp":"2026-09-02T14:53:29.897039Z",
            "eventType":"google.firestore.document.write",
            "resource":{"service":"firestore.googleapis.com",
                        "name":"projects/corie-testing/databases/(default)/documents/fxkits_range_counters/page-1788360801506/_counter_shards_/0000"},
            "params":{"collection":"fxkits_range_counters","counter":"page-1788360801506","shardId":"0000"}}}

firestore-translate-text, on a document write: same five context fields, resource.name projects/corie-testing/databases/(default)/documents/fxkits_range_tr_gemini/smoke-1788360897518, params {"messageId":"smoke-1788360897518"}.

Indirectly affected behaviour, with the channel unset as it was before: the translate handler still translates end to end — {"input":"hello world","translated":{"en":"hello world","es":"hola mundo"}} — and the counter still aggregates, the shard write landing as {"counter":1} on the counter document.

speech-to-text: its changed path is the catch-all handler, provoked by finalizing an audio/wav object and deleting it so the download throws inside the try. ApiError: No such object: … was logged from logs.error(error) and the invocation completed, so the error path is intact. The payload itself is covered only by the unit round-trip, for the reason below.

Afterwards the temporary codebase and its functions were deleted, the three other firestore-translate-text instances redeployed, and the smoke documents removed.

Out of scope

  • Eventarc publishing fails with a 403 in corie-testing for every kit that publishes events: the kits declare roles/eventarc.eventReceiver but no publisher role, and the extensions got publish rights on the channel from the Extensions install flow instead of extension.yaml. It is pre-existing and unrelated to the payload shape — the handlers reach recordStartEvent and fail there — but it is worth its own issue, since with EVENTARC_CHANNEL set the awaited publish also takes the handler down with it.
  • firestore-bigquery-export keeps the extension's context key on onStart, but fills it with the 2nd gen event (context.id, context.time) rather than a 1st gen EventContext. Same class of break, different payload; not currently tracked in Kits parity issues #2974.
  • firestore-vector-search publishes {params}, but the extension published nothing at all from that path (it declared the event types and never used them), so there is no extension payload to be at parity with.
  • The change snapshots serialize with firebase-admin internals in both the extension and the kit, but the kit is on firebase-admin@14 where the extension was on @12, so the internal field set differs slightly. That is an SDK-upgrade artifact this PR does not change — before it, the same snapshots were serialized under data.

The 2nd gen migration renamed what the Eventarc payloads carry.
`firestore-counter` and `firestore-translate-text` published
`{change, context}` on `onStart` and `{context}` on `onCompletion`; the kits
published `{data, params}` and `{params}`, following the 2nd gen handler
signature. That drops `context.eventId`, `context.timestamp`,
`context.eventType` and `context.resource` outright and moves the trigger
wildcards, so every subscriber reading them breaks.

Nothing about 2nd gen forces this. A custom event's payload is whatever the
kit hands `channel.publish`, and `FirestoreEvent` carries all five 1st gen
`EventContext` fields under different names, so `toEventContext` rebuilds the
object and the handlers publish the original shape. `event.data` and the 1st
gen `change` are the same `Change<DocumentSnapshot>` and serialize
identically, so the `change` key costs nothing.

`speech-to-text` published the caught `Error` itself, which serializes to
`{"error":{}}` because `message` and `stack` are not enumerable; the kit
published `{message, stack}` instead. That is an upgrade rather than parity,
and it dropped the `name` that `errorFromAny` puts on a thrown non-error, so
the error is published as-is again.

Refs #3027

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the firestore-counter, firestore-translate-text, and speech-to-text kits to maintain backward compatibility with 1st generation event payloads. For the Firestore kits, a new toEventContext helper is introduced to rebuild the 1st gen EventContext from 2nd gen events, ensuring onStart and onCompletion events retain their original shapes. For the speech-to-text kit, the error event payload is reverted to publish the raw error directly, preserving parity with the extension's behavior where genuine Error objects serialize to empty objects. I have no feedback to provide as there are no review comments.

@CorieW

CorieW commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Review of this PR's own changes — finding 1 of 2, firestore-counter test fixtures misrepresent the trigger.

The event fixtures I added in tests/handlers.test.ts and tests/event-context.test.ts use document: "_firebase_ext_/sharded_counter" with params: { shardId: "0000" }. That is the internal-state path, which is what the worker trigger watches. handleShardWrite serves onWrite, whose document pattern is {collection}/{counter=**}/_counter_shards_/{shardId} (src/index.ts:89, matching the extension's extension.yaml:72), so a real event carries a shard-document path and params: { collection, counter, shardId }.

The assertions pass either way, so the reconstruction is not wrong — but the fixtures pin a resource.name and a params map that no subscriber ever receives, which is exactly the detail the fix is about. Replacing them with pages/home/_counter_shards_/0000 and the three real wildcards.

@CorieW

CorieW commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Review of this PR's own changes — finding 2 of 2, no test asserts the serialized firestore-counter / firestore-translate-text payloads.

The whole point of the fix is what a subscriber reads off the wire, and firebase-admin puts the payload on the wire as JSON.stringify(data). The speech-to-text cases I added assert that serialized form, because the empty {"error":{}} only shows up after a round-trip. The counter and translate cases do not: handlers.test.ts mocks ../src/events entirely, and events.test.ts asserts the object handed to channel.publish before it is ever stringified.

That leaves the parity claim in the READMEs — context.eventId, context.timestamp, context.eventType, context.resource, context.params all reaching subscribers — unpinned at the level it is made. Adding a JSON round-trip assertion to both kits' events.test.ts.

Two problems in the parity tests. The `firestore-counter` fixtures used the
internal-state path with only a `shardId`, but `handleShardWrite` serves the
`{collection}/{counter=**}/_counter_shards_/{shardId}` trigger, so they pinned
a `resource.name` and `params` map no subscriber receives.

And nothing asserted the serialized payload for either Firestore kit, even
though `firebase-admin` puts it on the wire as `JSON.stringify(data)` and that
round-trip is the whole point of the fix. Both `events.test.ts` suites now
build the context with `toEventContext` and assert what a subscriber reads.

Refs #3027
@CorieW

CorieW commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Review of this PR's own changes — finding 3, an unrelated reformat slipped into firestore-counter/tests/handlers.test.ts.

The vi.mock("../src/controller") block picked up a hunk that has nothing to do with this PR:

-  const actual = await vi.importActual<typeof import("../src/controller")>(
-    "../src/controller"
-  );
+  const actual =
+    await vi.importActual<typeof import("../src/controller")>(
+      "../src/controller"
+    );

That is prettier 3 output. The repo pins prettier at 2.8.8, and the pre-commit hook shells out to a bare prettier, which in a worktree with no root node_modules resolved to a global 3.7.4 instead. Reverting the hunk; prettier@2.8.8 --list-different over every file this PR touches now reports only that one file, and nothing after the fix.

The pre-commit hook shells out to a bare `prettier`, which resolved to a
global 3.7.4 in a worktree with no root `node_modules` and reflowed the
`vi.importActual` call in the counter handler tests. The repo pins 2.8.8.

Refs #3027
@cabljac cabljac mentioned this pull request Sep 2, 2026
62 tasks

@cabljac cabljac left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The investigation in the description is the right shape - checking what v1 actually emits rather than trusting the yaml, and pinning it with a deploy, is what makes this reviewable. eventType, resource.name and the time/id mapping all check out against firebase-functions@4.9.0.

Two things I'd want resolved before merge: the publisher role (none of these three kits can publish at all today, and the README now tells users these payloads arrive), and the params claim, which I think is wrong in a way the README repeats.

The rest are smaller. Detail inline.

Note on provenance: this was an AI-led review, run against the branch and the original extension sources, and I've read the findings rather than re-derived every one of them myself. The file:line references and the SDK behaviour claims are worth checking against the source before you act on them, particularly the params one. Push back where it's wrong.

Comment thread kits/firestore-counter/README.md
service: FIRESTORE_SERVICE,
name: `projects/${event.project}/databases/${event.database}/documents/${event.document}`,
},
params: event.params,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the extension published any params here, so "still ... under context.params" in the README isn't parity, it's a superset.

In v1, context.params isn't supplied by the platform. The SDK computes it from the code-side trigger path (_makeParams, firebase-functions/lib/v1/cloud-functions.js:150). The extensions registered document(process.env.INTERNAL_STATE_PATH) (firestore-counter/functions/src/index.ts:80) and document(process.env.COLLECTION_PATH) (firestore-translate-text/functions/src/index.ts:47). Neither has a {wildcard} segment, so WILDCARD_REGEX matches nothing and params comes out {}. The yaml wildcards registered the trigger, they never reached the SDK.

Worth verifying yourself before you change anything, since you already have the deploy set up. Harmless either way, a superset breaks nobody, but the README and the description both claim a parity I don't think holds.

The more general point: the deploy test ran the kit's own toEventContext over 2nd gen events, so it can only confirm the kit is self-consistent. Nothing in it observes a real 1st gen context. If you can get one out of the emulator against the old extension, commit it as a fixture and assert event-context.test.ts against it. Then the parity claim is pinned rather than argued.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One precision after reading _makeParams properly: the only way the extension's params was non-empty is if a user put their own {wildcard} segments inside COLLECTION_PATH itself (the validation regex allows braces, and the code-side path is that env var verbatim). Even then messageId never appears, because it only exists in the yaml resource. So the exact statement is "empty by default, and never the yaml wildcards". The rest of the comment stands.

Comment thread kits/firestore-counter/src/event-context.ts
Comment on lines +88 to +91
// Parity with the extension: `message` and `stack` are not enumerable, so
// subscribers receive `{"error":{}}` for a genuine `Error`.
const payload = publish.mock.calls[0][0];
expect(JSON.parse(JSON.stringify(payload)).data).toEqual({ error: {} });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The {"error":{}} framing holds for new Error(...), but I don't think it's what this path actually produces.

The catch-all at handlers.ts:199 wraps remoteFile.download() (handlers.ts:133), which throws ApiError from @google-cloud/storage. code, errors, response and message are all enumerable own properties on it, so a subscriber gets something like {"error":{"code":404,"errors":[...],"message":"..."}}. Your own deploy log shows ApiError: No such object, so that's the realistic case.

Parity with the extension is unaffected either way, it published the raw error too. But this test and the README at :217-221 both describe the rare case as the general one. I'd reword to something like "carries whatever enumerable fields the thrown error has; a plain Error gives {}", and add an ApiError-shaped case so the realistic payload is pinned too.

One thing I did check: the response object isn't a leak risk, teeny-request gives it a toJSON that returns headers only.

});
});

test("puts the whole 1st gen context on the wire", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few small ones, none blocking:

  • This test mocks firebase-admin/eventarc, so the real serializer (toCloudEventProtoFormat) never runs, and recordStartEvent is a pass-through. It ends up being the event-context.test.ts assertion again with a hand-rolled JSON.stringify. Not wrong, but the comment claims more than it proves. Either drop it, or publish something where JSON semantics actually differ from toEqual (an error-bearing payload would).
  • No failure-mode coverage. strict is off, so timestamp: undefined compiles fine and the key just vanishes from the wire. One test for a missing event.time would catch that.
  • SHARD_WRITE here (:21-28) and the fixture in event-context.test.ts:25-32 are both as any. Translate centralises this in makeEvent (tests/helpers.ts:102), still a cast but only in one place. Worth giving counter the same.
  • expectedEventContext (tests/helpers.ts:122) rebuilds the projects/.../databases/.../documents/... template at :131 from the same parts the source uses, so the same mistake in both places passes. A literal expected string is stronger.
  • firestore-translate-text/README.md:216 says "The four event payloads:" and then describes two.

The Extensions platform granted the extension SA publish rights on the
instance's Eventarc channel implicitly from the `events:` block in
extension.yaml. Kits get no implicit grant, so every channel.publish()
call failed with PERMISSION_DENIED and no custom event was delivered.

## Changes
- Declare `roles/eventarc.publisher` in the eight kits that publish
  custom events (delete-user-data, firestore-bigquery-export,
  firestore-counter, firestore-send-email, firestore-translate-text,
  firestore-vector-search, speech-to-text, storage-resize-images).
- Document the role in each kit's README role table.
- Extend the firestore-translate-text role-declaration test and add a
  regression test pinning the publisher role.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

kits: declare roles/eventarc.publisher so event publishing does not 403 decision(kits): event payload shape {change, context} vs {data, params}

2 participants