Bulk operations¶
Batch write operations — deleting studies, analyses or recordings, and editing study tags in bulk — address the rows they touch through a selector. A selector uses exactly one of three addressing modes:
| Mode | Meaning | Safety |
|---|---|---|
ids |
An explicit list of ids (up to 500) — a closed set. | The list itself is the confirmation. |
filter |
A predicate the server resolves (e.g. by tag or name). | Must state expected_count. |
all |
The literal whole scope (every study in the project). | Must state expected_count. |
Two rules keep bulk operations safe by construction:
- An empty filter is invalid. "Everything" is only ever the explicit
all: true— a near-empty request body can never escalate into a whole-project operation. - Live sets state their count.
filterandallresolve at the server; the request must carryexpected_count— the total you last saw — and is rejected with a conflict error if the set has changed since. The count comes from the filtered listing itself (below).
Previewing what a filter resolves to¶
The same filter vocabulary is available as query parameters on the list endpoints, so the preview of a selector is the filtered listing:
# Which studies would {tags_any: ["pilot"]} address?
studies = await client.studies.list(project_id, tags_any=["pilot"])
client.studies.count(project_id, tags_any=["pilot"]) returns how many
match without fetching them. That total is the expected_count a
destructive call must present.
Deleting by selector¶
Deletion resolves the set, guards the count, enqueues a background cascade, and returns immediately. List endpoints hide the affected rows while the run is active.
from yousleep_common.models import (
DeleteStudiesRequest,
StudyFilter,
StudySelector,
)
# Delete every study tagged "pilot" (previewed as 12).
accepted = await client.batch.delete_studies(
project_id,
DeleteStudiesRequest(
selector=StudySelector(filter=StudyFilter(tags_any=["pilot"])),
expected_count=12,
),
)
print(f"{accepted.count} studies scheduled for deletion")
# Delete EVERY study in the project — the deliberate whole-scope form.
accepted = await client.batch.delete_studies(
project_id,
DeleteStudiesRequest(
selector=StudySelector(all=True),
expected_count=104,
),
)
Clearing failed analysis runs is a filter on status:
from yousleep_common.models import (
AnalysisFilter,
AnalysisSelector,
DeleteAnalysesRequest,
)
accepted = await client.batch.delete_analyses(
project_id,
DeleteAnalysesRequest(
selector=AnalysisSelector(filter=AnalysisFilter(status_any=["failed"])),
expected_count=3,
),
)
Explicit ids need no count — the enumeration is the confirmation:
accepted = await client.batch.delete_studies(
project_id,
DeleteStudiesRequest(selector=StudySelector(ids=[study_a, study_b])),
)
Editing tags by selector¶
The tag edit is synchronous and set-based. "Remove tag X from every study carrying it" is the filter being the tag itself:
from yousleep_common.models import EditStudyTagsRequest
result = await client.batch.edit_study_tags(
project_id,
EditStudyTagsRequest(
selector=StudySelector(filter=StudyFilter(tags_any=["pilot"])),
remove=["pilot"],
expected_count=12,
),
)
print(f"{result.count} studies updated")
add and remove may be combined in one call; at least one must be
non-empty. The third form is replace — set every selected study's tags to
exactly this list (mutually exclusive with add/remove; an empty
replace is the deliberate clear-all):
result = await client.batch.edit_study_tags(
project_id,
EditStudyTagsRequest(
selector=StudySelector(filter=StudyFilter(tags_any=["pilot"])),
replace=["cohort-b", "reviewed"],
expected_count=12,
),
)
Analyzing by selector¶
The batch analysis submit (and its quote — same body) accepts a study selector in place of the explicit target list. The server resolves each matched study's recordings at acceptance time:
from yousleep_common.models import (
BatchAnalysisPipelineStep,
BatchSubmitAnalysesRequest,
StudyFilter,
StudySelector,
)
# Quote, then submit staging for every study tagged "night" (previewed as 12).
request = BatchSubmitAnalysesRequest(
pipeline=[BatchAnalysisPipelineStep(analysis_config_id="u-sleep-research-v2")],
selector=StudySelector(filter=StudyFilter(tags_any=["night"])),
expected_count=12,
)
quote = await client.batch.quote_analyses(project_id, request)
print(f"{quote.total_estimated_credits} credits for {quote.summary.quoted} steps")
response = await client.batch.submit_analyses(project_id, request)
The quote also says what the recordings offer of the inputs each analysis
declares: per step, input_coverage counts the recordings with every input
and those with each one, and each result names its missing_inputs. A
recording without a required input is refused; one without an optional input
(a channel type declared with a minimum of 0, or a subject value such as age)
runs without it, unless the request sets complete_inputs_only=True, which
leaves it out so every run receives the same inputs. To find the studies that
lack a subject value, filter by missing_metadata_any:
quote = await client.batch.quote_analyses(project_id, request)
for step in quote.input_coverage:
print(step.step_id, f"{step.complete} of {step.recordings} complete")
for item in step.inputs:
print(f" {item.name}: {item.present} of {step.recordings}")
lacking = await client.studies.list(project_id, missing_metadata_any=["age", "sex"])
Each quoted result also names the channels the run receives. Without an
explicit choice the platform matches the recording's channels to the
analysis; where a recording has more of a type than the analysis takes, it
keeps the first ones in montage order. An explicit choice (default_channels
on a step) is used as given, and one that exceeds what the analysis takes is
refused. A refused result's error.code names the cause:
channels_missing, too_many_channels, channel_side_unknown,
recording_too_short, recording_too_large, inputs_incomplete, duplicate_analysis,
licence_not_permitted or analysis_unavailable. Studies a selector
addresses that have no recording are listed in studies_without_recordings.
The rules are the deletion rules: exactly one of targets | selector,
an empty filter is invalid, and filter/all must state
expected_count (the matched-study total from the filtered listing).
A selector resolving to more recordings than one request may carry (the
explicit targets cap) is rejected with the resolved count — narrow the
filter and submit in slices.
Handling a drifted set¶
If studies were added or removed between your preview and the call, the server answers with a conflict error naming both counts. Re-run the filtered listing and retry with the current total — never retry blindly with the number from the error message without looking at what changed.