Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions .changeset/soft-hoops-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@platforma-open/milaboratories.samples-and-data.parse-h5ad': patch
'@platforma-open/milaboratories.samples-and-data.parse-seurat': patch
'@platforma-open/milaboratories.samples-and-data.model': minor
'@platforma-open/milaboratories.samples-and-data': minor
---

Migrate to structure v2 and declare a block kind

The SDK upgrade moves the block onto the current canonical layout (block-tools
2.14.0, model/ui-vue 1.82.x, workflow-tengo 6.8.2, tengo-builder 4.0.23) and
switches the software packages from `pl-pkg` to `block-tools software build`.

The block now declares a kind, whose init-params contract is the study setup a
project template supplies — the metadata columns the study collects, the name of
the sample column, and how its datasets are configured — plus whichever of its
data can be resolved from where the block lands.

A dataset travels whole when every one of its files is a storage reference
(`index://` names a storage and a path); a dataset holding any local upload is
reduced to its configuration with the file slots unset, because an upload handle
carries a machine-local path signed with the installation's own secret. The
decision is per dataset rather than per file so that a grouped dataset can never
arrive with fewer sample groups than file groups, which is the invariant `args`
enforces. Samples, their labels and their metadata values follow the datasets
that reference them, so a sample never arrives without the files that created
it.

The dataset and metadata types move into the kind package and are re-exported
from the model, so every existing import keeps resolving. The model also exports
`templateParamsFor`, the projection itself.
22 changes: 19 additions & 3 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ on:
workflow_dispatch: {}
jobs:
init:
runs-on: ubuntu-latest
runs-on: hz-ubuntu-dind
steps:
- uses: milaboratory/github-ci/actions/context/init@v4
with:
Expand All @@ -24,8 +24,10 @@ jobs:
with:
app-name: 'Block: Samples And Data'
app-name-slug: 'block-samples-and-data'
node-version: '20.x'
build-script-name: 'build'
node-version: '22.x'
gha-runner-label: hz-ubuntu-dind
build-script-name: 'build:dev-local'
build-before-publish-script-name: 'build:release'
pnpm-recursive-build: false

test: true
Expand All @@ -37,6 +39,11 @@ jobs:
package-path: 'block'
create-tag: 'true'

# Require the published `block` package to be bumped by a changeset on
# PRs (empty changeset or the `skip-changelog` label waives it). Needs
# the input to exist on the pinned `@v4` reusable workflow.
require-package-path-bump: true

npmrc-config: |
{
"registries": {
Expand All @@ -56,6 +63,15 @@ jobs:

"AWS_CI_IAM_MONOREPO_SIMPLE_ROLE": ${{ toJSON(secrets.AWS_CI_IAM_MONOREPO_SIMPLE_ROLE) }},
"AWS_CI_TURBOREPO_S3_BUCKET": ${{ toJSON(secrets.AWS_CI_TURBOREPO_S3_BUCKET) }},

"HZ_CI_TURBO_S3_BUCKET": ${{ toJSON(vars.HZ_CI_TURBO_S3_BUCKET) }},
"HZ_CI_TURBO_S3_ENDPOINT": ${{ toJSON(vars.HZ_CI_TURBO_S3_ENDPOINT) }},
"HZ_CI_TURBO_S3_REGION": ${{ toJSON(vars.HZ_CI_TURBO_S3_REGION) }},
"HZ_CI_TURBO_S3_ACCESS_KEY": ${{ toJSON(secrets.HZ_CI_TURBO_S3_ACCESS_KEY) }},
"HZ_CI_TURBO_S3_SECRET_KEY": ${{ toJSON(secrets.HZ_CI_TURBO_S3_SECRET_KEY) }},
"HZ_CI_CACHE_S3_ACCESS_KEY": ${{ toJSON(secrets.HZ_CI_CACHE_S3_ACCESS_KEY) }},
"HZ_CI_CACHE_S3_SECRET_KEY": ${{ toJSON(secrets.HZ_CI_CACHE_S3_SECRET_KEY) }},

"PL_REGISTRY_PLATFORMA_OPEN_UPLOAD_URL": ${{ toJSON(secrets.PL_REGISTRY_PLOPEN_UPLOAD_URL) }},
"QUAY_USERNAME": ${{ toJSON(secrets.QUAY_USERNAME) }},
"QUAY_ROBOT_TOKEN": ${{ toJSON(secrets.QUAY_ROBOT_TOKEN) }} }
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/mark-stable.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
uses: milaboratory/github-ci/.github/workflows/block-mark-stable.yaml@v4
with:
app-name: 'Block: Samples And Data - Mark Stable'
node-version: '20.x'
node-version: '22.x'
npmrc-config: |
{
"registries": {
Expand Down
2 changes: 1 addition & 1 deletion .structure
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":1}
{"version":2}
6 changes: 4 additions & 2 deletions block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,24 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"sources": "./src/index.ts",
"types": "./dist/index.d.ts",
"sources": "./src/index.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"pretty": "prettier --write \"./**/*.{js,jsx,mjs,cjs,ts,tsx,json,vue}\"",
"build": "ts-builder build --target block-facade && block-tools pack",
"prepublishOnly": "block-tools publish -r s3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1 --registry-serve-url https://blocks.pl-open.science",
"do-pack": "pnpm pack && shx mv *.tgz package.tgz",
"mark-stable": "block-tools mark-stable -r 's3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1'",
"do-pack": "shx rm -f package.tgz && pnpm pack && shx mv *.tgz package.tgz",
"check": "ts-builder type-check --target block-facade"
},
"dependencies": {},
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-open/milaboratories.samples-and-data.kind": "workspace:*",
"@platforma-open/milaboratories.samples-and-data.model": "workspace:*",
"@platforma-open/milaboratories.samples-and-data.ui": "workspace:*",
"@platforma-open/milaboratories.samples-and-data.workflow": "workspace:*",
Expand Down
4 changes: 4 additions & 0 deletions kind/.oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": ["node_modules/@milaboratories/ts-builder/configs/oxfmt.json"],
"ignorePatterns": ["dist", "coverage", "CHANGELOG.md"]
}
3 changes: 3 additions & 0 deletions kind/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-node.json"]
}
38 changes: 38 additions & 0 deletions kind/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@platforma-open/milaboratories.samples-and-data.kind",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"sources": "./src/index.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"default": "./dist/index.js"
}
},
"scripts": {
"fmt": "ts-builder format",
"watch": "ts-builder build --target block-kind --watch",
"build": "ts-builder build --target block-kind && block-tools build-kind-manifest",
"check": "ts-builder check --target block-kind"
},
"dependencies": {
"@milaboratories/pl-model-common": "catalog:",
"@platforma-sdk/block-kind": "catalog:",
"es-toolkit": "catalog:"
},
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-sdk/block-tools": "catalog:"
},
"peerDependencies": {
"@types/node": "*",
"typescript": "*"
}
}
15 changes: 15 additions & 0 deletions kind/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineBlockKind } from "@platforma-sdk/block-kind";
import { name, version } from "../package.json" with { type: "json" };
import { parseInitializationParams } from "./params";
import type { BlockParams } from "./types";

export * from "./types";

// Identity (`name`/`version`) comes from this package's own `package.json`, so
// the on-wire `{name}@{version}` reference can never drift from what npm
// publishes; the bundler inlines the JSON import.
export const kind = defineBlockKind<BlockParams>({
name,
version,
parseInitializationParams,
});
169 changes: 169 additions & 0 deletions kind/src/params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { assertParamsObject } from "@platforma-sdk/block-kind";
import { isImportFileHandleIndex, isImportFileHandleUpload } from "@milaboratories/pl-model-common";
import { isBoolean, isPlainObject, isString } from "es-toolkit";
import { isArray } from "es-toolkit/compat";
import type {
BlockParams,
DSAny,
DSType,
ImportFileHandle,
MTColumn,
MTValueType,
PlId,
} from "./types";

/**
* The contract at runtime, for params that arrive from a template file rather
* than from typed code.
*
* Each field the contract names is read and checked; a key it does not name is
* dropped by never being read, so it needs no rejection here. Params written
* against a different version of the contract are caught by the version in the
* template entry's `{name}@{selector}` reference, not by a key-set check.
*/
export function parseInitializationParams(value: unknown): BlockParams {
assertParamsObject(value);

const params: Record<string, unknown> = {};
for (const [field, { is, must }] of Object.entries(CONTRACT)) {
const raw = value[field];
if (raw === undefined) continue;
if (!is(raw)) throw new Error(`'${field}' must be ${must}.`);
params[field] = raw;
}
// Every value placed here passed its own field's guard, and `CONTRACT` is
// proven exhaustive over `BlockParams` by the `satisfies` below.
return params as BlockParams;
}

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

type Guard<T> = (value: unknown) => value is T;

/** A guard plus how to finish the sentence "'field' must be …". */
type Check<T> = { readonly is: Guard<T>; readonly must: string };

function check<T>(is: Guard<T>, must: string): Check<T> {
return { is, must };
}

function arrayOf<T>(item: Guard<T>): Guard<T[]> {
return (v): v is T[] => isArray(v) && v.every((e) => item(e));
}

function recordOf<T>(item: Guard<T>): Guard<Record<string, T>> {
return (v): v is Record<string, T> => isPlainObject(v) && Object.values(v).every((e) => item(e));
}

/**
* A guard over the keys of one of the tag sets below, so the runtime check and
* the type it guards are both read off the same object.
*/
function keyOf<T extends string>(set: Record<T, true>): Guard<T> {
return (v): v is T => isString(v) && v in set;
}

function keyList(set: Record<string, true>): string {
return Object.keys(set).join(", ");
}

/**
* The value types a metadata column may declare, as a runtime set. `satisfies
* Record<MTValueType, true>` is what keeps it in step: a value type added to the
* union and forgotten here fails to compile, rather than turning into a kind
* that refuses a correct file.
*/
const MT_VALUE_TYPES = {
Long: true,
Double: true,
String: true,
} as const satisfies Record<MTValueType, true>;

/** The dataset kinds this block knows, kept in step with the union the same way. */
const DATASET_TYPES = {
Fastq: true,
MultilaneFastq: true,
TaggedFastq: true,
Fasta: true,
Xsv: true,
TaggedXsv: true,
BulkCountMatrix: true,
CellRangerMTX: true,
MultiplexedFastq: true,
H5AD: true,
H5: true,
Seurat: true,
MultiSampleH5AD: true,
MultiSampleSeurat: true,
} as const satisfies Record<DSType, true>;

/**
* A `PlId` is a branded 24-char base32 string, but the brand is erased at
* runtime and the block treats these ids as opaque keys, so the envelope check
* is the string test.
*/
const isPlId = isString as Guard<PlId>;

/**
* Both handle forms are accepted. The projection only sends `index://` handles,
* but `upload://` is a legitimate value of the type, and the two SDK guards are
* prefix tests — the cast only gets a checked string past a signature that
* expects the union.
*/
const isFileHandle: Guard<ImportFileHandle> = (v): v is ImportFileHandle =>
isString(v) &&
(isImportFileHandleIndex(v as ImportFileHandle) ||
isImportFileHandleUpload(v as ImportFileHandle));

/**
* A metadata column at the envelope: its identity, its declared value type, and
* nothing about the values. What the values mean is settled where they are used.
*/
const isMetadataColumn: Guard<MTColumn> = (v): v is MTColumn =>
isPlainObject(v) &&
isString(v.id) &&
isString(v.label) &&
isBoolean(v.global) &&
keyOf(MT_VALUE_TYPES)(v.valueType);

/**
* A dataset at the envelope: its identity and the discriminator of its content.
*
* The file handles inside are deliberately not walked. Their shape differs per
* dataset kind, and whether a handle still resolves is knowable only where the
* block runs, not here — the same limit every kind has on a handle. Likewise
* the per-sample and per-group maps: the block's own `args` is what holds
* datasets, groups and rules to each other, and rejecting a half-configured
* study here would refuse states the editor can reach.
*/
const isDataset: Guard<DSAny> = (v): v is DSAny =>
isPlainObject(v) &&
isString(v.id) &&
isString(v.label) &&
isPlainObject(v.content) &&
keyOf(DATASET_TYPES)(v.content.type) &&
isBoolean(v.content.gzipped);

/**
* The contract, field by field, at runtime.
*
* The `satisfies` clause is the drift guard: it demands an entry for every key
* `BlockParams` declares, and types each guard against that key's own type. Add
* a field to the contract and this stops compiling until the check exists —
* which matters here because every field is optional, so a parser that simply
* forgot one would otherwise return a valid `BlockParams` and say nothing.
*/
const CONTRACT = {
datasets: check(arrayOf(isDataset), `an array of datasets of: ${keyList(DATASET_TYPES)}`),
metadata: check(
arrayOf(isMetadataColumn),
`an array of metadata columns valued: ${keyList(MT_VALUE_TYPES)}`,
),
sampleIds: check(arrayOf(isPlId), "an array of sample ids"),
sampleLabelColumnLabel: check(isString, "a string"),
sampleLabels: check(recordOf(isString), "an object of sample id to label"),
h5adFilesToPreprocess: check(arrayOf(isFileHandle), "an array of file handles"),
seuratFilesToPreprocess: check(arrayOf(isFileHandle), "an array of file handles"),
} satisfies { [K in keyof BlockParams]-?: Check<NonNullable<BlockParams[K]>> };
Loading
Loading