Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
1fbecf3
fix(search): match dataset keyword against dbinfo when subject filter…
elainefan331 Sep 2, 2026
cebafee
fix(search): stop "no matching subjects" warning flashing on slow sea…
elainefan331 Sep 2, 2026
c4e2b9c
perf(search): index iolinks(dbname, dsname, view) for file-type filter
elainefan331 Sep 3, 2026
6dd4ac3
feat(stats): compute landing-page db stats from Postgres instead of CGI
elainefan331 Sep 3, 2026
0e42d23
feat(stats): add stats_history table for persistent sync stats
elainefan331 Sep 3, 2026
0e68326
feat(stats): write a stats_history snapshot at the end of each sync
elainefan331 Sep 3, 2026
4a9bb83
perf(stats): serve /dbs/stats from the stats_history snapshot
elainefan331 Sep 3, 2026
b6df62a
feat(stats): render StatisticsBanner from the stats_history snapshot
elainefan331 Sep 3, 2026
edb1141
feat(stats): add dataset_changes table for dataset-level sync change …
elainefan331 Sep 10, 2026
8f69f2c
feat(stats): log dataset-level changes during incremental sync; refs …
elainefan331 Sep 10, 2026
d1011e9
feat(stats): add SYNC_DBS test override; verified add/update/delete o…
elainefan331 Sep 10, 2026
3f0455f
feat(stats): add GET /dbs/updates/latest endpoint; #145
elainefan331 Sep 10, 2026
afa6083
feat(stats): add fetchLatestUpdate service/action/slice + types; refs…
elainefan331 Sep 10, 2026
f6732d6
feat(stats): add LatestUpdateBoard to the landing page; refs #145
elainefan331 Sep 10, 2026
80c257c
fix(stats): label LatestUpdateBoard chips as dataset(s) for clarity; …
elainefan331 Sep 10, 2026
4006fba
feat(stats): add subject/file/size deltas to /dbs/updates/latest; ref…
elainefan331 Sep 10, 2026
9bb6ba3
style(stats): refine LatestUpdateBoard typography, colors, and alignm…
elainefan331 Sep 10, 2026
eb73328
style(stats): finalize LatestUpdateBoard styling; refs #145
elainefan331 Sep 10, 2026
aff4363
Merge pull request #146 from NeuroJSON/dev-fan
elainefan331 Sep 10, 2026
e18cf05
feat: add sandbox upload endpoint; refs #147
elainefan331 Sep 15, 2026
e878fd5
feat: upload to zodiac sandbox via timestamp update handler
elainefan331 Sep 15, 2026
becb535
feat(uploads): add authenticated POST /api/v1/uploads for JSON submis…
elainefan331 Sep 16, 2026
6550035
feat(uploads): add login-gated /upload page for JSON submissions; ref…
elainefan331 Sep 16, 2026
0e22f1f
style(uploads): improve UploadPage readability on the blue background…
elainefan331 Sep 16, 2026
2cb7abe
fix(uploads): add safe rate-limit key; refs #147
elainefan331 Sep 16, 2026
b336ebe
fix(uploads): strip reserved _-prefixed fields before writing; refs #147
elainefan331 Sep 16, 2026
44ab6ba
feat(uploads): add info note about review + reference id on UploadPag…
elainefan331 Sep 16, 2026
a1ebf4e
feat(uploads): submission workflow logic in createUpload; refs #147
elainefan331 Sep 21, 2026
f4e035d
feat(uploads): add submissions table + neurojson_dataset_seq; refs
elainefan331 Sep 21, 2026
bf92a4d
feat(uploads): support dataset updates + confirm flow on /upload page…
elainefan331 Sep 21, 2026
73e90a7
feat(uploads): add dashboard Uploads tab with status list + update ac…
elainefan331 Sep 21, 2026
18631ae
feat(uploads): add dataset name input on /upload page; refs #147
elainefan331 Sep 21, 2026
d88eceb
fix(uploads): simplify dataset-name-required error message; refs #147
elainefan331 Sep 21, 2026
280398f
feat(uploads): clarify + restyle the resubmit confirm dialog; refs#147
elainefan331 Sep 22, 2026
1b93e46
feat(upload):add dataset registry, submissions, and submission commen…
elainefan331 Sep 23, 2026
50a4e8c
feat(uploads): redesign submission identity model (registry + cycles …
elainefan331 Sep 23, 2026
31ecae2
polish(uploads): refine preferred dataset ID field + purple field foc…
elainefan331 Sep 23, 2026
daca7b3
feat(uploads): add a dataset detail page with review discussion threa…
elainefan331 Sep 23, 2026
08653cf
feat(uploads): enforce unique preferred dataset ID; refs #147
elainefan331 Sep 23, 2026
f63bb56
fix(uploads): show the real error for a taken preferred ID; refs #147
elainefan331 Sep 25, 2026
051c09a
feat(uploads): let users choose a target database (default public); r…
elainefan331 Sep 25, 2026
6d9b488
polish(uploads): tidy the /upload form fields; refs #147
elainefan331 Sep 25, 2026
6fbf04f
feat(uploads): copy button for internal reference + view-status link;…
elainefan331 Sep 25, 2026
c438d3d
feat(uploads): view submitted JSON via owner-only proxy; stop leaking…
elainefan331 Sep 25, 2026
5fcd166
feat(uploads): add draft workflow columns to submissions; refs #147
elainefan331 Sep 28, 2026
9c9ffbf
feat(uploads): add draft workflow with submit and withdraw; refs #147
elainefan331 Sep 28, 2026
02ea42d
feat(uploads): edit publishing settings on the upload detail page; re…
elainefan331 Sep 28, 2026
0d20792
fix(uploads): remove the internal ID field from the upload form; refs…
elainefan331 Sep 29, 2026
5858296
refactor(uploads): replace Update with Open on the Uploads tab; refs …
elainefan331 Sep 29, 2026
893f58b
feat(uploads): resubmit reminder and stricter name validation; refs #147
elainefan331 Sep 29, 2026
50d6c69
feat(uploads): add raw data tables (raw_objects, submission_raw_files…
elainefan331 Sep 30, 2026
8b606d4
feat(uploads): Ed25519 upload tokens and HMAC service signatures; ref…
elainefan331 Sep 30, 2026
da6a649
feat(uploads): add raw ZIP upload session routes; refs #147
elainefan331 Sep 30, 2026
508732b
feat(uploads): Zodiac storage events and raw upload completion; refs …
elainefan331 Oct 1, 2026
5ec8a47
feat(storage-api): skeleton for the Zodiac storage service; refs #147
elainefan331 Oct 2, 2026
0bd5225
feat(storage-api): tus resumable uploads with token checks; refs #147
elainefan331 Oct 2, 2026
fb8986d
feat(storage-api): finish uploads via a crash-safe outbox; refs #147
elainefan331 Oct 2, 2026
790c9bd
feat(storage-api): internal endpoints for REN (pull mode + monitoring…
elainefan331 Oct 2, 2026
86a59ae
feat(uploads): reconciliation job that finishes raw uploads by pullin…
elainefan331 Oct 2, 2026
34e321c
feat(uploads): frontend service for raw ZIP uploads; refs #147
elainefan331 Oct 2, 2026
0d5fc0a
feat(uploads): browser raw ZIP uploader on tus-js-client; refs #147
elainefan331 Oct 2, 2026
a5851b9
feat(uploads): raw ZIP section on the upload detail page; refs #147
elainefan331 Oct 2, 2026
91a65ed
feat(uploads): raw ZIP chip on the Uploads tab; refs #147
elainefan331 Oct 2, 2026
888e01c
feat(uploads): 50 GB raw ZIP limit, sent to the page; refs #147
elainefan331 Oct 2, 2026
f41bb28
Merge pull request #148 from NeuroJSON/dev-fan
elainefan331 Oct 6, 2026
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ backend/.env.local
backend/.env.*.local
backend/*.sqlite
backend/*.db
!backend/package-lock.json
!backend/package-lock.json

#storage-api
storage-api/storage-dev/
!storage-api/package-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// Composite index for the file-type filter's EXISTS subquery in
// searchAllDatabases:
// EXISTS (SELECT 1 FROM iolinks l
// WHERE l.dbname = ioviews.dbname AND l.dsname = ioviews.dsname
// AND l.view IN (:fileTypes))
// Without it the planner uses idx_iolinks_dbname alone and scans every row
// of a database (openneuro ~1.4M), filtering dsname/view in memory — the
// dominant cost of a subject search combined with a file_type filter. This
// turns that into a direct lookup on (dbname, dsname, view).
async up (queryInterface, Sequelize) {
await queryInterface.addIndex("iolinks", ["dbname", "dsname", "view"], {
name: "idx_iolinks_dbname_dsname_view",
});
},

async down (queryInterface, Sequelize) {
await queryInterface.removeIndex(
"iolinks",
"idx_iolinks_dbname_dsname_view"
);
}
};
61 changes: 61 additions & 0 deletions backend/migrations/20260903180911-create-stats-history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// One row per scheduled sync run. Doubles as (1) persistent landing-page
// stats and (2) sync execution history. Lifecycle: inserted 'running' at
// sync start; finalized 'success' (with totals) or 'failed' (with error)
// after the run completes. The latest 'success' row = current NeuroJSON state.
async up (queryInterface, Sequelize) {
await queryInterface.createTable("stats_history", {
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false,
},
started_at: {
type: Sequelize.DATE, // TIMESTAMPTZ
allowNull: false,
},
completed_at: {
type: Sequelize.DATE,
allowNull: true,
},
status: {
type: Sequelize.TEXT, // 'running' | 'success' | 'failed'
allowNull: false,
defaultValue: "running",
},
total_datasets: {
type: Sequelize.BIGINT,
allowNull: true,
},
total_subjects: {
type: Sequelize.BIGINT,
allowNull: true,
},
total_files: {
type: Sequelize.BIGINT,
allowNull: true,
},
total_size_bytes: {
type: Sequelize.BIGINT,
allowNull: true,
},
error: {
type: Sequelize.TEXT, // failure reason when status = 'failed'
allowNull: true,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
});
},

async down (queryInterface, Sequelize) {
await queryInterface.dropTable("stats_history");
}
};
59 changes: 59 additions & 0 deletions backend/migrations/20260910160052-create-dataset-changes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// One row per dataset-level change detected during an incremental sync.
// Only the incremental path (processDatasetUpdate / delete branch) writes
// here — firstSync never logs, so a full/first sync does not mass-record
// every dataset as "added". Powers the "latest update" board on the landing
// page. history_id → stats_history.id (the sync run that produced the change).
async up (queryInterface, Sequelize) {
await queryInterface.createTable("dataset_changes", {
id: {
type: Sequelize.INTEGER,
autoIncrement: true,
primaryKey: true,
allowNull: false,
},
history_id: {
type: Sequelize.INTEGER, // FK → stats_history.id (same INTEGER type)
allowNull: false,
references: { model: "stats_history", key: "id" },
onDelete: "CASCADE",
},
dbname: {
type: Sequelize.TEXT,
allowNull: false,
},
dsname: {
type: Sequelize.TEXT,
allowNull: false,
},
change_type: {
type: Sequelize.TEXT, // 'added' | 'updated' | 'deleted'
allowNull: false,
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
});

// Dedupe: one logical change per dataset per sync run (a dataset touched
// multiple times in one sync → ON CONFLICT DO NOTHING on this key).
await queryInterface.addIndex(
"dataset_changes",
["history_id", "dbname", "dsname"],
{ name: "uq_dataset_changes_run_dataset", unique: true }
);
// "latest update" lookup filters/groups by run.
await queryInterface.addIndex("dataset_changes", ["history_id"], {
name: "idx_dataset_changes_history_id",
});
},

async down (queryInterface, Sequelize) {
await queryInterface.dropTable("dataset_changes");
}
};
64 changes: 64 additions & 0 deletions backend/migrations/20260923192128-create-dataset-registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// Permanent record for one logical dataset. `internal_id` is the stable
// identity for the dataset's whole lifetime and is also the sandbox CouchDB
// `_id`. `dataset_id` (final public id) is assigned only at promotion.
async up(queryInterface, Sequelize) {
// Sequence backing NeuroJSON-generated public ids (njds######). Consumed
// only at promotion when the user did not request a valid custom id.
await queryInterface.sequelize.query(
"CREATE SEQUENCE IF NOT EXISTS neurojson_dataset_seq START 1;"
);

await queryInterface.createTable("dataset_registry", {
internal_id: {
type: Sequelize.UUID,
primaryKey: true,
allowNull: false, // app-generated (crypto.randomUUID); also the sandbox _id
},
dataset_id: {
type: Sequelize.STRING(255),
allowNull: true,
unique: true, // final public id, set at promotion (njds###### or custom)
},
requested_dataset_id: {
type: Sequelize.STRING(63),
allowNull: true, // user's preferred public id — a preference until promotion
},
dataset_name: {
type: Sequelize.STRING(255),
allowNull: true, // canonical logical-dataset name
},
owner_user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: { model: "users", key: "id" },
onDelete: "RESTRICT", // don't orphan a dataset's owner
onUpdate: "CASCADE",
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
});

await queryInterface.addIndex("dataset_registry", ["owner_user_id"], {
name: "idx_registry_owner",
});
},

async down(queryInterface) {
await queryInterface.dropTable("dataset_registry");
await queryInterface.sequelize.query(
"DROP SEQUENCE IF EXISTS neurojson_dataset_seq;"
);
},
};
75 changes: 75 additions & 0 deletions backend/migrations/20260923192138-create-submissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// One review workflow (cycle) for a logical dataset. Holds only the current
// status + review/promotion metadata; the reviewer<->submitter discussion
// lives in submission_comments. A dataset can have many cycles over time,
// but only ONE open one (see the partial unique index below).
async up(queryInterface, Sequelize) {
await queryInterface.createTable("submissions", {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false,
},
submission_id: {
type: Sequelize.UUID,
allowNull: false,
unique: true, // one review cycle; referenced by submission_comments
},
dataset_registry_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: "dataset_registry", key: "internal_id" },
onDelete: "CASCADE", // removing the logical dataset removes its cycles
onUpdate: "CASCADE",
},
status: {
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: "pending", // pending|changes_requested|approved|rejected|promoted
},
reviewed_by: {
type: Sequelize.INTEGER,
allowNull: true,
references: { model: "users", key: "id" },
onDelete: "SET NULL", // keep the cycle if a reviewer is removed
onUpdate: "CASCADE",
},
reviewed_at: { type: Sequelize.DATE, allowNull: true },
promoted_db: { type: Sequelize.STRING(255), allowNull: true },
promoted_at: { type: Sequelize.DATE, allowNull: true },
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
});

await queryInterface.addIndex("submissions", ["dataset_registry_id"], {
name: "idx_submissions_registry",
});
await queryInterface.addIndex("submissions", ["status"], {
name: "idx_submissions_status",
});

// At most one OPEN workflow per dataset. "Open" = still active:
// pending / changes_requested / approved (approved is awaiting promotion,
// so it must also block a new cycle). rejected/promoted are terminal, so a
// confirmed resubmit can insert a fresh pending row.
await queryInterface.sequelize.query(
"CREATE UNIQUE INDEX uq_one_open_per_dataset ON submissions (dataset_registry_id) WHERE status IN ('pending','changes_requested','approved');"
);
},

async down(queryInterface) {
await queryInterface.dropTable("submissions");
},
};
50 changes: 50 additions & 0 deletions backend/migrations/20260923192147-create-submission-comments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// Chronological reviewer <-> submitter discussion for a submission cycle,
// one row per comment. This is the single source of review discussion
// (there is no review_note column). Author role is derived at read time
// (user_id == dataset_registry.owner_user_id => submitter, else reviewer).
async up(queryInterface, Sequelize) {
await queryInterface.createTable("submission_comments", {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
allowNull: false,
},
submission_id: {
type: Sequelize.UUID,
allowNull: false,
references: { model: "submissions", key: "submission_id" },
onDelete: "CASCADE", // comments die with their cycle
onUpdate: "CASCADE",
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: { model: "users", key: "id" },
onDelete: "RESTRICT", // a comment must keep a real author
onUpdate: "CASCADE",
},
message: { type: Sequelize.TEXT, allowNull: false },
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal("CURRENT_TIMESTAMP"),
},
});

// Serves the read query: WHERE submission_id = ? ORDER BY created_at ASC.
await queryInterface.addIndex(
"submission_comments",
["submission_id", "created_at"],
{ name: "idx_comments_submission_created" }
);
},

async down(queryInterface) {
await queryInterface.dropTable("submission_comments");
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// At most one dataset may reserve a given preferred public id (first-come).
// Partial so the many NULL (no preference) rows don't collide. Backs up the
// controller's assertRequestedIdFree check against races.
async up(queryInterface) {
await queryInterface.sequelize.query(
"CREATE UNIQUE INDEX uq_requested_dataset_id ON dataset_registry (requested_dataset_id) WHERE requested_dataset_id IS NOT NULL;"
);
},

async down(queryInterface) {
await queryInterface.sequelize.query(
"DROP INDEX IF EXISTS uq_requested_dataset_id;"
);
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
// The public database a dataset should be published to at promotion.
// A preference/reservation; defaults to "public" when the user picks nothing.
// Actual db is resolved/created at promotion (a new name is review-gated).
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("dataset_registry", "requested_db", {
type: Sequelize.STRING(63),
allowNull: false,
defaultValue: "public",
});
},

async down(queryInterface) {
await queryInterface.removeColumn("dataset_registry", "requested_db");
}
};
Loading
Loading