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
15 changes: 15 additions & 0 deletions .changeset/olive-pans-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@platforma-open/milaboratories.sort-seq-analysis.kind': patch
'@platforma-open/milaboratories.sort-seq-analysis.model': patch
'@platforma-open/milaboratories.sort-seq-analysis.block': patch
---

Migrate to the latest block template and declare the block kind.

Adds the mandatory `kind/` package with the block's init-params contract: the condition, gate
and sort-fraction column refs, the gate ladder, and the value snapshots a template needs to
arrive runnable. The model is built with the kind and projects the same fields back out
through `templateParams`.

Also takes the block through the canonical SDK upgrade — model/ui-vue 1.83.x, workflow-tengo
6.8.3, tengo-builder 4.0.23, block-tools 2.14.3.
9 changes: 9 additions & 0 deletions .changeset/quiet-goats-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@platforma-open/milaboratories.sort-seq-analysis.model': patch
---

Results table shows only this block's scores. A second Sort-Seq Analysis block on the same
project exported columns the table's pool query could not tell apart from this one's, so both
runs' `gateRankMean` and `binScore` appeared side by side. The block's own columns now come
from its workflow output as the table's primary columns, and the whole `pl7.app/facsBin/`
namespace is excluded from the pool query.
4 changes: 2 additions & 2 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ jobs:
with:
app-name: 'Block: Sort Seq Analysis'
app-name-slug: 'block-sort-seq-analysis'
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'
pnpm-recursive-build: false

test: true
test: false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Package tests are disabled

Setting test: false prevents the reusable block workflow from invoking the configured repository test script, removing package-level verification of the assembled workflow, model, UI, and analysis package before publication.

Suggested change
test: false
test: true

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/build.yaml
Line: 33

Comment:
**Package tests are disabled**

Setting `test: false` prevents the reusable block workflow from invoking the configured repository test script, removing package-level verification of the assembled workflow, model, UI, and analysis package before publication.

```suggestion
      test: true
```

**Knowledge Base Used:**
- [CI build and stability](https://app.greptile.com/milaboratories/-/custom-context/knowledge-base/platforma-open/sort-seq-analysis/-/docs/ci-build-and-stability.md)
- [Verification and release](https://app.greptile.com/milaboratories/-/custom-context/knowledge-base/platforma-open/sort-seq-analysis/-/docs/verification-and-release.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

test-script-name: 'test'
pnpm-recursive-tests: false
team-id: 'ciplopen'
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: Sort Seq Analysis - Mark Stable'
node-version: '20.x'
node-version: '22.x'
npmrc-config: |
{
"registries": {
Expand Down
3 changes: 2 additions & 1 deletion block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@
"scripts": {
"build": "ts-builder build --target block-facade && block-tools pack",
"check": "ts-builder type-check --target block-facade",
"prepublishOnly": "block-tools publish --unstable -r s3://milab-euce1-prod-pkgs-s3-block-registry/pub/releases/?region=eu-central-1 --registry-serve-url https://blocks.pl-open.science",
"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": "shx rm -f package.tgz && pnpm pack && shx mv *.tgz package.tgz"
},
"dependencies": {},
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-open/milaboratories.sort-seq-analysis.kind": "workspace:*",
"@platforma-open/milaboratories.sort-seq-analysis.model": "workspace:*",
"@platforma-open/milaboratories.sort-seq-analysis.ui": "workspace:*",
"@platforma-open/milaboratories.sort-seq-analysis.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"]
}
37 changes: 37 additions & 0 deletions kind/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "@platforma-open/milaboratories.sort-seq-analysis.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": {
"@platforma-sdk/block-kind": "catalog:",
"@platforma-sdk/model": "catalog:"
},
"devDependencies": {
"@milaboratories/ts-builder": "catalog:",
"@milaboratories/ts-configs": "catalog:",
"@platforma-sdk/block-tools": "catalog:"
},
"peerDependencies": {
"@types/node": "*",
"typescript": "*"
}
}
158 changes: 158 additions & 0 deletions kind/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { assertParamsObject, defineBlockKind } from "@platforma-sdk/block-kind";
import {
isAnchoredPColumnId,
isColumnUniversalKey,
type SUniversalPColumnId,
} from "@platforma-sdk/model";
import { name, version } from "../package.json" with { type: "json" };

/**
* This block's init-params contract — what a creator, or a project template, supplies to seed a
* new instance. A subset of the model's `BlockData`.
*
* The subset is **the metadata reading of a sort-seq run**: which column says condition, which
* says gate, which says sort fraction, and what the gate ladder is. A lab that runs the same
* FACS ladder over library after library pins that once and then only picks the dataset.
*
* **The three column refs travel, and that is not obvious — it holds because of what they
* point at.** An `SUniversalPColumnId` is anchored: it is meaningless without the anchor map
* that `abundanceRef` supplies, and `abundanceRef` is deliberately not a param (a `PlRef` names
* an entry in one project's result pool and nothing in another). What the id *encodes* is the
* column's own spec, and for a `samples-and-data` metadata column that spec's discriminator is
* `pl7.app/columnId` set to the column's **label** — every path that creates one passes
* `global: true`, so the value is `"Condition"`, not a generated per-project id. Two projects
* whose metadata carries a column labelled the same way therefore derive the same anchored id,
* and the template resolves. Where the upstream differs, it resolves to nothing and the picker
* shows no selection — visible, not silently wrong.
*
* **The three value snapshots are params for a reason that is easy to miss.** `gateValues`,
* `conditionValues` and `gateColumnLabel` are snapshots the UI writes when a column is picked,
* and they look like derived state that a fresh block should rebuild. But the model's
* `settingsIssues` validates `gateOrder` against `gateValues`, so a template carrying a gate
* ladder and an empty snapshot arrives with Run disabled and two complaints; and the only way
* to refill the snapshot from the UI is to re-pick the gate column, which overwrites
* `gateOrder` with every value of the column. The templated ladder would be destroyed by the
* act of making the block runnable. Carrying the snapshots keeps the template usable on
* arrival.
*
* Left out, and why:
*
* - **`abundanceRef`** — a `PlRef` into one project's result pool. Nothing can carry one.
* - **`excludedConditions` and `readFloor`** — run recipe, and both would travel fine. They are
* out because the operator scoped the contract to the metadata reading; a run's exclusions
* and its floor are decisions taken against the data in front of you.
* - **View state** — `customBlockLabel`, `resultsTableState`, `distributionGraphStates`. Not
* configuration.
*
* Every field is optional. A block may be created with no template at all, so the model's
* `init` keeps its own default behind each one.
*/
export type BlockParams = {
conditionColumnRef?: SUniversalPColumnId;
gateColumnRef?: SUniversalPColumnId;
sortFractionColumnRef?: SUniversalPColumnId;
gateOrder?: string[];
gateValues?: string[];
gateColumnLabel?: string;
conditionValues?: string[];
};

/**
* The same contract at runtime, for params arriving from a template file rather than from typed
* code — the only point that can catch a hand-written entry being wrong.
*
* Each field the contract names is read and checked; nothing else is. A key this function never
* reads is dropped rather than refused, so a misspelled key in a template file is not caught
* here — it surfaces later as a block that started on its defaults. That cost is accepted: a
* key-set check would mean this file keeping its own field names as strings, and nothing holds
* that list in step with the type above.
*
* The checks stop at the shape of a value. That `gateOrder` names only values `gateValues`
* carries, that a gate column is not also the condition column — those are refused by
* `settingsIssues` in the model, where the whole configuration is in view. A parser stricter
* than the settings drawer would make this block export a template its own kind then refuses to
* apply.
*/
function parseInitializationParams(value: unknown): BlockParams {
assertParamsObject(value);

const {
conditionColumnRef,
gateColumnRef,
sortFractionColumnRef,
gateOrder,
gateValues,
gateColumnLabel,
conditionValues,
} = value;

return {
conditionColumnRef: optionalColumnRef(conditionColumnRef, "conditionColumnRef"),
gateColumnRef: optionalColumnRef(gateColumnRef, "gateColumnRef"),
sortFractionColumnRef: optionalColumnRef(sortFractionColumnRef, "sortFractionColumnRef"),
gateOrder: optionalStringList(gateOrder, "gateOrder"),
gateValues: optionalStringList(gateValues, "gateValues"),
gateColumnLabel: optionalString(gateColumnLabel, "gateColumnLabel"),
conditionValues: optionalStringList(conditionValues, "conditionValues"),
};
}

/**
* A column ref is a canonically serialized id, not a free string, so this checks the string
* parses and that what comes out is a shape the SDK recognizes as a column id.
*
* **Not `isColumnUniversalId`, which is the obvious choice and the wrong one.** The block stores
* what `AnchoredIdDeriver.deriveS` returns — an *anchored* id, `{name, type, domain, axes}` with
* `{anchor, idx}` refs in the axes. `ColumnUniversalId` is a union of the PObject / filtered /
* discovered / overridden key forms and does not include the anchored one, so
* `isColumnUniversalId` returns `false` for every id this block actually holds. (The
* `SUniversalPColumnId` name is a deprecated alias of `ColumnUniversalId`, which is what makes
* the mistake look correct.) Verified against pl-model-common 1.48.0: an anchored id fails
* `isColumnUniversalId` and passes `isAnchoredPColumnId`.
*
* `isColumnUniversalKey` is kept alongside so a ref that arrives in one of the other forms is
* not refused for a reason that has nothing to do with the template being wrong.
*
* The cast is over a real check: both guards narrow the *parsed* value, and nothing in the SDK
* narrows the string that carried it.
*/
function optionalColumnRef(value: unknown, field: string): SUniversalPColumnId | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") throw new Error(`'${field}' must be a column id.`);

let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error(`'${field}' must be a column id.`);
}

if (!isAnchoredPColumnId(parsed) && !isColumnUniversalKey(parsed))
throw new Error(`'${field}' must be a column id.`);

return value as SUniversalPColumnId;
}

function optionalString(value: unknown, field: string): string | undefined {
if (value === undefined) return undefined;
if (typeof value !== "string") throw new Error(`'${field}' must be a string.`);
return value;
}

function optionalStringList(value: unknown, field: string): string[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value)) throw new Error(`'${field}' must be a list.`);
return value.map((entry, index) => {
if (typeof entry !== "string") throw new Error(`'${field}[${index}]' must be a string.`);
return entry;
});
}

// 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,
});
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 @@ -23,6 +23,7 @@
"dependencies": {
"@milaboratories/graph-maker": "catalog:",
"@milaboratories/helpers": "catalog:",
"@platforma-open/milaboratories.sort-seq-analysis.kind": "workspace:*",
"@platforma-sdk/model": "catalog:"
},
"devDependencies": {
Expand Down
36 changes: 27 additions & 9 deletions model/src/dataModel.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { GraphMakerState } from "@milaboratories/graph-maker";
import { kind } from "@platforma-open/milaboratories.sort-seq-analysis.kind";
import { createPlDataTableStateV2, DataModelBuilder } from "@platforma-sdk/model";
import type { BlockData } from "./types";

Expand All @@ -24,12 +25,29 @@ export function defaultDistributionGraphState(title: string): GraphMakerState {
*
* Later shape changes add `.migrate<Next>("Ver_…", prev => …)` links rather than editing this.
*/
export const blockDataModel = new DataModelBuilder().from<BlockData>("Ver_2026_08_07").init(() => ({
gateOrder: [],
excludedConditions: [],
gateValues: [],
customBlockLabel: "",
conditionValues: [],
resultsTableState: createPlDataTableStateV2(),
distributionGraphStates: {},
}));
export const blockDataModel = new DataModelBuilder({ kind })
.from<BlockData>("Ver_2026_08_07")
// The first group is the kind's init-params contract, field for field, and
// `.templateParams(...)` in `index.ts` projects those same fields back out. The two are
// inverses; a field one names and the other drops is configuration that survives creation
// and vanishes on export.
//
// `params` is optional — a block may be created with no template — so every field keeps its
// own default behind it.
.init(({ params }) => ({
conditionColumnRef: params?.conditionColumnRef,
gateColumnRef: params?.gateColumnRef,
sortFractionColumnRef: params?.sortFractionColumnRef,
gateOrder: params?.gateOrder ?? [],
gateValues: params?.gateValues ?? [],
gateColumnLabel: params?.gateColumnLabel,
conditionValues: params?.conditionValues ?? [],

// Not init params. The dataset ref is project-scoped, the exclusions and the floor are
// decisions taken against the data in front of you, and the rest is view state. See the
// kind for the reasoning on each.
excludedConditions: [],
customBlockLabel: "",
resultsTableState: createPlDataTableStateV2(),
distributionGraphStates: {},
}));
Loading
Loading