Skip to content

feat(metadata): Psych-DS-compliant data layout on OSF + Firestore-only metadata transaction - #152

Merged
htsukamoto5 merged 13 commits into
testfrom
feat/metadata-sidecars
Jul 6, 2026
Merged

feat(metadata): Psych-DS-compliant data layout on OSF + Firestore-only metadata transaction#152
htsukamoto5 merged 13 commits into
testfrom
feat/metadata-sidecars

Conversation

@htsukamoto5

@htsukamoto5 htsukamoto5 commented Jul 2, 2026

Copy link
Copy Markdown
Member

⚠️ Stacked on #151

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 is test so CI runs; merge #151 first, then this.

Part 1: Firestore-only metadata transaction (refactor)

blockMetadata's entire body — including every OSF network call — ran inside db.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:

  • The Firestore-only branch checked putFileOSF's result with errorCode !== 210, which throws even on success (success returns errorCode: null), so that branch could never complete.
  • The OSF-only branch uploaded the unmerged incoming metadata to OSF while Firestore got the merged version; both now receive the merged one.
  • The create branch ignored putFileOSF's result entirely (silent metadata loss); it is now handled (see resilience model below).

blockMetadata also now receives the token api-data already resolved via resolveToken, 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 submission abc123.json with a survey response object and a mouse_tracking_data array produces:

<OSF component root>/
  dataset_description.json
  .psychds-ignore
  data/
    subject-abc123_data.csv                              ← main data table
    subject-abc123_measure-response_data.csv             ← object-column sidecar
    subject-abc123_measure-mouseTrackingData_data.csv    ← array-column sidecar
    raw/
      abc123.json                                        ← original, byte-for-byte

With the toggle off, nothing changes: the raw file goes to the component root as today.

How it works

  • The raw file is the critical upload. The participant's submission lands byte-verbatim at 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 existing uploadQueue (whose retry worker treats 409 as done) and logged, but never fails the submission and can never precede its source data.
  • Conversion is the library's, not ours. The main CSV and sidecars come from one call to 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.
  • CSV submissions stay byte-verbatim. The original CSV text is passed through as mainContent, so the main data file keeps its exact bytes (column order, quoting) instead of being re-serialised.
  • JSON parsing is at parity with the CLI. produceMetadata now routes JSON through the library's parseJsonData, 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.
  • putFileOSF walks arbitrary-depth paths. WaterButler has no atomic deep-path create, so each folder level of data/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 from node-fetch to Node 22's global fetch.
  • Researcher subfolders are flattened (condition-A/abc.jsondata/raw/abc.json), matching how the CLI converts whole directories into a flat data/. Grouping by subfolder is lost under the Psych-DS layout; with metadata off, subfolders behave as before.
  • Modules: metadata-sidecars.ts is deleted in favor of metadata-derived-files.ts (pure builder: full derived set + rawDataPath) and metadata-derived-upload.ts (best-effort upload/queue).

For reviewers

  • Filename bases come from the library's deriveFallbackBase (subject-<stem>, an official Psych-DS keyword); sidecar names from its deriveArrayFilename/disambiguation. Cheap to change before merge if you want a different convention.
  • Deliberately kept pre-existing behavior: if the metadata block itself hard-fails (metadata generation throws, or an OSF read fails), the submission still returns 400 and the raw file never uploads. Making that non-fatal would change the participant-facing API contract — flagged as a product decision, not changed here.

Testing

  • 40 unit tests across 5 suites pass locally (tsc clean): 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 extended metadata-production (wrapped-trials parity, mainContent) and existing metadata-process/metadata-update suites.
  • CI build (22.x) green, covering the emulator suites (each metadata-state branch's response message is preserved by the refactor).
  • Live-verified against a real OSF project (throwaway project, token since revoked), driving the actual compiled modules: nested-folder create and find via WaterButler move links, 409 on duplicate folder create (the race branch), 409 on duplicate file, Buffer bodies under global fetch, the full layout above landing exactly as designed, and the raw file round-tripping byte-identical.

🤖 Generated with Claude Code

jodeleeuw and others added 13 commits March 28, 2026 08:36
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
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>
@htsukamoto5 htsukamoto5 changed the title feat(metadata): sidecar CSVs for nested data + Firestore-only metadata transaction feat(metadata): Psych-DS-compliant data layout on OSF + Firestore-only metadata transaction Jul 6, 2026
@htsukamoto5
htsukamoto5 merged commit 04a4fea into test Jul 6, 2026
1 check passed
htsukamoto5 added a commit that referenced this pull request Jul 7, 2026
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.

2 participants