From 53fbb6660fef44a512d927b3a5c907672b5c824b Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Mon, 21 Sep 2026 18:30:20 -0400 Subject: [PATCH 1/5] Pin the script tags in the docs samples to exact versions An unpinned unpkg URL serves the newest release, so publishing one could change a study that is already collecting data. Every sample now takes its tag from components/dashboard/script-tags.js, pinned to extension-pipe 0.2.0 and datapipe-client 0.1.0, and the client README pins too. __tests__/script-tags.test.js fails when the client pin falls behind packages/client/package.json. Also say on the streaming and sending-data pages that the library takes experimentID while the extension's parameter is experiment_id. Co-Authored-By: Claude Opus 5 --- __tests__/script-tags.test.js | 37 ++++++++++++++++++++++++++ components/dashboard/CodeHints.js | 15 ++++++----- components/dashboard/script-tags.js | 16 +++++++++++ packages/client/README.md | 4 ++- pages/docs/experiments/sending-data.js | 15 ++++++++++- pages/docs/experiments/streaming.js | 4 ++- 6 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 __tests__/script-tags.test.js create mode 100644 components/dashboard/script-tags.js diff --git a/__tests__/script-tags.test.js b/__tests__/script-tags.test.js new file mode 100644 index 0000000..1825efb --- /dev/null +++ b/__tests__/script-tags.test.js @@ -0,0 +1,37 @@ +/** + * @jest-environment node + * + * The pinned datapipe-client version in the pasted `} + {EXTENSION_PIPE_SCRIPT} {extensionSnippet(expId)} @@ -106,7 +107,7 @@ export default function CodeHints({ expId }) { Use saveBase64Data to upload binary files (audio, video, images). This example saves audio from the html-audio-response plugin. - {``} + {EXTENSION_PIPE_SCRIPT} {` @@ -132,7 +133,7 @@ export default function CodeHints({ expId }) { Request the next condition assignment. This is async, so wrap your experiment in an async function. - {``} + {EXTENSION_PIPE_SCRIPT} {` @@ -173,7 +174,7 @@ export default function CodeHints({ expId }) { Send your data as a string with a unique filename. - {``} + {DATAPIPE_CLIENT_SCRIPT} {` @@ -201,7 +202,7 @@ export default function CodeHints({ expId }) { Send each trial as it happens, so a participant who closes the tab partway through does not take all of their data with them. - {``} + {DATAPIPE_CLIENT_SCRIPT} {` @@ -238,7 +239,7 @@ export default function CodeHints({ expId }) { Send binary data (audio, video, images) as a base64 string. DataPipe decodes it and uploads the file to your storage provider. - {``} + {DATAPIPE_CLIENT_SCRIPT} {` @@ -256,7 +257,7 @@ export default function CodeHints({ expId }) { Request the next condition assignment, a number starting at 0. - {``} + {DATAPIPE_CLIENT_SCRIPT} {` diff --git a/components/dashboard/script-tags.js b/components/dashboard/script-tags.js new file mode 100644 index 0000000..902e173 --- /dev/null +++ b/components/dashboard/script-tags.js @@ -0,0 +1,16 @@ +// The `; +export const DATAPIPE_CLIENT_SCRIPT = ``; diff --git a/packages/client/README.md b/packages/client/README.md index 589f17e..1e60e23 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -13,9 +13,11 @@ npm install datapipe-client Or in a plain HTML page, which exposes a `DataPipe` global: ```html - + ``` +Keep the version in the URL. Without one, unpkg serves the newest release, which could change a study that is already collecting data. + ## Sending data at the end ```js diff --git a/pages/docs/experiments/sending-data.js b/pages/docs/experiments/sending-data.js index 13ecf51..bd266bc 100644 --- a/pages/docs/experiments/sending-data.js +++ b/pages/docs/experiments/sending-data.js @@ -6,6 +6,7 @@ import DocsLayout from "../../../components/docs/DocsLayout"; import DocsSection from "../../../components/docs/DocsSection"; import CodeHints from "../../../components/dashboard/CodeHints"; import CodeBlock from "../../../components/CodeBlock"; +import { DATAPIPE_CLIENT_SCRIPT } from "../../../components/dashboard/script-tags"; // Prose link, per DESIGN.md §5: brandGreen.fg with a persistent underline, so // a link is never signalled by color alone. Local to this page for the same @@ -97,7 +98,7 @@ export default function SendingDataPage() { of the panel above switches every sample to it. - {``} + {DATAPIPE_CLIENT_SCRIPT} That gives you a DataPipe global. If you use a bundler,{" "} @@ -106,6 +107,18 @@ export default function SendingDataPage() { saveData, createSession,{" "} saveBase64Data, and getCondition. + + The URL names an exact version, and so does the extension's in + the jsPsych samples. Keep it pinned. Without a version, unpkg + serves the newest release, which could change a study that's + already collecting data. Move to a newer version when you're + ready to pilot with it. + + + The library names the ID experimentID. The jsPsych + extension's parameter is experiment_id, following + jsPsych's naming. Copy the sample for the one you use. + Send whatever your experiment produces. The data string is stored byte for byte under the filename you give it. diff --git a/pages/docs/experiments/streaming.js b/pages/docs/experiments/streaming.js index bd076f9..88118b6 100644 --- a/pages/docs/experiments/streaming.js +++ b/pages/docs/experiments/streaming.js @@ -77,7 +77,9 @@ export default function SavingAsYouGoPage() { 's createSession. Unlike the extension, the library doesn't stream by default. Your experiment opts in by starting a session. The Save as you go tab under - JavaScript in the panel below has the code. + JavaScript in the panel below has the code. Note that the library + takes experimentID, where the extension's + parameter is experiment_id. From 4479720018112b5b0db7adbfc6fd12b6d6acf192 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Mon, 21 Sep 2026 18:30:20 -0400 Subject: [PATCH 2/5] Accept experiment_id in datapipe-client The jsPsych extension and plugin call the experiment ID experiment_id, so the client now takes that name too. experimentID keeps working, because studies on an unpinned script tag pass it and so does the extension. If both are given and differ, the one-shot calls throw and a session starts inert with a warning. The wire format is unchanged. Co-Authored-By: Claude Opus 5 --- .../client/.changeset/experiment-id-name.md | 7 +++ packages/client/src/api.ts | 61 +++++++++++-------- packages/client/src/http.ts | 22 +++++++ packages/client/src/index.ts | 2 +- packages/client/src/session.ts | 24 +++++++- packages/client/src/types.ts | 27 ++++++-- packages/client/test/api.test.ts | 46 ++++++++++++++ packages/client/test/session.test.ts | 27 ++++++++ 8 files changed, 181 insertions(+), 35 deletions(-) create mode 100644 packages/client/.changeset/experiment-id-name.md diff --git a/packages/client/.changeset/experiment-id-name.md b/packages/client/.changeset/experiment-id-name.md new file mode 100644 index 0000000..eb24094 --- /dev/null +++ b/packages/client/.changeset/experiment-id-name.md @@ -0,0 +1,7 @@ +--- +"datapipe-client": minor +--- + +Accept `experiment_id`, the name the jsPsych extension and plugin use for the experiment ID, so the same option is spelled the same way everywhere. + +`experimentID` still works and there are no plans to remove it. Give one or the other. If both are given and differ, `saveData`, `saveBase64Data` and `getCondition` throw, and a session starts inert with a console warning. Nothing changes on the wire. diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index f07cb9b..1efee9b 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -2,8 +2,8 @@ // Incremental upload (DataPipeSession) lives in session.ts and calls // saveData too, via the caller's own code -- see the `sessionId` option. -import { endpoint, isSuccessfulResult, sendRequest } from "./http.js"; -import { SaveResult } from "./types.js"; +import { endpoint, experimentIDFrom, isSuccessfulResult, sendRequest } from "./http.js"; +import { ExperimentIDOption, SaveResult } from "./types.js"; export { setBaseURL, getBaseURL } from "./http.js"; @@ -34,7 +34,8 @@ async function postJSON( * Save data to a researcher's storage provider via pipe.jspsych.org (or * another deployment). * - * @param options.experimentID The 12-character experiment ID. + * @param options.experiment_id The 12-character experiment ID. (`experimentID` + * is accepted too; see `ExperimentIDOption`.) * @param options.filename A unique filename to save the data to, including * its extension. If it already exists, no data will be saved. * @param options.data A string-based representation of the data (JSON, CSV, @@ -44,14 +45,16 @@ async function postJSON( * staged copy this submission supersedes. * @param options.baseURL Override the DataPipe deployment for this call. */ -export async function saveData(options: { - experimentID: string; - filename: string; - data: string; - sessionId?: string; - baseURL?: string; -}): Promise { - const { experimentID, filename, data, sessionId, baseURL } = options; +export async function saveData( + options: ExperimentIDOption & { + filename: string; + data: string; + sessionId?: string; + baseURL?: string; + } +): Promise { + const experimentID = experimentIDFrom(options); + const { filename, data, sessionId, baseURL } = options; if (!experimentID || !filename || !data) { throw new Error("Missing required parameter(s)."); } @@ -71,19 +74,22 @@ export async function saveData(options: { * Save base64-encoded data (e.g. audio, images) to a researcher's storage * provider. The server decodes it to binary before storing it. * - * @param options.experimentID The 12-character experiment ID. + * @param options.experiment_id The 12-character experiment ID. (`experimentID` + * is accepted too; see `ExperimentIDOption`.) * @param options.filename A unique filename to save the data to, including * its extension. * @param options.data The data as a base64-encoded string. * @param options.baseURL Override the DataPipe deployment for this call. */ -export async function saveBase64Data(options: { - experimentID: string; - filename: string; - data: string; - baseURL?: string; -}): Promise { - const { experimentID, filename, data, baseURL } = options; +export async function saveBase64Data( + options: ExperimentIDOption & { + filename: string; + data: string; + baseURL?: string; + } +): Promise { + const experimentID = experimentIDFrom(options); + const { filename, data, baseURL } = options; if (!experimentID || !filename || !data) { throw new Error("Missing required parameter(s)."); } @@ -112,26 +118,27 @@ export async function saveBase64Data(options: { * ```js * let condition; * try { - * condition = await getCondition({ experimentID: "abc123" }); + * condition = await getCondition({ experiment_id: "abc123" }); * } catch (error) { * document.body.innerHTML = "

The experiment could not be started.

"; * throw error; * } * ``` * - * @param options.experimentID The 12-character experiment ID. + * @param options.experiment_id The 12-character experiment ID. (`experimentID` + * is accepted too; see `ExperimentIDOption`.) * @param options.baseURL Override the DataPipe deployment for this call. * @throws If the request cannot be made, DataPipe refuses it (condition * assignment switched off, experiment closed), or the response carries no * condition. */ -export async function getCondition(options: { - experimentID: string; - baseURL?: string; -}): Promise { - const { experimentID, baseURL } = options; +export async function getCondition( + options: ExperimentIDOption & { baseURL?: string } +): Promise { + const experimentID = experimentIDFrom(options); + const { baseURL } = options; if (!experimentID) { - throw new Error("datapipe: getCondition requires an experimentID."); + throw new Error("datapipe: getCondition requires an experiment_id."); } let response: Response; diff --git a/packages/client/src/http.ts b/packages/client/src/http.ts index 98a6e4d..42d42d3 100644 --- a/packages/client/src/http.ts +++ b/packages/client/src/http.ts @@ -49,6 +49,28 @@ export function normalizeBaseURL(url: string): string { return url.slice(0, end); } +/** + * The experiment ID from an options object, whichever name it came under + * (see `ExperimentIDOption`). Returns "" when neither is given, so each + * caller keeps its own answer to a missing ID. + * + * Throws when both are given and disagree. TypeScript already refuses both, + * but a plain-JavaScript caller gets no such check, and quietly picking one + * would send data to an experiment the researcher may not have meant. + */ +export function experimentIDFrom(options: { + experiment_id?: string; + experimentID?: string; +}): string { + const { experiment_id, experimentID } = options; + if (experiment_id && experimentID && experiment_id !== experimentID) { + throw new Error( + "datapipe: experiment_id and experimentID were both given and differ. Pass only experiment_id." + ); + } + return experiment_id || experimentID || ""; +} + export function endpoint(path: string, override?: string): string { return `${normalizeBaseURL(override || baseURL)}/api/${path}/`; } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4fce2b1..1d12e82 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -12,4 +12,4 @@ export { saveData, saveBase64Data, getCondition, setBaseURL, getBaseURL } from "./api.js"; export { DataPipeSession, createSession, startSession } from "./session.js"; -export type { SessionOptions, SaveResult, SessionConfig } from "./types.js"; +export type { ExperimentIDOption, SessionOptions, SaveResult, SessionConfig } from "./types.js"; diff --git a/packages/client/src/session.ts b/packages/client/src/session.ts index 32507ad..3ed8fa4 100644 --- a/packages/client/src/session.ts +++ b/packages/client/src/session.ts @@ -43,7 +43,7 @@ import { update, } from "firebase/database"; -import { endpoint } from "./http.js"; +import { endpoint, experimentIDFrom } from "./http.js"; import { SessionConfig, SessionOptions } from "./types.js"; // How many trials record() will hold onto before the session has finished @@ -557,6 +557,24 @@ export class DataPipeSession { } } +/** + * The experiment ID for a session, or "" if there is no usable one. + * + * `experimentIDFrom` throws when `experiment_id` and `experimentID` + * disagree, which is right for `saveData` but not here: starting a session + * never throws (see `startSession`). An empty ID turns streaming off in + * `doStart`, and a `saveData` call given the same options throws where + * the caller can see it. + */ +function sessionExperimentID(options: SessionOptions): string { + try { + return experimentIDFrom(options); + } catch (error) { + console.warn((error as Error).message); + return ""; + } +} + /** * Start an incremental upload session SYNCHRONOUSLY: the session object is * returned immediately, and the POST /api/session round trip runs in the @@ -580,7 +598,7 @@ export function createSession(options: SessionOptions): DataPipeSession { const session = new DataPipeSession(); // Fire-and-forget: errors are handled inside start() itself (see // doStart's catch block) and never surface here or reject anything. - void session.start(options.experimentID, endpoint("session", options.baseURL), { + void session.start(sessionExperimentID(options), endpoint("session", options.baseURL), { filename: options.filename, }); return session; @@ -602,7 +620,7 @@ export function createSession(options: SessionOptions): DataPipeSession { */ export async function startSession(options: SessionOptions): Promise { const session = new DataPipeSession(); - await session.start(options.experimentID, endpoint("session", options.baseURL), { + await session.start(sessionExperimentID(options), endpoint("session", options.baseURL), { filename: options.filename, }); return session; diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 6ea8d8a..b63227b 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -2,10 +2,29 @@ // surface these describe, and docs/streaming-ingest-design.md (in the // DataPipe repository) for the staging-tier design these types are part of. +/** + * The experiment ID, under either name. + * + * `experiment_id` is the documented one, because it is what the jsPsych + * extension and the older jsPsych plugin call it, and a researcher moving + * between them should not have to remember a second spelling. `experimentID` + * was this library's only name in 0.1.0 and keeps working, since studies + * already running on an unpinned script tag pass it. Give one, not both. + */ +export type ExperimentIDOption = + | { + /** The 12-character experiment ID provided by pipe.jspsych.org. */ + experiment_id: string; + experimentID?: never; + } + | { + /** The same, under the name 0.1.0 used. Prefer `experiment_id`. */ + experimentID: string; + experiment_id?: never; + }; + /** Options for starting an incremental-upload session. */ -export interface SessionOptions { - /** The 12-character experiment ID provided by pipe.jspsych.org. */ - experimentID: string; +export type SessionOptions = ExperimentIDOption & { /** * The filename this participant will submit under, if it is already known. * @@ -17,7 +36,7 @@ export interface SessionOptions { filename?: string; /** Override the DataPipe deployment. Defaults to https://pipe.jspsych.org. */ baseURL?: string; -} +}; /** The outcome of a `saveData` / `saveBase64Data` call. */ export interface SaveResult { diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index 0c81f98..b6f44da 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -250,3 +250,49 @@ describe("getCondition", () => { await expect(getCondition({ experimentID: "" })).rejects.toThrow(); }); }); + +// experiment_id is the documented name, matching the jsPsych extension and +// plugin. experimentID was the only name in 0.1.0, and studies running on an +// unpinned script tag still pass it, so it has to keep working. Either way the +// wire format is unchanged: the API has always read `experimentID`. +describe("the experiment ID's two names", () => { + const calls: Array<[string, (options: any) => Promise]> = [ + ["saveData", (id) => saveData({ ...id, filename: "p01.csv", data: "x" })], + ["saveBase64Data", (id) => saveBase64Data({ ...id, filename: "a.wav", data: "AAAA" })], + ["getCondition", (id) => getCondition(id)], + ]; + + it.each(calls)("%s sends experiment_id as experimentID", async (_name, call) => { + const fetchMock = mockFetch(() => ({ condition: 1 })); + + await call({ experiment_id: "EXP12345" }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); + expect(body.experimentID).toBe("EXP12345"); + expect(body).not.toHaveProperty("experiment_id"); + }); + + it.each(calls)("%s still accepts experimentID", async (_name, call) => { + const fetchMock = mockFetch(() => ({ condition: 1 })); + + await call({ experimentID: "EXP12345" }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); + expect(body.experimentID).toBe("EXP12345"); + }); + + it.each(calls)("%s accepts both when they agree", async (_name, call) => { + mockFetch(() => ({ condition: 1 })); + + await expect(call({ experiment_id: "EXP12345", experimentID: "EXP12345" })).resolves.not.toThrow(); + }); + + it.each(calls)("%s throws when they disagree, and sends nothing", async (_name, call) => { + const fetchMock = mockFetch(() => ({ condition: 1 })); + + await expect(call({ experiment_id: "EXP12345", experimentID: "OTHER" })).rejects.toThrow( + /experiment_id and experimentID/ + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/client/test/session.test.ts b/packages/client/test/session.test.ts index 8dfdaa6..3f13808 100644 --- a/packages/client/test/session.test.ts +++ b/packages/client/test/session.test.ts @@ -90,6 +90,33 @@ describe("startSession", () => { expect(session.enabled).toBe(false); }); + + it("accepts experiment_id, the name the jsPsych extension uses", async () => { + const fetchMock = mockFetch(() => SESSION_CONFIG); + + const session = await startSession({ experiment_id: "EXP12345", filename: "p01.csv" }); + + expect(JSON.parse(fetchMock.mock.calls[0][1]!.body as string)).toEqual({ + experimentID: "EXP12345", + filename: "p01.csv", + }); + expect(session.enabled).toBe(true); + }); + + it("returns an inert session, without throwing, when the two names disagree", async () => { + const fetchMock = mockFetch(() => SESSION_CONFIG); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const session = await startSession({ + experiment_id: "EXP12345", + experimentID: "OTHER", + } as any); + + expect(session.enabled).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + expect(warn.mock.calls[0][0]).toMatch(/experiment_id and experimentID/); + warn.mockRestore(); + }); }); describe("createSession (synchronous, pre-start buffering)", () => { From a00dac072defcc50aed1ea237a63834286ab7e3c Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Mon, 21 Sep 2026 18:58:45 -0400 Subject: [PATCH 3/5] Keep the client pins in step with each release automatically The docs samples now read datapipe-client's version from its package.json, so the release PR's version bump moves their pin. That can't be done by a sync script: changesets/action (cwd: packages/client) commits only files under that directory, so an edit to components/ would never be committed. The README is inside the package, so `version-packages` now runs scripts/sync-readme-pin.mjs after `changeset version` to rewrite its pin. The script fails if the README has no unpkg URL to pin, rather than shipping one that points at the previous release. Co-Authored-By: Claude Opus 5 --- __tests__/script-tags.test.js | 5 ++-- components/dashboard/script-tags.js | 16 ++++++---- packages/client/package.json | 2 +- packages/client/scripts/sync-readme-pin.mjs | 33 +++++++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 packages/client/scripts/sync-readme-pin.mjs diff --git a/__tests__/script-tags.test.js b/__tests__/script-tags.test.js index 1825efb..a4db246 100644 --- a/__tests__/script-tags.test.js +++ b/__tests__/script-tags.test.js @@ -3,8 +3,9 @@ * * The pinned datapipe-client version in the pasted `; export const DATAPIPE_CLIENT_SCRIPT = ``; diff --git a/packages/client/package.json b/packages/client/package.json index 698c490..cda9ee4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -41,7 +41,7 @@ "test:watch": "vitest", "size": "node scripts/report-size.mjs", "changeset": "changeset", - "version-packages": "changeset version", + "version-packages": "changeset version && node scripts/sync-readme-pin.mjs", "release": "npm run build && changeset publish" }, "dependencies": { diff --git a/packages/client/scripts/sync-readme-pin.mjs b/packages/client/scripts/sync-readme-pin.mjs new file mode 100644 index 0000000..6bdb849 --- /dev/null +++ b/packages/client/scripts/sync-readme-pin.mjs @@ -0,0 +1,33 @@ +// Points the README's unpkg URLs at the version in package.json. +// +// Runs as part of `npm run version-packages`, after `changeset version` has +// bumped package.json, so the "Release datapipe-client" PR carries the new +// pin and npm publishes a README whose script tag loads the release it +// ships with. The DataPipe site needs no counterpart: its samples read the +// version from package.json directly (components/dashboard/script-tags.js). +// +// Fails, rather than doing nothing, if the README has no unpkg URL to pin. +// A silent no-op would ship a README pointing at the previous release. + +import { readFileSync, writeFileSync } from "node:fs"; + +const packageURL = new URL("../package.json", import.meta.url); +const readmeURL = new URL("../README.md", import.meta.url); + +const { version } = JSON.parse(readFileSync(packageURL, "utf8")); +const readme = readFileSync(readmeURL, "utf8"); + +// Matches the URL with or without a version, so an unpinned one gets pinned. +const pin = /unpkg\.com\/datapipe-client(@[^/"\s]+)?/g; +if (!pin.test(readme)) { + console.error("sync-readme-pin: README.md has no unpkg.com/datapipe-client URL to pin."); + process.exit(1); +} + +const updated = readme.replace(pin, `unpkg.com/datapipe-client@${version}`); +if (updated !== readme) { + writeFileSync(readmeURL, updated); + console.log(`sync-readme-pin: README.md now pins datapipe-client@${version}.`); +} else { + console.log(`sync-readme-pin: README.md already pins datapipe-client@${version}.`); +} From 132affd6f7b6f9b5961cf7c0bdc6f70c25763314 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 22 Sep 2026 07:58:09 -0400 Subject: [PATCH 4/5] Let saveData take the session instead of a flushed sessionId The documented pattern was flush, read sessionId, submit. The flush was only there to wait for the session to start, but it also waited on the last staged write, which a backgrounded tab throttles hard, so the final submission could sit for most of a minute (#273). session.ready() waits for startup alone. saveData({ session }) awaits it and sends the id, so callers no longer read sessionId themselves. The sessionId option still works. The README, the dashboard sample and the streaming docs now show the new pattern. Co-Authored-By: Claude Opus 5 --- components/dashboard/CodeHints.js | 6 +-- packages/client/.changeset/session-ready.md | 7 ++++ packages/client/README.md | 7 ++-- packages/client/src/api.ts | 20 ++++++--- packages/client/src/session.ts | 18 ++++++++- packages/client/test/api.test.ts | 45 +++++++++++++++++++++ packages/client/test/session.test.ts | 24 +++++++++++ pages/docs/experiments/streaming.js | 13 ++---- 8 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 packages/client/.changeset/session-ready.md diff --git a/components/dashboard/CodeHints.js b/components/dashboard/CodeHints.js index 7151a44..86b3f73 100644 --- a/components/dashboard/CodeHints.js +++ b/components/dashboard/CodeHints.js @@ -216,18 +216,14 @@ export default function CodeHints({ expId }) { session.record(trialData); // ...when the experiment ends: - await session.flush(); const result = await DataPipe.saveData({ experimentID: "${expId}", filename: filename, data: dataAsString, - sessionId: session.sessionId, + session: session, }); await session.close({ submitted: result.ok });`}
- - Flush before reading sessionId: the session starts in the background, and until it has, the id is empty. Submitting without it leaves the staged copy unmatched, and it comes back as a duplicate .partial.json. - A participant who finishes produces one ordinary file. One who quits partway produces a separate file ending in .partial.json, holding the trials they completed. Partial sessions do not count toward your session limit. diff --git a/packages/client/.changeset/session-ready.md b/packages/client/.changeset/session-ready.md new file mode 100644 index 0000000..e22fc02 --- /dev/null +++ b/packages/client/.changeset/session-ready.md @@ -0,0 +1,7 @@ +--- +"datapipe-client": minor +--- + +Pass the session to `saveData` with `session` instead of flushing and reading `sessionId` yourself. `saveData` waits for the session to start and sends its id. + +Adds `session.ready()`, which resolves once the session has started (or failed to). It waits only for startup, not for staged writes, so a final submission from a background tab is no longer held up by a throttled flush. `sessionId` still works. diff --git a/packages/client/README.md b/packages/client/README.md index 1e60e23..4dea3b9 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -52,12 +52,11 @@ const session = createSession({ session.record(trialData); // ...at the end: -await session.flush(); const result = await saveData({ experimentID: "YOUR_EXPERIMENT_ID", filename: "subject-01.csv", data: allTrialsAsCSV, - sessionId: session.sessionId, + session, }); await session.close({ submitted: result.ok }); ``` @@ -65,7 +64,7 @@ await session.close({ submitted: result.ok }); Three things are worth knowing: - **`createSession()` returns immediately.** The request that starts the session is still in flight, and trials recorded before it lands are buffered and staged once it does. Use `await startSession(...)` instead if you would rather wait and check `session.enabled`. -- **Flush before reading `sessionId`.** `flush()` waits for the session to start, so until you have awaited it, `sessionId` may still be empty. Submitting without it leaves DataPipe unable to match your file to the staged copy, which it would then recover a second time. +- **Pass the session to `saveData`.** It tells DataPipe that this submission completes the staged copy, so the staged copy is discarded instead of being recovered as a second file. If you build the request yourself, `await session.ready()` and send `session.sessionId`. - **Tell `close()` what happened.** `{ submitted: true }` cancels the abandonment marker, so a completed session is never also reported as abandoned. `{ submitted: false }` marks it now, so the staged trials are recovered on DataPipe's normal sweep rather than waiting out the 24-hour expiry. Nothing about staging will break your experiment. If the session cannot be started, every method on it becomes a no-op and the data is still submitted at the end. @@ -116,7 +115,7 @@ setBaseURL("https://datapipe-test.web.app"); | `getCondition(options)` | `Promise` | **yes** | | `setBaseURL(url)` / `getBaseURL()` | — | no | -`DataPipeSession` has `enabled`, `sessionId`, `record(data)`, `flush()`, and `close({ submitted })`. +`DataPipeSession` has `enabled`, `sessionId`, `ready()`, `record(data)`, `flush()`, and `close({ submitted })`. `SaveResult` is `{ ok: boolean, status: number, body: any }`. A `status` of `0` means the request never reached DataPipe. diff --git a/packages/client/src/api.ts b/packages/client/src/api.ts index 1efee9b..4e12f02 100644 --- a/packages/client/src/api.ts +++ b/packages/client/src/api.ts @@ -1,8 +1,9 @@ // The three one-shot REST calls: saveData, saveBase64Data, getCondition. // Incremental upload (DataPipeSession) lives in session.ts and calls -// saveData too, via the caller's own code -- see the `sessionId` option. +// saveData too, via the caller's own code -- see the `session` option. import { endpoint, experimentIDFrom, isSuccessfulResult, sendRequest } from "./http.js"; +import type { DataPipeSession } from "./session.js"; import { ExperimentIDOption, SaveResult } from "./types.js"; export { setBaseURL, getBaseURL } from "./http.js"; @@ -40,24 +41,33 @@ async function postJSON( * its extension. If it already exists, no data will be saved. * @param options.data A string-based representation of the data (JSON, CSV, * or any other text-based format). - * @param options.sessionId Present only for a streamed session -- see - * `DataPipeSession`. Carries no data of its own; it tells DataPipe which - * staged copy this submission supersedes. + * @param options.session The streamed session this submission completes, if + * there is one. saveData waits for it to start and sends its id, which + * tells DataPipe which staged copy this submission supersedes. + * @param options.sessionId The same id, given directly. Use `session` + * instead unless you are managing the id yourself; if both are given, + * `session` wins. * @param options.baseURL Override the DataPipe deployment for this call. */ export async function saveData( options: ExperimentIDOption & { filename: string; data: string; + session?: DataPipeSession; sessionId?: string; baseURL?: string; } ): Promise { const experimentID = experimentIDFrom(options); - const { filename, data, sessionId, baseURL } = options; + const { filename, data, session, baseURL } = options; if (!experimentID || !filename || !data) { throw new Error("Missing required parameter(s)."); } + let sessionId = options.sessionId; + if (session) { + await session.ready(); + sessionId = session.sessionId; + } return postJSON( endpoint("data", baseURL), { diff --git a/packages/client/src/session.ts b/packages/client/src/session.ts index 3ed8fa4..dd1b67f 100644 --- a/packages/client/src/session.ts +++ b/packages/client/src/session.ts @@ -76,7 +76,10 @@ export class DataPipeSession { } private _sessionId = ""; - /** The id to send with the final submission. Empty when never enabled. */ + /** + * The id to send with the final submission. Empty until the session has + * started (see `ready()`), and for good if it never does. + */ get sessionId(): string { return this._sessionId; } @@ -145,6 +148,19 @@ export class DataPipeSession { return this.startPromise; } + /** + * Resolves once the session has started, or failed to start. Never + * rejects. From then on `sessionId` is final: the session's id, or "" if + * it could not start. + * + * This waits only for the round trip to /api/session, not for any staged + * writes, so it is the thing to await before submitting. (`saveData` does + * it for you when given `session`.) + */ + async ready(): Promise { + await this.startPromise; + } + private async doStart( experimentID: string, endpointURL: string, diff --git a/packages/client/test/api.test.ts b/packages/client/test/api.test.ts index b6f44da..d0a655e 100644 --- a/packages/client/test/api.test.ts +++ b/packages/client/test/api.test.ts @@ -124,6 +124,51 @@ describe("saveData", () => { expect(body.sessionId).toBe("SESSION123"); }); + it("waits for a session that has not started yet, then sends its id", async () => { + let started!: () => void; + const session = { + sessionId: "", + ready: () => + new Promise((resolve) => { + started = () => { + session.sessionId = "SESSION123"; + resolve(); + }; + }), + }; + const fetchMock = mockFetch(() => ({ message: "Success" })); + + const pending = saveData({ + experimentID: "EXP12345", + filename: "p01.csv", + data: "a,b\n1,2\n", + session: session as any, + }); + await Promise.resolve(); + expect(fetchMock).not.toHaveBeenCalled(); + + started(); + await pending; + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); + expect(body.sessionId).toBe("SESSION123"); + }); + + it("sends no sessionId for a session that could not start", async () => { + const session = { sessionId: "", ready: async () => {} }; + const fetchMock = mockFetch(() => ({ message: "Success" })); + + await saveData({ + experimentID: "EXP12345", + filename: "p01.csv", + data: "a,b\n1,2\n", + session: session as any, + }); + + const body = JSON.parse(fetchMock.mock.calls[0][1]!.body as string); + expect(body).not.toHaveProperty("sessionId"); + }); + it("throws on a missing required parameter", async () => { await expect(saveData({ experimentID: "", filename: "p01.csv", data: "x" })).rejects.toThrow(); }); diff --git a/packages/client/test/session.test.ts b/packages/client/test/session.test.ts index 3f13808..ea6fce5 100644 --- a/packages/client/test/session.test.ts +++ b/packages/client/test/session.test.ts @@ -210,6 +210,30 @@ describe("createSession (synchronous, pre-start buffering)", () => { expect(flushedTrials(0)).toEqual([["trials/0", '{"trial":0}']]); }); + it("ready() resolves once the session has started, without waiting for staged writes", async () => { + const release = deferredFetch(); + rtdb.update.mockImplementation(() => new Promise(() => {})); // a write that never lands + + const session = createSession({ experimentID: "EXP12345" }); + session.record({ trial: 0 }); + expect(session.sessionId).toBe(""); + + release(SESSION_CONFIG); + await session.ready(); + + expect(session.sessionId).toBe("SESSION123"); + }); + + it("ready() resolves, with an empty sessionId, when the start fails", async () => { + const release = deferredFetch(); + const session = createSession({ experimentID: "EXP12345" }); + + release({ ok: false, status: 400 }); + await session.ready(); + + expect(session.sessionId).toBe(""); + }); + it("a trial recorded after close() is requested is not staged", async () => { const release = deferredFetch(); diff --git a/pages/docs/experiments/streaming.js b/pages/docs/experiments/streaming.js index 88118b6..037d594 100644 --- a/pages/docs/experiments/streaming.js +++ b/pages/docs/experiments/streaming.js @@ -83,15 +83,10 @@ export default function SavingAsYouGoPage() { - One thing to get right in plain JavaScript:{" "} - - call flush() before you read sessionId. - {" "} - A session starts in the background, and until it has, the ID is an - empty string. If you submit without it, DataPipe can't match - your file to the staged copy, so it recovers that copy separately - and you end up with a stray .partial.json next to a - complete file. + At the end, pass the session to saveData as{" "} + session. That tells DataPipe the submission completes + the staged copy, so the staged copy is discarded rather than + recovered as a second file. The rest of the code, for jsPsych and plain JavaScript, and what From ea9c1e1cc0fd41bd967337d065e05ef74439c6c3 Mon Sep 17 00:00:00 2001 From: Josh de Leeuw Date: Tue, 22 Sep 2026 08:04:40 -0400 Subject: [PATCH 5/5] Use experiment_id in the datapipe-client samples The client accepts experiment_id as of this release, so the samples and README now use the same name as the jsPsych extension, and the notes explaining that the two names differ are gone. Co-Authored-By: Claude Opus 5 --- components/dashboard/CodeHints.js | 10 +++++----- components/home/hero-snippets.js | 2 +- packages/client/README.md | 10 +++++----- pages/docs/experiments/sending-data.js | 5 ----- pages/docs/experiments/streaming.js | 4 +--- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/components/dashboard/CodeHints.js b/components/dashboard/CodeHints.js index 86b3f73..ed0ad20 100644 --- a/components/dashboard/CodeHints.js +++ b/components/dashboard/CodeHints.js @@ -179,7 +179,7 @@ export default function CodeHints({ expId }) { {` const result = await DataPipe.saveData({ - experimentID: "${expId}", + experiment_id: "${expId}", filename: "UNIQUE_FILENAME.csv", data: dataAsString, }); @@ -208,7 +208,7 @@ export default function CodeHints({ expId }) { {` const filename = "UNIQUE_FILENAME.csv"; const session = DataPipe.createSession({ - experimentID: "${expId}", + experiment_id: "${expId}", filename: filename, }); @@ -217,7 +217,7 @@ export default function CodeHints({ expId }) { // ...when the experiment ends: const result = await DataPipe.saveData({ - experimentID: "${expId}", + experiment_id: "${expId}", filename: filename, data: dataAsString, session: session, @@ -240,7 +240,7 @@ export default function CodeHints({ expId }) { {` const result = await DataPipe.saveBase64Data({ - experimentID: "${expId}", + experiment_id: "${expId}", filename: "UNIQUE_FILENAME.webm", data: base64DataString, });`} @@ -259,7 +259,7 @@ export default function CodeHints({ expId }) { {` let condition; try { - condition = await DataPipe.getCondition({ experimentID: "${expId}" }); + condition = await DataPipe.getCondition({ experiment_id: "${expId}" }); } catch (error) { document.body.innerHTML = "

The experiment could not be started.

"; throw error; diff --git a/components/home/hero-snippets.js b/components/home/hero-snippets.js index f7a7fbb..8791612 100644 --- a/components/home/hero-snippets.js +++ b/components/home/hero-snippets.js @@ -51,7 +51,7 @@ export const snippets = [ { role: "fg", text: "await DataPipe." }, { role: "fn", text: "saveData" }, { role: "fg", text: "({\n" }, - { role: "fg", text: " experimentID: " }, + { role: "fg", text: " experiment_id: " }, { role: "string", text: '"your_id"' }, { role: "fg", text: ",\n" }, { role: "fg", text: " filename: " }, diff --git a/packages/client/README.md b/packages/client/README.md index 4dea3b9..f2dd023 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -24,7 +24,7 @@ Keep the version in the URL. Without one, unpkg serves the newest release, which import { saveData } from "datapipe-client"; const result = await saveData({ - experimentID: "YOUR_EXPERIMENT_ID", + experiment_id: "YOUR_EXPERIMENT_ID", filename: "subject-01.csv", data: "rt,response\n204,1\n389,0", }); @@ -44,7 +44,7 @@ Staging each trial as it is produced means a participant who closes the tab at t import { createSession, saveData } from "datapipe-client"; const session = createSession({ - experimentID: "YOUR_EXPERIMENT_ID", + experiment_id: "YOUR_EXPERIMENT_ID", filename: "subject-01.csv", }); @@ -53,7 +53,7 @@ session.record(trialData); // ...at the end: const result = await saveData({ - experimentID: "YOUR_EXPERIMENT_ID", + experiment_id: "YOUR_EXPERIMENT_ID", filename: "subject-01.csv", data: allTrialsAsCSV, session, @@ -76,7 +76,7 @@ import { getCondition } from "datapipe-client"; let condition; try { - condition = await getCondition({ experimentID: "YOUR_EXPERIMENT_ID" }); + condition = await getCondition({ experiment_id: "YOUR_EXPERIMENT_ID" }); } catch (error) { document.body.innerHTML = "

The experiment could not be started.

"; throw error; @@ -91,7 +91,7 @@ try { import { saveBase64Data } from "datapipe-client"; await saveBase64Data({ - experimentID: "YOUR_EXPERIMENT_ID", + experiment_id: "YOUR_EXPERIMENT_ID", filename: "subject-01-recording.webm", data: base64EncodedString, }); diff --git a/pages/docs/experiments/sending-data.js b/pages/docs/experiments/sending-data.js index bd266bc..ceda8ba 100644 --- a/pages/docs/experiments/sending-data.js +++ b/pages/docs/experiments/sending-data.js @@ -114,11 +114,6 @@ export default function SendingDataPage() { already collecting data. Move to a newer version when you're ready to pilot with it. - - The library names the ID experimentID. The jsPsych - extension's parameter is experiment_id, following - jsPsych's naming. Copy the sample for the one you use. - Send whatever your experiment produces. The data string is stored byte for byte under the filename you give it. diff --git a/pages/docs/experiments/streaming.js b/pages/docs/experiments/streaming.js index 037d594..a512ede 100644 --- a/pages/docs/experiments/streaming.js +++ b/pages/docs/experiments/streaming.js @@ -77,9 +77,7 @@ export default function SavingAsYouGoPage() { 's createSession. Unlike the extension, the library doesn't stream by default. Your experiment opts in by starting a session. The Save as you go tab under - JavaScript in the panel below has the code. Note that the library - takes experimentID, where the extension's - parameter is experiment_id. + JavaScript in the panel below has the code.