Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/code/providers/interactive-api-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,13 @@ describe('InteractiveApiProvider', () => {
expect(shouldSaveAsAttachment(contentAtThreshold)).toBe(true)
expect(shouldSaveAsAttachment(contentAboveThreshold)).toBe(true)

// CFM-18 / spec R6: the size check measures UTF-8 bytes, not UTF-16 code units.
// A run of multi-byte characters can stay below the threshold by String length
// yet exceed it - and Firestore's byte-based 1 MiB limit - once UTF-8 encoded.
const multiByteContent = "€".repeat(kDynamicAttachmentSizeThreshold / 2) // "€" is 1 code unit, 3 UTF-8 bytes
expect(JSON.stringify(multiByteContent).length).toBeLessThan(kDynamicAttachmentSizeThreshold)
expect(shouldSaveAsAttachment(multiByteContent)).toBe(true)

// with an explicit kAttachmentUrlParameter value attachments are always used
setQueryParams(`interactiveApi=${kAttachmentUrlParameter}`)
expect(shouldSaveAsAttachment(contentBelowThreshold)).toBe(true)
Expand Down
21 changes: 18 additions & 3 deletions src/code/providers/interactive-api-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ export const shouldSaveAsAttachment = (content: any) => {
return true
}

const aboveDynamicThreshold = JSON.stringify(content).length >= kDynamicAttachmentSizeThreshold
// Measure the UTF-8 byte length, not String.prototype.length: the latter counts
// UTF-16 code units and undercounts non-ASCII content, whereas Firestore's 1 MiB
// document limit — and kDynamicAttachmentSizeThreshold — are byte-based. (spec R6)
const serializedBytes = new TextEncoder().encode(JSON.stringify(content)).byteLength
const aboveDynamicThreshold = serializedBytes >= kDynamicAttachmentSizeThreshold
if (aboveDynamicThreshold) {
return true
}
Expand Down Expand Up @@ -61,8 +65,19 @@ interface InteractiveApiProviderParams {
// pass `interactiveApi=attachment` as url parameter to always save state as an attachment
export const kAttachmentUrlParameter = "attachment"

// can save it twice with room to spare in 1MB Firestore limit
export const kDynamicAttachmentSizeThreshold = 480 * 1024
// Interactive state at or above this size is offloaded to an S3 attachment;
// smaller state is saved directly into the Firestore answer document.
//
// CFM-18: this was 480 KiB, justified as "can save it twice with room to spare
// in the 1MB Firestore limit" (480 * 2 = 960 KiB). That math was wrong. Activity
// Player stores the interactive state twice in one answer doc (`answer` +
// `report_state`), but it embeds each copy as an escaped JSON *string* inside a
// `report` wrapper alongside `attachments` and other fields. Measured expansion
// is ~2.2 bytes of Firestore document per char of interactive state, so a state
// near 480 KiB produced a ~1.06 MB document that Firestore rejected.
// The 1 MiB (1,048,576 byte) limit / 2.2 ≈ 465 KiB is the hard ceiling;
// 400 KiB leaves margin for escaping-density variation and wrapper overhead.
export const kDynamicAttachmentSizeThreshold = 400 * 1024

// in solidarity with legacy DocumentStore implementation and S3 sharing implementation
export const kLegacyAttachmentFilename = "file.json"
Expand Down
4 changes: 4 additions & 0 deletions src/test/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ g.createReactClassFactory = (classDef: any) => g.createReactFactory(g.createReac
// providers use window.alert() (which isn't implemented in JSDom) to signal unimplemented methods
window.alert = jest.fn(msg => console.error(msg))

// TextEncoder is a standard browser global but isn't exposed by the jsdom test
// environment; polyfill it from Node's util module so code under test can use it.
g.TextEncoder = g.TextEncoder ?? require('util').TextEncoder

declare global {
function jestSpyConsole(method: ConsoleMethod, fn: JestSpyConsoleFn,
options?: IJestSpyConsoleOptions): Promise<jest.SpyInstance>
Expand Down
Loading