feat(svc-datasets): record dataset file write time and serve the full File model in v1 and v2 - #11
Merged
Merged
Conversation
added 6 commits
September 11, 2026 10:46
…l in both API versions
Foundry's `datasets_models.File` requires `updatedTime` in v1 and v2 alike, but
no file store recorded when a file record was written. v2 shipped the model
without it; the two equivalent v1 reads were left unserved rather than publish
the same incomplete shape twice. This fixes the root instead.
Both stores now record the write time on every `putFile`, including the
overwrite of an existing path:
- `FileStore` stamps `updatedTime` on the record it stores.
- `PgFileStore` writes `updated_at` and returns the value it wrote, so the
recorded time and the served time are the same value rather than two clocks.
Migration 010 adds `dataset_files.updated_at`, mirrored into `scripts/migrate.sql`
so the Compose-initialised database does not drift from `pnpm db:migrate`.
`PgFileStore.putFile` has always set `updated_at = NOW()` in its ON CONFLICT
clause against a column no schema created, so every overwrite of an existing
path failed with `42703 undefined_column`; the migration adds the column that
clause has always assumed.
Pre-existing rows take their own `created_at`, which is not a guess: because
that overwrite path could never succeed, no persisted row has ever been
rewritten, so the insert is the only successful write it ever had and
`created_at` is exactly its write time. The in-memory store persists nothing, so
it has no pre-existing records at all. No file is left unserveable.
With a write time available, both versions serve the model Foundry declares:
- v2 `serializeFile` emits `path`, `transactionRid`, `sizeBytes`, `updatedTime`.
The size was named `size`, which no generated client reads, and `contentType`
is not in the Foundry model - a field Foundry never sends is as much a wire
divergence as a missing one, so it is dropped from the body and stays where it
belongs, as the `content-type` header on the download.
- v1 serves `GET /datasets/{rid}/files` and `GET /datasets/{rid}/files/{filePath}`
through its own `toV1File`. The two versions declare identical fields here,
but agreeing today is not a contract, so the projection is not shared.
Every file route now awaits the store: `PgFileStore` is fully async, and an
unawaited read serves a pending promise as a 200 against Postgres while every
in-memory test passes.
The v1 file operations declare Foundry's branch and transaction scoping
parameters and refuse them with a 400. A record is keyed by
`${datasetRid}::${path}` with no branch or transaction dimension, so a scoped
read would answer out of the single copy while reporting it had honoured the
scope - and a scoped delete would remove that copy. `POST .../files:upload`
stays unserved: it places a file in a named transaction, which one-record-per-path
storage cannot represent.
Tests cover both stores, not just the in-memory one:
`pg-file-write-time.test.ts` asserts the SQL writes and reads the column on
insert and on overwrite, asserts both schema sources carry that column, asserts
the migration backfills from `created_at` rather than `NOW()`, and drives the v1
and v2 routes against an asynchronous store double. Each new assertion was
checked to fail when the behaviour it names is removed.
API scoreboard: 15.5% present / 12.1% shape-clean -> 15.8% / 12.3%.
GUIDE.md records the newly served operations and, under Batasan yang Diketahui,
the one remaining divergence this does not close: v2
`GET /datasets/{rid}/files/{filePath}` returns file content where Foundry returns
`File` metadata and serves content from `.../content`. That is an operation
identity, not a field shape, and moving it changes the download URL clients use
today.
…grate.sql comment
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.
Intent
Kapten mengembangkan OpenFoundry - emulator Palantir Foundry buatannya sendiri - menuju kesetaraan dengan Foundry, sebagai alat bantu belajar dan riset.
Temuan ini muncul saat menyajikan /api/v1. Kontrak Foundry menuntut File.updatedTime, tetapi tidak ada penyimpanan berkas di OpenFoundry yang mencatat waktu tulis. Akibatnya dua hal:
Jadi v1 menolak menyajikan bentuk yang salah sementara v2 tetap menyajikannya - ketidakkonsistenan yang hanya bisa ditutup dengan memperbaiki akarnya.
What Changed
FileStorestampsupdatedTimeon everyputFile, andPgFileStoreinserts and returnsupdated_at(new migrationdb/migrations/010_dataset_file_updated_at.sqlplusscripts/migrate.sqladd the column, backfilled fromcreated_at) - which also fixes overwrites of an existing path failing with42703 undefined_column./api/v2file routes now serve Foundry's completeFileshape (path,transactionRid,sizeBytes,updatedTime) instead of droppingupdatedTime, and the previously unserved v1 equivalents - list dataset files and read file metadata - are now implemented with their own v1 serializer.DatasetFileinpackages/api-types, the conjuredataset-service.ymldefinition, README/GUIDE/AGENTS notes, and the dataset lifecycle integration test are updated to the served shape, with a newservices/svc-datasets/tests/pg-file-write-time.test.tsasserting the Pg write SQL against both schema sources.Risk Assessment
✅ Low: The change fixes the root cause the intent names (no recorded write time), serves the identical complete File model in both versions, keeps the Pg store's SQL and both schema sources in agreement, and is covered by behavioral route and store tests with no in-repo consumer of the removed size/contentType fields.
Testing
Targeted unit, api-types and dataset-lifecycle integration suites all pass, and I backed them with two product-level demonstrations: a curl transcript against the running svc-datasets service showing v1 and v2 serving the identical complete
Filemodel withupdatedTime(and v1's file routes reachable at all, which they previously were not), and a real-Postgres run reproducing the42703 undefined_columnoverwrite failure before migration 010 and showing it fixed with the write time advancing after. No screenshot or rendered-UI artifact applies: the change is confined to HTTP API serialization, a file store and a SQL migration, with no console or other rendered surface touched, so the HTTP response bodies and persisted database rows are the end-user surface.Evidence: v1 + v2 file API transcript against the running service
Evidence: Postgres: 42703 overwrite failure before migration 010, write time recorded after
Evidence: v1 file routes now served with the full File model (excerpt)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
services/svc-datasets/tests/pg-file-write-time.test.ts:121- The schema-source test asserts only a loose regex over raw SQL file text (/dataset_files[\s\S]*updated_at/), which is the source-content-only anti-pattern and is additionally too weak to prove anything:[\s\S]*spans the whole file, so it would pass ifupdated_atbelonged to any later, unrelated table, or if it appeared only inside a comment. Replace with an assertion that has semantic meaning (e.g. apply both schema sources to a real/parsed schema and assertdataset_fileshas anupdated_atcolumn), or drop these two cases and rely on the SQL-statement and route-level behavior tests.services/svc-datasets/src/store/pg-file-store.ts:26-toIsoInstantthrows a RangeError (a 500) ifupdated_atever comes back unparseable; a stricter guard or an explicit error would degrade more clearly. Low likelihood given the column is NOT NULL TIMESTAMPTZ.services/svc-datasets/src/routes/v2/files.ts:49- The v2 File payload now dropssizeandcontentType(renamed/removed), which is a wire-shape breaking change for any existing v2 client. Nothing in this repo consumes those fields and the change is explicitly justified by the Foundry model in AGENTS.md/GUIDE.md, so noting only.🔧 Fix: assert parsed schema model instead of raw SQL regex
3 issues (1 error, 2 warnings) still open:
tests/integration/dataset-lifecycle.test.ts:99- The v2 upload response no longer carriescontentTypeorsize(serializeFile now emitspath/transactionRid/sizeBytes/updatedTime), but this integration test still assertsbody.contentType === "text/csv"andbody.size === byteLength. It will fail onpnpm run test:integration, which is a separate command from the unit suite and so was not covered by the unit tests added in this change. Update the assertions to the new File shape (sizeBytes, plus a parseableupdatedTime, and dropcontentType).packages/api-types/src/datasets.ts:86-DatasetFilein the shared api-types package still declares{ path, size, contentType, transactionRid }, which is now a shape no route emits. Any consumer typed against it readssize/contentTypeas undefined at runtime with no type error, andListFilesResponseinherits the same stale shape. Align it with the served model (path,transactionRid,sizeBytes,updatedTime) and update the type-level assertion at packages/api-types/tests/api-types.test.ts:205.scripts/migrate.sql:412- The new comment claims the ALTER statements added here also fix "an existing database created beforeupdated_atexisted". They cannot: docker-compose.yml mounts this file into /docker-entrypoint-initdb.d, which Postgres executes only when the data directory is empty, so an existing Compose volume never runs these statements and itsdataset_filesstill lacks the column (the 42703 overwrite failure remains reachable there untilpnpm db:migrateis run). The statements themselves are harmless and idempotent on a fresh init; the comment is what is wrong - correct it to say the ALTERs keep this file in step with db/migrations/010 and that an already-initialised volume must be migrated withpnpm db:migrate.🔧 Fix: align DatasetFile type, integration test, and migrate.sql comment
2 issues (1 warning, 1 info) still open:
conjure/dataset-service.yml:83-DatasetFilein the conjure IDL still declares{path, size: integer, contentType: string, transactionRid}and has noupdatedTime. This file is the declared source of the shape that was changed:packages/api-types/src/datasets.tsopens with "Derived from conjure/dataset-service.yml", andpackages/sdk/sdk-cli/src/commands/generate.tscompilesconjure/into generated SDK clients. The fix round updated the derivative (api-types) but not the source, so the two now disagree, and anyone runningsdk-cli generateproduces a client whoseDatasetFile.size/.contentTypeare always undefined at runtime and which cannot seeupdatedTimeat all - the exact class of silent wrong-shape defect this change set out to close. Update theDatasetFiledefinition (and itsdocs) topath,transactionRid,sizeBytes: integer,updatedTime: datetimeto match what both API versions now serve.services/svc-datasets/src/routes/v1/files.ts:58-rejectUnservableScopingtreats an empty-string value (?branchId=) as absent and silently serves the unscoped answer, where a non-empty value is a 400. That is a defensible reading (an empty scope asks for nothing), and the Foundry clients dropNoneparameters rather than sending empties, so no real caller hits it. Noting only.🔧 Fix: update conjure DatasetFile to the served File shape
2 infos still open:
scripts/migrate.sql:412- The fourALTER/UPDATEstatements added afterCREATE TABLE IF NOT EXISTS dataset_filesare inert on every path they can actually execute: the CREATE above already declaresupdated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), and the file's own (now correct) comment states Postgres runs /docker-entrypoint-initdb.d only on an empty data directory, so an existing volume never reaches them. They are harmless but permanently dead, andservices/svc-datasets/tests/pg-file-write-time.test.tsnow asserts the compose source contains exactly oneupdated_atbackfill, which pins them in place. Removing them (and relaxing that assertion to the migrations source only) would be simpler; keeping them as deliberate drift-parity with db/migrations/010 is also defensible, which is why this is the author's call rather than an auto-fix.services/svc-datasets/src/routes/v2/files.ts:49- Re-confirming after the fix rounds: v2 upload/list/metadata responses now emitsizeBytes/updatedTimeand no longer emitsize/contentType. I grepped apps/, packages/ and services/ - no in-repo consumer reads either field, and conjure, api-types and both route layers now agree field for field, so the rename is consistent end to end. Noted only (the equivalent finding was already reviewed and accepted in round 1).🔧 Fix: drop unreachable updated_at ALTERs from compose schema
2 infos still open:
services/svc-datasets/tests/pg-file-write-time.test.ts:227- The overwrite guard asserts the emitted SQL text matches /DO UPDATE SET[\s\S]*updated_at = NOW()/ against a FakePool. It is an assertion about the statement OpenFoundry sends (a serialized protocol, so not the source-grep anti-pattern), but it cannot show that a second putFile to the same path actually advances updatedTime on Postgres - the canned row is fixed. The behavioral proof exists only for the in-memory store (v1-api.test.ts 'moves the write time when an existing path is overwritten'). Acceptable without a live Postgres in unit tests; noting the residual gap.services/svc-datasets/src/routes/v1/files.ts:150- rejectUnservableScoping runs before datasetStore.getDataset, so a request for a nonexistent dataset carrying ?branchId=x answers 400 InvalidArgument rather than 404. Foundry validates arguments before resource existence too, so this ordering is defensible and no caller is misled; noting only.services/svc-datasets/src/store/pg-file-store.ts- Against Postgres,PgFileStore.getFilereturnssizeas a string ("11"), becausedataset_files.sizeisbigintand node-pg parses bigint to string. BothtoV1FileandserializeFilepass it straight through, so in DATABASE_URL mode a read of file metadata emits"sizeBytes": "11"instead of a number, diverging from the Foundry model this change is otherwise aligning. Pre-existing (the oldsizefield had the same problem) and orthogonal toupdatedTime, so not fixed here; noting only. Observed in the pg evidence transcript:getFile -> {"size":"11"}where the putFile return is{"size":11}.npx vitest run --root services/svc-datasets(69 tests: v1-api, datasets-service, pg-file-write-time, list-files-query-params)npx vitest run --root packages/api-typesnpx vitest run --config tests/vitest.config.ts tests/integration/dataset-lifecycle.test.tsManual E2E: started the real service (node services/svc-datasets/dist/index.js, PORT=8385) and drove v1/v2 file upload, list, metadata, download-header and overwrite via curlManual E2E: Postgres 16 in docker, applied db/migrations/001-009, ran PgFileStore putFile/putFile/getFile (reproduced42703), applieddb/migrations/010_dataset_file_updated_at.sql, re-ran, then querieddataset_filesfor created_at vs updated_at✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.