feat(metadata): Psych-DS-compliant data layout on OSF + Firestore-only metadata transaction - #152
Merged
Conversation
fix: improve error logging and handling for OSF uploads
fix: fall back to valid PAT when OAuth token refresh fails
Upload queue retry with automatic recovery and dashboard UI
Merge test into main
docs: add FAQ entry about the 32 MB request size limit
…tooling
DataPipe's vendored @jspsych/metadata was a June-2024 fork frozen at v0.0.1
that silently discarded nested object/array trial data. The fix lives on the
upstream main branch but is NOT in the published npm 0.0.3 (which has the same
data-loss bug and would silently drop all data given DataPipe's pre-parsed
input). Rather than depend on the stale npm release or live-track a moving
branch, vendor a PINNED upstream commit and rebuild from it, with tooling to
make future re-syncs a one-command, reviewable step.
- functions/scripts/sync-metadata.mjs (+ npm run sync:metadata): clone upstream
at a ref, build packages/metadata, copy the built dist + sanitized
package.json + LICENSE into functions/metadata/, and record provenance in
VENDORED_FROM.json. Strips the package's scripts (upstream's
prepare:"npm run build" would break `npm install` of the file: dep, since we
ship dist-only) while keeping the csv-parse runtime dep.
- .github/workflows/metadata-drift-check.yml: weekly non-blocking job that
opens/updates a tracking issue when upstream main moves past the pinned commit.
- functions/metadata/: now dist-only, pinned to upstream main 224d336. dist is
committed (deploys need no metadata build); .gitignore updated to un-ignore it.
- functions/package.json: dep stays file:metadata; add explicit typescript
devDep (the build had relied on it transitively via the removed fork).
- functions/src/metadata-production.ts: generate()'s 3rd arg is now a string
ext ('json'|'csv'), not the old boolean csv flag.
- metadata-production.test.js: fixture updated to real output (type -> @type,
numeric -> number); data-derived levels/min-max double as a silent-drop guard;
fixed a pre-existing aliasing bug in the options test.
Verified: metadata-production, metadata-update, metadata-process suites pass;
functions build (tsc) and npm install are clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
functions/metadata is now a pre-built vendored dist with no lockfile or build script, so the "npm ci && npm run build" step in that directory fails with EUSAGE. The dist is committed and installed as a file: dependency by the existing functions npm ci step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both suites used logs/testlog and each deletes it at test start; jest runs them in parallel workers, so the base64 suite's delete could wipe the data suite's saveData counter between write and read (doc exists via the base64 increment, saveData undefined). Rename the base64 suite's doc to base64-testlog, matching its other doc IDs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The entire metadata block (OSF list/download/upload calls included) ran inside db.runTransaction. Firestore retries the transaction callback on contention, which would re-run those OSF network calls — a latent duplicate-write risk. The transaction now only reads, merges, and writes the Firestore metadata doc; OSF I/O happens before (existence check) and after (mirror upload) the transaction. When the merge needs the OSF copy as its base (Firestore empty, OSF populated), the transaction aborts via a sentinel, the copy is downloaded outside it, and the transaction re-runs. Also fixes two pre-existing bugs in the process: - The Firestore-only branch checked putFileOSF's result with `errorCode !== 210`, which threw even on success (success returns errorCode null); now checks `!response.success`. - The OSF-only branch uploaded the unmerged incoming metadata to OSF while Firestore got the merged version; both now get the merged one. - The create branch's putFileOSF result was silently ignored; it is now checked like the other branches. blockMetadata now receives the OSF token api-data already resolved via resolveToken, instead of re-deriving it with duplicated logic that could trigger a second token refresh per submission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The re-vendored @jspsych/metadata expands nested object/array trial fields (survey responses, mouse-tracking samples, ...) into dotted sub-variables and exposes the per-row data behind them. Until now DataPipe only used the variable descriptions, so the described data was not actually retrievable in tabular form. This mirrors what the standalone metadata CLI writes per data file: one sidecar CSV per extracted array column (rows keyed by the join keys + element_index) and per object column (one row per trial), named with the library's own Psych-DS helpers (deriveFallbackBase/deriveArrayFilename), placed in the same subfolder as the data file. Because the CLI's sidecars are per source file, per-submission sidecars are the exact incremental equivalent — no cross-session accumulation or dedup state is needed. Flow: produceMetadata() now returns the extraction results alongside the metadata; blockMetadata() builds the sidecar payloads and returns them; api-data uploads them only after the participant's data file itself lands in OSF (no orphan sidecars on 409), best-effort — a sidecar failure is queued in the existing uploadQueue (the retry worker already handles arbitrary filenames and treats 409 as done) and logged, never failing the submission. When the main file is queued because OSF is down, the sidecars are queued alongside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…surface mainRows Replace the hand-rolled sidecar builder in metadata-sidecars.ts with a call to the library's shared buildPsychDSDataFiles (the same function the @jspsych/metadata CLI and browser flows use), filtering to the sidecar files (kind 'array'/'object') and keeping today's placement next to the data file. This deletes the duplicated deriveArrayFilename/disambiguate/objectsToCSV logic; sidecar output is byte-identical (verified: arrays lead with [...joinKeys, 'element_index'], objects with joinKeys). produceMetadata now also returns mainRows (the parsed JSON trial array, or parseCSV records for CSV) so a later change can emit the main Psych-DS data CSV via the same builder. generate() leaves nested columns intact in these rows; the main CSV serialises them as JSON-in-cell (lossless), with the dotted expansion in the sidecars. The main CSV (kind 'main') is discarded for now — it is wired up when api-data.ts adopts the data/ + data/raw/ layout. tsc clean; 25/25 metadata unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
putFileOSF previously handled only a single subfolder level (split on '/' and used components[0]/components[1]), so a nested path like "data/raw/abc.json" would break. Generalise it to split the filename into folder segments plus the file name and walk each level in turn. subfolder.ts's parsePath is replaced by resolveFolder(parentUrl, token, name): it lists the children of any level — the storage root or a parent folder's WaterButler link — and returns the named child folder's link, creating it if absent. The link chains, so callers walk nested paths one level at a time. Folder creation treats a 409 Conflict as success: concurrent submissions can race to create the same folder (now every metadata submission needs data/ and data/raw/), and the loser re-lists and returns the winner's folder. putFileOSF also switches from node-fetch to the Node 22 global fetch, matching subfolder.ts and making the whole path unit-testable via a global.fetch mock. New put-file-osf.test.js covers depth 0/1/2, folder creation, the 409 race, and upload failures (10 tests). tsc clean. NOTE: the WaterButler move-link recursion (listing/creating inside a subfolder via its move link) and the node-fetch->global fetch body handling still need live verification against a real OSF component + token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… CSVs under data/)
With metadata on, the participant's raw submission is now the critical
upload and lands byte-verbatim at data/raw/<original name>; session
counting and queue-on-failure key off it. The full derived set — main
data CSV (byte-verbatim for CSV submissions via mainContent), sidecar
CSVs for nested columns, and .psychds-ignore at the component root — is
built by the library's shared buildPsychDSDataFiles and uploaded
best-effort under data/ after the raw file lands. Researcher subfolders
are flattened, matching the CLI. Metadata off is unchanged.
Also:
- dataset_description.json's OSF mirror is now best-effort: create
failures queue for retry (409 = concurrent create, done), update
failures self-heal on the next submission — they no longer reject the
participant's data.
- produceMetadata routes JSON through the library's parseJsonData, so a
nonstandard { "trials": [...] } wrapper is unwrapped exactly as the
CLI does; bare arrays are untouched.
- metadata-sidecars.ts is deleted: metadata-derived-files.ts consumes
the full buildPsychDSDataFiles set directly, and the upload module is
renamed metadata-derived-upload.ts to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch includes the three commits of #151 (
fix/metadata-version-drift) — review only the last five commits here, or view the diff against the #151 branch. Base istestso CI runs; merge #151 first, then this.Part 1: Firestore-only metadata transaction (refactor)
blockMetadata's entire body — including every OSF network call — ran insidedb.runTransaction. Firestore retries the transaction callback on contention (e.g. two participants submitting concurrently), which would re-run OSF uploads: a latent duplicate-write risk. The transaction is now strictly read → merge → write on the Firestore metadata doc; OSF I/O happens before it (existence check) and after it (mirror upload). When the merge needs the OSF copy as its base (Firestore empty, OSF populated — first submission after a wipe), the transaction aborts via a sentinel, the copy is downloaded outside it, and the transaction re-runs.Fixes three pre-existing bugs found in the process:
putFileOSF's result witherrorCode !== 210, which throws even on success (success returnserrorCode: null), so that branch could never complete.putFileOSF's result entirely (silent metadata loss); it is now handled (see resilience model below).blockMetadataalso now receives the tokenapi-dataalready resolved viaresolveToken, instead of re-deriving it with ~25 lines of duplicated logic that could trigger a second token refresh per submission.Part 2: Psych-DS-compliant data layout on OSF
With the metadata toggle on, DataPipe now ships a genuinely Psych-DS-compliant dataset to OSF instead of a lone raw file next to
dataset_description.json. A JSON submissionabc123.jsonwith a surveyresponseobject and amouse_tracking_dataarray produces:With the toggle off, nothing changes: the raw file goes to the component root as today.
How it works
data/raw/<original name>and is what session counting and queue-on-failure key off. Everything else is derived from it and uploaded best-effort after it lands — a failure of any derived file is queued in the existinguploadQueue(whose retry worker treats 409 as done) and logged, but never fails the submission and can never precede its source data.buildPsychDSDataFiles— the same shared function the metadata CLI and browser flows use — so DataPipe's filenames and CSV bytes match the CLI's for identical data, with zero conversion code to keep in sync across re-vendors. Since the CLI writes these per source file, per-submission output is the exact incremental equivalent: no cross-session accumulation or dedup state.mainContent, so the main data file keeps its exact bytes (column order, quoting) instead of being re-serialised.produceMetadatanow routes JSON through the library'sparseJsonData, so a nonstandard{ "trials": [...] }wrapper is unwrapped exactly as the CLI does; bare arrays (the standard jsPsych payload) are untouched.dataset_description.json's OSF mirror is now non-fatal. Firestore is the source of truth and every submission re-merges and re-mirrors, so a create failure queues for retry (409 = a concurrent submission won the create race — fine), an update failure self-heals on the next submission, and neither rejects the participant's data. Previously a create failure 400'd the whole submission.putFileOSFwalks arbitrary-depth paths. WaterButler has no atomic deep-path create, so each folder level ofdata/raw/is found-or-created by chaining folder move links (subfolder.ts), with a 409 on folder creation treated as "another submission created it first — re-list and continue" (this race is now hit on every metadata submission). Also switched fromnode-fetchto Node 22's globalfetch.condition-A/abc.json→data/raw/abc.json), matching how the CLI converts whole directories into a flatdata/. Grouping by subfolder is lost under the Psych-DS layout; with metadata off, subfolders behave as before.metadata-sidecars.tsis deleted in favor ofmetadata-derived-files.ts(pure builder: full derived set +rawDataPath) andmetadata-derived-upload.ts(best-effort upload/queue).For reviewers
deriveFallbackBase(subject-<stem>, an official Psych-DS keyword); sidecar names from itsderiveArrayFilename/disambiguation. Cheap to change before merge if you want a different convention.Testing
tscclean):metadata-derived-files.test.js(layout, naming, flattening, byte-verbatim CSV, disambiguation),put-file-osf.test.js(10 tests: depth 0/1/2 walks, folder create, 409 race, upload failures), plus the extendedmetadata-production(wrapped-trials parity,mainContent) and existingmetadata-process/metadata-updatesuites.build (22.x)green, covering the emulator suites (each metadata-state branch's response message is preserved by the refactor).🤖 Generated with Claude Code