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
20 changes: 20 additions & 0 deletions .changeset/mixcr-clonotyping-block-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@platforma-open/milaboratories.mixcr-clonotyping-2.kind': minor
'@platforma-open/milaboratories.mixcr-clonotyping-2.model': minor
'@platforma-open/milaboratories.mixcr-clonotyping-2': minor
---

Add the mandatory block kind and migrate the model to the new column access API

The block now declares a `kind/` package carrying its identity and its
init-params contract — the fields a project template supplies to seed a new
instance. The model consumes them in `init` and projects the same set back out
via `templateParams`, so export and apply are inverses. File-valued params are
narrowed to `index://` handles, since an `upload://` handle names an import
local to one machine and would not resolve after a template is applied
elsewhere.

Model column access moves off the removed/deprecated surface: `ColumnLazy` →
`DataColumn`, `resultPool.getSpecByRef` → `Column(ref).getSpec()`, and all three
`getPColumns()` call sites → `ColumnsCollection`, which resolves ids host-side
instead of materialising specs and data in the sandbox.
7 changes: 6 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
with:
app-name: 'Block: Mixcr Clonotyping 2'
app-name-slug: 'block-mixcr-clonotyping-2'
node-version: '20.x'
node-version: '22.x'
gha-runner-label: hz-ubuntu-dind
build-script-name: 'build:dev-local'
build-before-publish-script-name: 'build:release'
Expand All @@ -39,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 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: Mixcr Clonotyping 2 - Mark Stable'
node-version: '20.x'
node-version: '22.x'
npmrc-config: |
{
"registries": {
Expand Down
4 changes: 3 additions & 1 deletion block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,23 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"sources": "./src/index.ts",
"types": "./dist/index.d.ts",
"sources": "./src/index.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"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",
"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.mixcr-clonotyping-2.kind": "workspace:*",
"@platforma-open/milaboratories.mixcr-clonotyping-2.model": "workspace:*",
"@platforma-open/milaboratories.mixcr-clonotyping-2.ui": "workspace:*",
"@platforma-open/milaboratories.mixcr-clonotyping-2.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"]
}
39 changes: 39 additions & 0 deletions kind/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "@platforma-open/milaboratories.mixcr-clonotyping-2.kind",
"version": "1.0.0",
"private": true,
"description": "Block kind for the mixcr-clonotyping block",
"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": {
"@platforma-sdk/block-kind": "catalog:",
"@platforma-sdk/model": "catalog:",
"es-toolkit": "catalog:"
},
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-sdk/block-tools": "catalog:"
},
"peerDependencies": {
"@types/node": "*",
"typescript": "*"
}
}
22 changes: 22 additions & 0 deletions kind/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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 type {
BlockParams,
CloneClusteringMode,
Preset,
RunMode,
StopCodonReplacements,
StopCodonType,
} 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,
});
136 changes: 136 additions & 0 deletions kind/src/params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { assertParamsObject } from "@platforma-sdk/block-kind";
import {
isImportFileHandleIndex,
isPlRef,
type ImportFileHandle,
type ImportFileHandleIndex,
} from "@platforma-sdk/model";
import { isBoolean, isPlainObject, isString } from "es-toolkit";
import type {
BlockParams,
CloneClusteringMode,
Preset,
RunMode,
StopCodonReplacements,
StopCodonType,
} 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 };
}

/** `Number.isInteger` already rejects non-numbers; this only adds the narrowing. */
const isInteger: Guard<number> = (v): v is number => Number.isInteger(v);

function oneOf<T extends string>(...allowed: readonly T[]): Guard<T> {
return (v): v is T => allowed.includes(v as T);
}

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

/**
* `isImportFileHandleIndex` is a prefix test, so handing it a checked string is
* safe; the cast only gets the string past a signature that expects the union.
*/
const isIndexFileHandle: Guard<ImportFileHandleIndex> = (v): v is ImportFileHandleIndex =>
isString(v) && isImportFileHandleIndex(v as ImportFileHandle);

const STOP_CODON_TYPES = ["amber", "ochre", "opal"] as const;

const isPreset: Guard<Preset> = (v): v is Preset =>
isPlainObject(v) &&
(v.type === "name" ? isString(v.name) : v.type === "file" && isIndexFileHandle(v.file));

const isStopCodonReplacements: Guard<StopCodonReplacements> = (v): v is StopCodonReplacements =>
isPlainObject(v) && STOP_CODON_TYPES.every((k) => v[k] === undefined || isString(v[k]));

const REF = "a reference to another block's output";
const INDEX_HANDLE =
"an 'index://' file handle — an 'upload://' handle names a local import and does not resolve on another machine";

/**
* 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 = {
input: check(isPlRef, REF),
inputLibrary: check(isPlRef, REF),
libraryFile: check(isIndexFileHandle, INDEX_HANDLE),
isLibraryFileGzipped: check(isBoolean, "a boolean"),

preset: check(isPreset, "{ type: 'name', name } or { type: 'file', file }"),
presetCommonName: check(isString, "a string"),
isGenericPreset: check(isBoolean, "a boolean"),
species: check(isString, "a string"),
customSpecies: check(isString, "a string"),
materialType: check(isString, "a string"),
leftAlignmentMode: check(isString, "a string"),
rightAlignmentMode: check(isString, "a string"),
tagPattern: check(isString, "a string"),
assembleClonesBy: check(isString, "a string"),
imputeGermline: check(isBoolean, "a boolean"),
chains: check(arrayOf(isString), "an array of strings"),
scHeavyOnly: check(isBoolean, "a boolean"),
cloneClusteringMode: check(
oneOf<CloneClusteringMode>("relaxed", "default", "off"),
"one of: relaxed, default, off",
),
exportMinQuality: check(isBoolean, "a boolean"),
stopCodonTypes: check(
arrayOf(oneOf<StopCodonType>(...STOP_CODON_TYPES)),
"an array of: amber, ochre, opal",
),
stopCodonReplacements: check(
isStopCodonReplacements,
"an object of optional amber / ochre / opal strings",
),

runMode: check(oneOf<RunMode>("dry", "full"), "one of: dry, full"),
limitInput: check(isInteger, "an integer"),
perProcessMemGB: check(isInteger, "an integer"),
perProcessCPUs: check(isInteger, "an integer"),

defaultBlockLabel: check(isString, "a string"),
customBlockLabel: check(isString, "a string"),
title: check(isString, "a string"),
} satisfies { [K in keyof BlockParams]-?: Check<NonNullable<BlockParams[K]>> };
70 changes: 70 additions & 0 deletions kind/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { ImportFileHandleIndex, PlRef } from "@platforma-sdk/model";

/** MiXCR preset — either a named built-in or a preset file. */
export type Preset = { type: "name"; name: string } | { type: "file"; file: ImportFileHandleIndex };

export type StopCodonType = "amber" | "ochre" | "opal";

export type StopCodonReplacements = {
amber?: string;
ochre?: string;
opal?: string;
};

export type CloneClusteringMode = "relaxed" | "default" | "off";

/** Preview runs a read-limited pass; full runs the whole dataset. */
export type RunMode = "dry" | "full";

/**
* This block's init-params contract — the shape a block of this kind receives
* at creation, and exactly what a project template serializes for it.
*
* Every field is optional. A block with no input picked and no preset chosen is
* an ordinary state the UI reaches, so export has to be able to write it and
* apply has to be able to take it back; a contract that demanded `input` would
* make export and apply stop being inverses. Whether a configuration is
* runnable is settled by the model's `args` lambda, not here.
*
* File-valued fields are narrowed to `index://` handles. An `upload://` handle
* names an import local to one machine, so it cannot survive being written to a
* template and applied elsewhere; the projection drops those rather than
* writing a reference that resolves nowhere.
*/
export type BlockParams = {
// Input wiring — PlRefs a template engine fills from an earlier entry's output.
input?: PlRef;
inputLibrary?: PlRef;
libraryFile?: ImportFileHandleIndex;
isLibraryFileGzipped?: boolean;

// Analysis configuration — the recipe a template exists to reproduce.
preset?: Preset;
presetCommonName?: string;
isGenericPreset?: boolean;
species?: string;
customSpecies?: string;
materialType?: string;
leftAlignmentMode?: string;
rightAlignmentMode?: string;
tagPattern?: string;
assembleClonesBy?: string;
imputeGermline?: boolean;
chains?: string[];
scHeavyOnly?: boolean;
cloneClusteringMode?: CloneClusteringMode;
exportMinQuality?: boolean;
stopCodonTypes?: StopCodonType[];
stopCodonReplacements?: StopCodonReplacements;

// Run mode and per-process resource limits.
runMode?: RunMode;
limitInput?: number;
perProcessMemGB?: number;
perProcessCPUs?: number;

// Display naming.
defaultBlockLabel?: string;
customBlockLabel?: string;
title?: string;
};
10 changes: 10 additions & 0 deletions kind/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "@milaboratories/ts-configs/block/facade",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"resolveJsonModule": true
},
"include": ["src/**/*", "package.json"],
"exclude": ["dist", "node_modules"]
}
1 change: 1 addition & 0 deletions model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
},
"dependencies": {
"@milaboratories/helpers": "catalog:",
"@platforma-open/milaboratories.mixcr-clonotyping-2.kind": "workspace:*",
"@platforma-sdk/model": "catalog:",
"zod": "catalog:"
},
Expand Down
Loading
Loading