@camstack/addon-model-studio 1.1.255 → 1.1.256

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. package/dist/{MotionZonesSettings-gq2w11wR.mjs → MotionZonesSettings-BoNl71uF.mjs} +2 -2
  2. package/dist/{PrivacyMaskSettings-DdbffEaf.mjs → PrivacyMaskSettings-DSQQH-FH.mjs} +4 -4
  3. package/dist/{SceneMonitorEditor-DfrOv9ev.mjs → SceneMonitorEditor-tFGk_bs2.mjs} +3 -3
  4. package/dist/_stub.js +10 -10
  5. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_model_studio_page-C4rP40pW.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_model_studio_page-BqCn1ABp.mjs} +3 -3
  6. package/dist/_virtual_mf___mfe_internal__addon_model_studio_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-JlsKitOu.mjs +26 -0
  7. package/dist/{hostInit-BvTpvVe_.mjs → hostInit-DMxW9qi0.mjs} +2 -2
  8. package/dist/model-studio.addon.js +2261 -2203
  9. package/dist/model-studio.addon.mjs +2261 -2203
  10. package/dist/{player-overlays-ZvkWJw-e.mjs → player-overlays-_tIuC53p.mjs} +1 -1
  11. package/dist/remoteEntry.js +1 -1
  12. package/dist/{responsive-hGzojjp0.mjs → responsive-D99yIECE.mjs} +1 -1
  13. package/dist/{scene-monitor-copy-Bi87lVVQ.mjs → scene-monitor-copy-BCE1iubY.mjs} +1 -1
  14. package/dist/{square-z5yHMQxC.mjs → square-C3_2L-a4.mjs} +1 -1
  15. package/dist/{trash-2-BitUR1qz.mjs → trash-2-JESKnX5Q.mjs} +1 -1
  16. package/dist/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_model_studio_page__remoteEntry_js-B_syN0jO.mjs → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__addon_model_studio_page__remoteEntry_js-DeNEhAVI.mjs} +1 -1
  17. package/package.json +1 -1
  18. package/dist/_virtual_mf___mfe_internal__addon_model_studio_page__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B3r-mTd_.mjs +0 -26
@@ -13638,2461 +13638,2493 @@ method(object({
13638
13638
  password: string()
13639
13639
  }), AuthResultSchema.nullable(), { kind: "mutation" }), method(object({ state: string() }), string()), method(record(string(), string()), AuthResultSchema, { kind: "mutation" }), method(object({ token: string() }), AuthResultSchema.nullable());
13640
13640
  /**
13641
- * How long one snapshot may take end to end: the pinned copy of the whole
13642
- * database plus `PRAGMA integrity_check` over the copy, on the hub's slowest
13643
- * disk. ~800 MB at a pessimistic 10 MB/s is under 3 minutes; the bound is
13644
- * generous because a timeout fails the whole backup run.
13641
+ * Query filter for settings-store collections.
13645
13642
  */
13646
- var DATABASE_SNAPSHOT_TIMEOUT_MS = 30 * 6e4;
13643
+ var QueryFilterSchema = object({
13644
+ where: record(string(), unknown()).optional(),
13645
+ whereIn: record(string(), array(unknown())).optional(),
13646
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
13647
+ /** NULL-safe exclusion: matches rows whose field is NULL OR != the value. */
13648
+ whereNot: record(string(), unknown()).optional(),
13649
+ /** NULL-safe exclusion of a SET — `whereNot` for more than one value. An
13650
+ * empty list excludes nothing. See `QueryFilter.whereNotIn`. */
13651
+ whereNotIn: record(string(), array(unknown())).optional(),
13652
+ orderBy: object({
13653
+ field: string(),
13654
+ direction: _enum(["asc", "desc"])
13655
+ }).optional(),
13656
+ limit: number().optional(),
13657
+ offset: number().optional()
13658
+ });
13647
13659
  /**
13648
- * The databases an owner snapshots — declared by the OWNER, so a caller never
13649
- * guesses which file is live from what sits beside it (a cleanly closed
13650
- * database has no sidecars and is just as owned). Paths are absolute on the
13651
- * owner's host.
13660
+ * The predicate half of a filter, for BULK MUTATIONS.
13661
+ *
13662
+ * Deliberately not `QueryFilterSchema`: `orderBy` / `limit` / `offset` have no
13663
+ * meaning for a statement that rewrites a set, and accepting them would invite
13664
+ * a caller to believe `limit` bounds the damage. Every field is optional here
13665
+ * only so the shape stays composable — the implementation REJECTS a filter
13666
+ * that compiles to no predicate, because that is the whole collection.
13652
13667
  */
13653
- var DatabaseSnapshotTargetsSchema = object({ databases: array(object({ livePath: string() })) });
13654
- /** One b-tree table of the snapshot and the rows it holds. */
13655
- var DatabaseSnapshotTableSchema = object({
13656
- name: string(),
13657
- rows: number().int().nonnegative()
13668
+ var MutationFilterSchema = object({
13669
+ where: record(string(), unknown()).optional(),
13670
+ whereIn: record(string(), array(unknown())).optional(),
13671
+ whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
13672
+ whereNot: record(string(), unknown()).optional(),
13673
+ whereNotIn: record(string(), array(unknown())).optional()
13658
13674
  });
13659
13675
  /**
13660
- * What the owner wrote. Every path is ABSOLUTE on the owner's host — the
13661
- * caller pins the call to its own node, so it can read the file; a caller
13662
- * that finds the path outside its own data root refuses it rather than
13663
- * archiving something it cannot place.
13676
+ * One scalar an {@link settingsStoreCapability.methods.aggregate} call asks for.
13677
+ *
13678
+ * `as` names the slot in the result, so the SAME column may be asked twice with
13679
+ * two operations (`MIN(startMs)` and `MAX(startMs)` in one round trip) — which
13680
+ * a `Record<column, op>` shape could not express.
13664
13681
  */
13665
- var DatabaseSnapshotResultSchema = object({
13666
- /** The live database the snapshot copies. Never to be copied as a file. */
13667
- livePath: string(),
13668
- /** The verified copy: `<livePath>` + {@link DATABASE_SNAPSHOT_SUFFIX}. */
13669
- snapshotPath: string(),
13670
- sizeBytes: number().int().nonnegative(),
13671
- pageCount: number().int().nonnegative(),
13672
- pageSize: number().int().positive(),
13673
- /**
13674
- * Every b-tree table with its row count. Read inside the SAME read
13675
- * transaction the copy was taken from, and found EQUAL on the copy — so
13676
- * this is the content of the snapshot, not an estimate of the source.
13677
- */
13678
- tables: array(DatabaseSnapshotTableSchema),
13679
- /** Wall time of the pinned copy (row counts included). */
13680
- copyMs: number().nonnegative(),
13681
- /** Wall time of the checks on the copy (`integrity_check`, row counts). */
13682
- verifyMs: number().nonnegative(),
13683
- /** When the pinned read transaction began (ms epoch) — the snapshot's instant. */
13684
- takenAt: number()
13682
+ var AggregateFieldSchema = object({
13683
+ /** Result key. */
13684
+ as: string().min(1),
13685
+ /** Column to aggregate. Must be a real column of a declared collection. */
13686
+ field: string().min(1),
13687
+ op: _enum([
13688
+ "sum",
13689
+ "min",
13690
+ "max"
13691
+ ])
13685
13692
  });
13686
13693
  /**
13687
- * A snapshot as an ARCHIVE records it (`ArchiveManifest.sqliteSnapshots`):
13688
- * the same facts, with both paths relative to the archive root (the node's
13689
- * data dir), because an archive outlives the host that made it.
13694
+ * `COUNT(*)` plus one number per requested field.
13695
+ *
13696
+ * `null` means NO ROW MATCHED, never zero: a `SUM` over an empty set and a sum
13697
+ * that really is 0 are different facts, and an accounting caller that renders
13698
+ * "0 bytes, oldest = 0" for "nothing here" reports a lie about a disk.
13690
13699
  */
13691
- var ArchiveSqliteSnapshotSchema = DatabaseSnapshotResultSchema.extend({
13692
- /** Archive-relative path of the live database the archive does NOT carry. */
13693
- livePath: string(),
13694
- /** Archive-relative path of the verified copy it carries in its place. */
13695
- snapshotPath: string()
13700
+ var AggregateResultSchema = object({
13701
+ count: number().int(),
13702
+ values: record(string(), number().nullable())
13696
13703
  });
13697
- method(object({}), DatabaseSnapshotTargetsSchema, { auth: "admin" }), method(object({}), DatabaseSnapshotResultSchema, {
13698
- kind: "mutation",
13699
- auth: "admin",
13700
- timeoutMs: DATABASE_SNAPSHOT_TIMEOUT_MS
13704
+ /** A single stored record: `{ id, data }`. */
13705
+ var SettingsRecordSchema = object({
13706
+ id: string(),
13707
+ data: record(string(), unknown())
13701
13708
  });
13702
13709
  /**
13703
- * Orchestrator-side destination metadata. The orchestrator computes
13704
- * `id = <addonId>:<subId>` from its provider lookup so consumers
13705
- * (admin UI, restore flow) see one canonical key.
13706
- *
13707
- * Phase 4 (admin UI redesign) adds the per-destination policy fields
13708
- * (`enabled`, `retentionCount`, `label`) so the destinations table can
13709
- * render the joined view without a follow-up round-trip. The backend
13710
- * already joins these in `listDestinations` — we now surface them on
13711
- * the wire.
13710
+ * One record of a BULK insert — {@link SettingsRecordSchema} with the id made
13711
+ * optional.
13712
13712
  *
13713
- * `lastSuccessAt` / `lastSuccessSizeBytes` are computed from each
13714
- * location's `manifests.json` (newest archive). Optional — locations
13715
- * with no archives yet leave them undefined.
13713
+ * A separate schema rather than loosening the shared one: every other method
13714
+ * on this cap addresses a row BY its id, and making that field optional
13715
+ * everywhere would turn a forgotten key into a silently generated one on
13716
+ * `update` and `delete` as well.
13716
13717
  */
13717
- var BackupDestinationInfoSchema = object({
13718
- /**
13719
- * Sub-id within this addon. Convention `default` for single-
13720
- * destination addons; addons that host many (S3 with N buckets)
13721
- * pick stable per-bucket strings. The orchestrator stitches
13722
- * `<manifestId>:<subId>` for the wire format.
13723
- */
13724
- subId: string(),
13725
- displayName: string(),
13726
- description: string().optional(),
13727
- kind: string(),
13728
- triggerSupported: boolean(),
13729
- restoreSupported: boolean()
13730
- }).extend({
13731
- /** `<addonId>:<subId>` — globally-unique dispatch key. */
13732
- id: string(),
13733
- /**
13734
- * Manifest id of the owning addon. The Settings button on the
13735
- * destination card opens that addon's `globalSettingsSchema` panel.
13736
- */
13737
- addonId: string(),
13738
- /** Operator-toggled enable flag from the destination-policy table. */
13739
- enabled: boolean(),
13740
- /** Operator-defined retention count (archives kept per destination). */
13741
- retentionCount: number(),
13742
- /** Operator-defined display label (overrides storage location displayName). */
13743
- label: string().optional(),
13744
- /** Newest-archive timestamp from `manifests.json`, or undefined. */
13745
- lastSuccessAt: number().optional(),
13746
- /** Newest-archive size from `manifests.json`, or undefined. */
13747
- lastSuccessSizeBytes: number().optional(),
13748
- /**
13749
- * Cron cadence(s) of the ENABLED schedules that fan out to this
13750
- * destination, comma-joined. Absent when no enabled schedule targets it
13751
- * — a destination nothing is scheduled to write to must not advertise a
13752
- * cadence (D384). This is never the `backup_destination_policies.cron`
13753
- * column: that per-location cron has scheduled nothing since 2026-07-28
13754
- * and reading it made a destination with a DISABLED schedule claim a
13755
- * nightly run.
13756
- */
13757
- cron: string().optional(),
13758
- /** ms-epoch of the next firing across those schedules (earliest), if any. */
13759
- nextRunAt: number().optional(),
13760
- /**
13761
- * ms-epoch of the last time a run ATTEMPTED to write here — success or
13762
- * failure. Never a success stamp: pair it with `lastSuccessAt` (the
13763
- * newest archive that actually landed) and `lastError`.
13764
- */
13765
- lastAttemptAt: number().optional(),
13766
- /**
13767
- * Why the last attempt failed, verbatim. Absent when the last attempt
13768
- * landed the archive. A destination that has never been written to has
13769
- * neither this nor `lastAttemptAt`.
13770
- */
13771
- lastError: string().optional()
13718
+ var BulkRecordSchema = object({
13719
+ id: string().optional(),
13720
+ data: record(string(), unknown())
13772
13721
  });
13773
13722
  /**
13774
- * How an archive is encrypted (operator decision 2026-10-09): to a backup
13775
- * PUBLIC key the hub holds, so only the operator's secret key opens it.
13776
- * Absent on a plain `.tar.gz` — every archive made before encryption, or
13777
- * with it off — which restores without a key.
13723
+ * The database FILES one `sqlite-settings` engine process owns (D851).
13724
+ * Closed on purpose: a file is a thing the engine opens, backs up and gives a
13725
+ * serving thread, so a new one is a code change, never a string a caller
13726
+ * invents. `settings` is `camstack.db` and the default for every collection
13727
+ * that names no file.
13778
13728
  */
13779
- var BackupArchiveEncryptionSchema = object({
13780
- /** `x25519-hkdf-aes256gcm-stream-v1`. */
13781
- scheme: string(),
13782
- /** 16 hex — the backup key whose secret restores this archive. */
13783
- keyId: string()
13784
- });
13729
+ var DataStoreDatabaseSchema = _enum([
13730
+ "settings",
13731
+ "events",
13732
+ "history"
13733
+ ]);
13734
+ DataStoreDatabaseSchema.options;
13785
13735
  /**
13786
- * The backup encryption setting. The hub keeps PUBLIC keys only: the active
13787
- * one new archives are encrypted to, and the retired ones older archives
13788
- * still need. The secret key is shown once, at `setupEncryption`.
13736
+ * One write inside a {@link settingsStoreCapability.methods.transact} call.
13737
+ * The three that a "replace this set, bump that revision" write needs, plus
13738
+ * the batched upsert a rollup writer needs.
13789
13739
  */
13790
- var BackupEncryptionStatusSchema = object({
13791
- enabled: boolean(),
13792
- active: object({
13793
- keyId: string(),
13794
- publicKey: string(),
13795
- createdAt: number()
13796
- }).nullable(),
13797
- /** Every key ever set up, newest first. */
13798
- keys: array(object({
13799
- keyId: string(),
13800
- createdAt: number(),
13801
- retiredAt: number().nullable()
13802
- })).readonly()
13803
- });
13740
+ var TransactOpSchema = discriminatedUnion("op", [
13741
+ object({
13742
+ op: literal("deleteWhere"),
13743
+ collection: string(),
13744
+ filter: MutationFilterSchema
13745
+ }),
13746
+ object({
13747
+ op: literal("insertMany"),
13748
+ collection: string(),
13749
+ records: array(BulkRecordSchema).readonly()
13750
+ }),
13751
+ object({
13752
+ op: literal("update"),
13753
+ collection: string(),
13754
+ id: string(),
13755
+ data: record(string(), unknown())
13756
+ }),
13757
+ object({
13758
+ op: literal("upsertMany"),
13759
+ collection: string(),
13760
+ conflict: array(string()).readonly(),
13761
+ records: array(BulkRecordSchema).readonly()
13762
+ })
13763
+ ]);
13804
13764
  /**
13805
- * Per-archive entry returned by `backup.listArchives({ destinationId })`.
13806
- * Same shape the destination drill-in renders. Sourced from the per-
13807
- * location `manifests.json` (no per-archive tarball read required).
13765
+ * The precondition of a transaction: the row `key` of `collection` must hold
13766
+ * `equals` in `column`, read INSIDE the transaction.
13808
13767
  */
13809
- var BackupArchiveEntrySchema = object({
13810
- id: string(),
13811
- filename: string(),
13812
- createdAt: number(),
13813
- sizeBytes: number(),
13814
- label: string().optional(),
13815
- /** Top-level locations included in the archive (db, addons, tls, …). */
13816
- locations: array(string()).readonly(),
13817
- /** Present when the archive is encrypted; restore then needs that key. */
13818
- encryption: BackupArchiveEncryptionSchema.optional()
13819
- });
13820
- var BackupEntrySchema = object({
13821
- id: string(),
13822
- /** Addon id of the destination that owns this backup (e.g. `local-backup`, `s3-backup`). */
13823
- destinationId: string().optional(),
13824
- label: string().optional(),
13825
- createdAt: number(),
13826
- sizeBytes: number(),
13827
- locations: array(string()).optional(),
13828
- encryption: BackupArchiveEncryptionSchema.optional()
13829
- });
13830
- var ArchiveEntrySchema = object({
13831
- path: string(),
13832
- kind: _enum([
13833
- "file",
13834
- "dir",
13835
- "symlink"
13836
- ]),
13837
- sizeBytes: number(),
13838
- mtime: number()
13839
- });
13840
- var ArchiveManifestSchema = object({
13841
- archiveVersion: literal(1),
13842
- createdAt: number(),
13843
- dataDir: string(),
13844
- locations: array(string()),
13845
- entries: array(ArchiveEntrySchema),
13846
- totalBytes: number(),
13847
- totalFiles: number(),
13848
- /**
13849
- * Live SQLite databases this archive carries as their OWNER's verified
13850
- * snapshot instead of as files (D693). Absent on archives made before.
13851
- */
13852
- sqliteSnapshots: array(ArchiveSqliteSnapshotSchema).optional()
13853
- });
13854
- var LocationStatSchema = object({
13855
- name: string(),
13856
- sizeBytes: number(),
13857
- fileCount: number(),
13858
- present: boolean()
13768
+ var TransactGuardSchema = object({
13769
+ collection: string(),
13770
+ key: string(),
13771
+ column: string(),
13772
+ equals: unknown()
13859
13773
  });
13860
- /** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
13861
- var BackupRunStateSchema = _enum([
13862
- "queued",
13863
- "running",
13864
- "succeeded",
13865
- "failed",
13866
- "cancelled"
13867
- ]);
13868
- /**
13869
- * Where a running backup currently is. `queued` before it starts,
13870
- * `building` while the tar.gz is being staged, `uploading` during the
13871
- * per-destination fan-out, `done` once terminal.
13872
- */
13873
- var BackupRunPhaseSchema = _enum([
13874
- "queued",
13875
- "building",
13876
- "uploading",
13877
- "done"
13878
- ]);
13879
13774
  /**
13880
- * Observable state of one backup run — readable WHILE it runs via
13881
- * `backup.listRuns`. This is what makes the execution queue and
13882
- * `backup.cancel` usable: the 2026-09-04 incident (two concurrent
13883
- * multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
13884
- * diagnosable with `du` because nothing reported that runs existed or
13885
- * how large the staged archive had grown.
13775
+ * Column declaration for a structured (SQL-backed) collection.
13776
+ *
13777
+ * Logical types — the backend translates each to the matching SQLite
13778
+ * storage class and handles per-type marshaling:
13779
+ * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
13780
+ * - `JSON` — TEXT under the hood; serialised on write, parsed on read
13781
+ * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
13886
13782
  */
13887
- var BackupRunSchema = object({
13888
- /** Stable run id — the handle `backup.cancel` takes. */
13889
- id: string(),
13890
- state: BackupRunStateSchema,
13891
- phase: BackupRunPhaseSchema,
13892
- /**
13893
- * Resolved destination location ids. Empty while queued (targets are
13894
- * resolved when the run starts, against the then-current policies).
13895
- */
13896
- destinationIds: array(string()).readonly(),
13897
- label: string().optional(),
13898
- /** ms-epoch when the run was submitted (trigger call / schedule fire). */
13899
- requestedAt: number(),
13900
- /** ms-epoch when the run left the queue and started building. */
13901
- startedAt: number().optional(),
13902
- /** ms-epoch when the run reached a terminal state. */
13903
- finishedAt: number().optional(),
13904
- /** Compressed bytes of the staging archive written so far. */
13905
- stagedBytes: number(),
13906
- /** Final staged archive size, once the build phase completes. */
13907
- archiveSizeBytes: number().optional(),
13908
- /** Bytes pushed to the destination currently uploading. */
13909
- uploadedBytes: number(),
13910
- /** Destinations where the archive fully landed (uploaded + indexed). */
13911
- completedDestinationIds: array(string()).readonly(),
13912
- /** Destinations that failed during the fan-out. */
13913
- failedDestinationIds: array(string()).readonly(),
13914
- /** Failure message when `state === 'failed'`. */
13915
- error: string().optional(),
13783
+ var CollectionColumnSchema = object({
13784
+ name: string(),
13785
+ type: _enum([
13786
+ "TEXT",
13787
+ "INTEGER",
13788
+ "REAL",
13789
+ "JSON",
13790
+ "BOOLEAN"
13791
+ ]),
13792
+ primaryKey: boolean().optional(),
13793
+ notNull: boolean().optional(),
13794
+ unique: boolean().optional(),
13916
13795
  /**
13917
- * 1-based place in the execution queue — 1 = runs next. Present only
13918
- * while `state === 'queued'`. Stamped by the orchestrator from the
13919
- * queue's OWN pending order, never derived from timestamps, so the
13920
- * UI cannot show an order the executor will not honour.
13796
+ * Column DEFAULT. Required for B2: the four `ensureTable` consumers declare
13797
+ * columns like `enabled INTEGER NOT NULL DEFAULT 1`, and without this the
13798
+ * collection surface simply cannot express their existing tables — which is
13799
+ * why they were still on the uncapped `table*` API.
13921
13800
  */
13922
- queuePosition: number().int().min(1).optional()
13801
+ defaultValue: union([
13802
+ string(),
13803
+ number(),
13804
+ boolean()
13805
+ ]).optional()
13923
13806
  });
13924
- /**
13925
- * Result of `backup.trigger`. The call still resolves when the run
13926
- * terminates (compat with schedule-driven runs and the admin UI), but
13927
- * it now names the run and says whether it had to WAIT: a trigger that
13928
- * arrives while another run is in flight is enqueued (or joined onto
13929
- * an identical already-queued run), never started concurrently.
13930
- */
13931
- var BackupTriggerResultSchema = object({
13932
- /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
13933
- runId: string(),
13934
- /** True when the run waited behind an in-flight run instead of starting immediately. */
13935
- queued: boolean(),
13936
- /** True when this trigger was coalesced onto an identical already-queued run. */
13937
- joined: boolean(),
13938
- /** True when the run was cancelled before completing every destination. */
13939
- cancelled: boolean(),
13940
- /** One entry per destination the archive landed at (partial on cancel). */
13941
- entries: array(BackupEntrySchema).readonly()
13807
+ var CollectionIndexSchema = object({
13808
+ name: string(),
13809
+ columns: array(string()).readonly(),
13810
+ unique: boolean().optional()
13942
13811
  });
13943
13812
  /**
13944
- * A backup schedule — the N:M "entry" that binds one cron cadence to a
13945
- * SET of destination locations. Supersedes the per-location cron on
13946
- * `BackupDestinationPolicy`: an operator creates a schedule, picks the
13947
- * `backups` locations it should write to, and the orchestrator fans a
13948
- * single archive out to all of them when the cron fires.
13949
- *
13950
- * `retentionCount` is per-schedule (D-decision 2026-07-28): every
13951
- * location targeted by this schedule keeps this many archives from
13952
- * this schedule's runs.
13813
+ * The input of `declareCollection` — ONE schema for the door and for the
13814
+ * engine behind it, so the two surfaces cannot drift (the dispatcher forwards
13815
+ * the payload untouched).
13953
13816
  *
13954
- * `dataSources` optionally narrows which top-level state locations
13955
- * (db, addons, tls, …) are archived; omitted = the orchestrator's
13956
- * default full set.
13817
+ * - `database` — the file the collection lives in (D851). Absent means
13818
+ * `settings`. A declaration naming a file other than the one the table is
13819
+ * already in is REFUSED by the engine, never moved.
13820
+ * - `primaryKey` — a COMPOSITE key, as column names. A single name is the same
13821
+ * as `primaryKey: true` on that column. A composite-keyed collection has no
13822
+ * `id`: it is written with `insertMany` / `upsertMany` / `transact` and read
13823
+ * with `query` / `count` / `aggregate` / `deleteWhere`; the calls that
13824
+ * address one row by id are refused.
13825
+ * - `withoutRowid` — emit `WITHOUT ROWID`. Requires a primary key.
13957
13826
  */
13958
- var BackupScheduleSchema = object({
13959
- /** Stable id. Generated by the orchestrator on first upsert if absent. */
13960
- id: string(),
13961
- /** Operator-facing display name. */
13962
- label: string(),
13963
- /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
13964
- cron: string(),
13965
- /** Master on/off toggle for the whole schedule. */
13966
- enabled: boolean(),
13967
- /** `backups`-location ids this schedule writes to (fan-out set). */
13968
- locationIds: array(string()).readonly(),
13969
- /** Archives kept per targeted location for this schedule. */
13970
- retentionCount: number().int().min(1).max(1e3),
13971
- /** Optional subset of source locations to include; omitted = all. */
13972
- dataSources: array(string()).readonly().optional(),
13973
- /**
13974
- * ms-epoch of the last tick that FIRED this schedule. Stamped before the
13975
- * archive runs (it is the dedupe anchor), so it says "attempted", never
13976
- * "succeeded" — a run refused by every destination stamps it too.
13977
- */
13978
- lastAttemptAt: number().optional(),
13979
- /** ms-epoch of the last run of this schedule that landed at ≥1 destination. */
13980
- lastSuccessAt: number().optional(),
13981
- /** Why the last fired run failed, verbatim. Absent when it succeeded. */
13982
- lastError: string().optional(),
13983
- /** ms-epoch of next computed firing (read-only, filled on list). */
13984
- nextRunAt: number().optional()
13827
+ var DeclareCollectionInputSchema = object({
13828
+ namespace: string().optional(),
13829
+ collection: string(),
13830
+ columns: array(CollectionColumnSchema).readonly(),
13831
+ indexes: array(CollectionIndexSchema).readonly().optional(),
13832
+ database: DataStoreDatabaseSchema.optional(),
13833
+ primaryKey: array(string()).readonly().optional(),
13834
+ withoutRowid: boolean().optional()
13985
13835
  });
13986
- method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
13987
- /** Subset of registered `backup-destination` addon ids to write to. */
13988
- destinations: array(string()).optional(),
13989
- locations: array(string()).optional(),
13990
- label: string().optional(),
13991
- /**
13992
- * Per-run retention override applied to every targeted
13993
- * destination. Used by schedule-driven runs (per-entry
13994
- * retention). Omitted = each destination's own policy
13995
- * retention (manual runs).
13996
- */
13997
- retentionCount: number().int().min(1).max(1e3).optional()
13998
- }).optional(), BackupTriggerResultSchema, {
13836
+ method(object({
13837
+ namespace: string().optional(),
13838
+ collection: string(),
13839
+ key: string()
13840
+ }), unknown(), { auth: "admin" }), method(object({
13841
+ namespace: string().optional(),
13842
+ collection: string(),
13843
+ key: string(),
13844
+ value: unknown()
13845
+ }), _void(), {
13999
13846
  kind: "mutation",
14000
13847
  auth: "admin"
14001
- }), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
13848
+ }), method(object({
13849
+ namespace: string().optional(),
13850
+ collection: string(),
13851
+ filter: QueryFilterSchema.optional(),
13852
+ columns: array(string()).readonly().optional()
13853
+ }), array(SettingsRecordSchema).readonly(), { auth: "admin" }), method(object({
13854
+ namespace: string().optional(),
13855
+ collection: string(),
13856
+ /** Declared columns identifying the slot; must be covered by a UNIQUE
13857
+ * index on the collection. Never empty. */
13858
+ conflict: array(string()).readonly(),
13859
+ record: BulkRecordSchema
13860
+ }), object({
13861
+ /** The id the row HAS: assigned by SQLite on an insert, or the existing
13862
+ * row's own id on an update. */
13863
+ id: union([string(), number()]) }), {
14002
13864
  kind: "mutation",
14003
13865
  auth: "admin"
14004
- }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
14005
- destinationId: string(),
14006
- backupId: string()
14007
- }), ArchiveManifestSchema.nullable(), { auth: "admin" }), method(object({
14008
- destinationId: string(),
14009
- backupId: string(),
14010
- /**
14011
- * Optional whitelist — only restore these top-level locations
14012
- * from the archive. Default = every location the archive
14013
- * carries (full restore). The boot-time apply hook reads
14014
- * this list and skips entries whose path doesn't start with
14015
- * any of them.
14016
- */
14017
- locations: array(string()).optional(),
14018
- /**
14019
- * The backup SECRET key, for an encrypted archive (ignored for a plain
14020
- * one). The archive is decrypted into the hub's private staging
14021
- * before the restart; the key is not stored. A wrong or missing key
14022
- * is refused before the archive is downloaded.
14023
- */
14024
- backupSecretKey: string().optional()
14025
- }), _void(), {
13866
+ }), method(object({
13867
+ namespace: string().optional(),
13868
+ collection: string(),
13869
+ conflict: array(string()).readonly(),
13870
+ records: array(BulkRecordSchema).readonly()
13871
+ }), object({ upserted: number().int() }), {
14026
13872
  kind: "mutation",
14027
13873
  auth: "admin"
14028
- }), method(_void(), BackupEncryptionStatusSchema, { auth: "admin" }), method(object({}), object({
14029
- keyId: string(),
14030
- secretKey: string(),
14031
- publicKey: string(),
14032
- createdAt: number(),
14033
- replacedKeyId: string().nullable()
14034
- }), {
13874
+ }), method(object({
13875
+ namespace: string().optional(),
13876
+ collection: string(),
13877
+ record: object({
13878
+ id: string().optional(),
13879
+ data: record(string(), unknown())
13880
+ })
13881
+ }), object({
13882
+ /**
13883
+ * The id the row ACTUALLY got (D473): the one supplied, the UUID minted
13884
+ * for an absent one, or the ROWID SQLite assigned on an `INTEGER`
13885
+ * primary key — which is the only place an auto key is knowable.
13886
+ */
13887
+ id: union([string(), number()]) }), {
14035
13888
  kind: "mutation",
14036
13889
  auth: "admin"
14037
- }), method(object({}), object({ disabledKeyId: string().nullable() }), {
13890
+ }), method(object({
13891
+ namespace: string().optional(),
13892
+ collection: string(),
13893
+ records: array(BulkRecordSchema).readonly()
13894
+ }), object({ inserted: number().int() }), {
14038
13895
  kind: "mutation",
14039
13896
  auth: "admin"
14040
- }), method(object({ backupSecretKey: string() }), object({
14041
- valid: boolean(),
14042
- keyId: string().nullable(),
14043
- /** The hub knows this key (active or retired). */
14044
- known: boolean(),
14045
- /** It is the key new archives are encrypted to. */
14046
- active: boolean(),
14047
- error: string().optional()
13897
+ }), method(object({
13898
+ namespace: string().optional(),
13899
+ guard: TransactGuardSchema.optional(),
13900
+ ops: array(TransactOpSchema).readonly()
13901
+ }), object({
13902
+ applied: boolean(),
13903
+ current: unknown().optional()
14048
13904
  }), {
14049
13905
  kind: "mutation",
14050
13906
  auth: "admin"
14051
13907
  }), method(object({
14052
- destinationId: string(),
14053
- backupId: string()
13908
+ namespace: string().optional(),
13909
+ collection: string(),
13910
+ id: string(),
13911
+ data: record(string(), unknown())
14054
13912
  }), _void(), {
14055
13913
  kind: "mutation",
14056
13914
  auth: "admin"
14057
- }), method(object({ destinationId: string() }), array(BackupArchiveEntrySchema).readonly(), { auth: "admin" }), method(object({
14058
- locationId: string(),
14059
- enabled: boolean(),
14060
- retentionCount: number().int().min(1).max(1e3),
14061
- label: string().optional()
13915
+ }), method(object({
13916
+ namespace: string().optional(),
13917
+ collection: string(),
13918
+ key: string()
14062
13919
  }), _void(), {
14063
13920
  kind: "mutation",
14064
13921
  auth: "admin"
14065
13922
  }), method(object({
14066
- cron: string(),
14067
- count: number().int().min(1).max(20).optional()
14068
- }), object({
14069
- ok: boolean(),
14070
- error: string().optional(),
14071
- nextRuns: array(number()).readonly()
14072
- })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(object({
14073
- id: string().optional(),
14074
- label: string(),
14075
- cron: string(),
14076
- enabled: boolean(),
14077
- locationIds: array(string()).readonly(),
14078
- retentionCount: number().int().min(1).max(1e3),
14079
- dataSources: array(string()).readonly().optional()
14080
- }), BackupScheduleSchema, {
13923
+ namespace: string().optional(),
13924
+ collection: string(),
13925
+ filter: MutationFilterSchema
13926
+ }), object({ deleted: number().int() }), {
14081
13927
  kind: "mutation",
14082
13928
  auth: "admin"
14083
- }), method(object({ id: string() }), _void(), {
13929
+ }), method(object({
13930
+ namespace: string().optional(),
13931
+ collection: string(),
13932
+ filter: MutationFilterSchema,
13933
+ data: record(string(), unknown())
13934
+ }), object({ updated: number().int() }), {
13935
+ kind: "mutation",
13936
+ auth: "admin"
13937
+ }), method(object({
13938
+ namespace: string().optional(),
13939
+ collection: string(),
13940
+ filter: QueryFilterSchema.optional()
13941
+ }), number(), { auth: "admin" }), method(object({
13942
+ namespace: string().optional(),
13943
+ collection: string(),
13944
+ fields: array(AggregateFieldSchema).readonly(),
13945
+ filter: QueryFilterSchema.optional()
13946
+ }), AggregateResultSchema, { auth: "admin" }), method(object({
13947
+ namespace: string().optional(),
13948
+ collection: string(),
13949
+ field: string(),
13950
+ bucketSize: number().int().positive(),
13951
+ origin: number().int(),
13952
+ filter: QueryFilterSchema.optional()
13953
+ }), array(object({
13954
+ bucket: number().int(),
13955
+ count: number().int()
13956
+ })).readonly(), { auth: "admin" }), method(object({
13957
+ namespace: string().optional(),
13958
+ collection: string()
13959
+ }), boolean(), { auth: "admin" }), method(DeclareCollectionInputSchema, _void(), {
14084
13960
  kind: "mutation",
14085
13961
  auth: "admin"
14086
13962
  });
14087
13963
  /**
14088
- * `broker` — unified pub/sub broker registry, system-scoped collection.
14089
- *
14090
- * The cap models any kind-tagged message broker the user wants to
14091
- * register and that other addons might consume. The first two kinds
14092
- * are MQTT (mosquitto / aedes embedded / cloud bridge) and
14093
- * Home Assistant (HA WebSocket — subscribe_entities + call_service).
14094
- * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
14095
- * the same surface.
14096
- *
14097
- * Why one cap, not one cap per kind:
14098
- * - The integrations page wants a single table of "every broker"
14099
- * across kinds; a unified cap drives it without join logic.
14100
- * - Generic admin operations (add / remove / test / status) are
14101
- * identical across kinds — duplicating them per cap was busywork.
14102
- * - The `network-access` cap precedent: same interface, many
14103
- * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
14104
- *
14105
- * What stays kind-specific:
14106
- * - `getBrokerConfig` payload (kind decides whether it returns
14107
- * mqtt URL+creds, HA baseUrl+token, …) — typed as
14108
- * `Record<string, unknown>` at the wire; consumers narrow.
14109
- * - `publish` / `subscribe` / `getState` arguments (target / filter /
14110
- * key) — also typed as `Record<string, unknown>` so each kind can
14111
- * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
14112
- * without rebreaking the cap signature.
14113
- * - `add` `settings` payload (different fields per kind, validated by
14114
- * the kind-specific provider).
14115
- *
14116
- * Bidirectionality:
14117
- * - `publish` is RPC — synchronous result (provider-defined).
14118
- * - `subscribe` returns a subscription handle; the actual message
14119
- * stream flows over the typed event-bus as `broker.message` events
14120
- * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
14121
- * event handler. Subscriptions persist across reconnects — the
14122
- * provider re-subscribes upstream after a transport drop.
14123
- *
14124
- * Why event-bus push (not a subscription RPC stream):
14125
- * - Matches the rest of CamStack's D8 contract — events for telemetry,
14126
- * RPC for loss-is-a-bug. Broker messages are telemetry (a single
14127
- * drop is recoverable via `getState({key})`).
14128
- * - Frees `broker.subscribe` from holding a long-lived RPC channel.
14129
- * - Cross-process: events already route through `$event-bus`; no
14130
- * per-broker wire-shaping needed.
13964
+ * How long one snapshot may take end to end: the pinned copy of the whole
13965
+ * database plus `PRAGMA integrity_check` over the copy, on the hub's slowest
13966
+ * disk. ~800 MB at a pessimistic 10 MB/s is under 3 minutes; the bound is
13967
+ * generous because a timeout fails the whole backup run.
14131
13968
  */
14132
- var BrokerStatusEnum = _enum([
14133
- "connected",
14134
- "disconnected",
14135
- "connecting",
14136
- "auth-failed",
14137
- "unreachable",
14138
- "error"
14139
- ]);
14140
- var BrokerInfoSchema = object({
14141
- /** Stable broker id. Persisted; survives addon restarts. */
14142
- id: string(),
14143
- /** Addon id of the provider that OWNS this broker.
14144
- *
14145
- * The `broker` cap is a system-scoped collection: several addons
14146
- * register a `broker` provider, each owning a DISJOINT set of
14147
- * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
14148
- * `ha_*`). A broker therefore belongs to exactly one addon — like a
14149
- * device belongs to one integration. The admin UI threads this id
14150
- * back as the `{ addonId }` system-collection selector on every
14151
- * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
14152
- * `testConnection`), so the call routes to the OWNING provider
14153
- * instead of defaulting to the first-registered one. */
14154
- addonId: string(),
14155
- /** Human-readable name (operator-chosen at add-time). */
13969
+ var DATABASE_SNAPSHOT_TIMEOUT_MS = 30 * 6e4;
13970
+ /**
13971
+ * The databases an owner snapshots — declared by the OWNER, so a caller never
13972
+ * guesses which file is live from what sits beside it (a cleanly closed
13973
+ * database has no sidecars and is just as owned). Paths are absolute on the
13974
+ * owner's host.
13975
+ */
13976
+ var DatabaseSnapshotTargetsSchema = object({ databases: array(object({
13977
+ /** Which owned file this is — the handle `writeSnapshot` takes. */
13978
+ databaseId: DataStoreDatabaseSchema,
13979
+ livePath: string()
13980
+ })) });
13981
+ /** One b-tree table of the snapshot and the rows it holds. */
13982
+ var DatabaseSnapshotTableSchema = object({
14156
13983
  name: string(),
14157
- /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
14158
- kind: string(),
14159
- status: BrokerStatusEnum,
14160
- /** Free-form provider-specific info (HA version, MQTT broker
14161
- * flavour, latency, mTLS active, …). */
14162
- info: record(string(), unknown()),
14163
- /** Ms epoch of the last connection probe / status update. */
14164
- lastCheckedAt: number().nullable(),
14165
- /** Last error message, when `status` indicates failure. */
14166
- error: string().nullable()
14167
- });
14168
- var RegistryStatusSchema = object({
14169
- brokerCount: number().int().nonnegative(),
14170
- connectedCount: number().int().nonnegative()
13984
+ rows: number().int().nonnegative()
14171
13985
  });
14172
13986
  /**
14173
- * One entry per `broker` provider — which addon provides which broker
14174
- * kind(s). The unified create picker reads this to know, for each
14175
- * (addonId, kind) pair the operator can pick, where to route the
14176
- * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
14177
- * `{ addonId }` selector). A provider only advertises a kind here when
14178
- * it implements `getSettingsSchema` for that kind (so the picker never
14179
- * offers a kind whose creation form can't be rendered).
13987
+ * What the owner wrote. Every path is ABSOLUTE on the owner's host — the
13988
+ * caller pins the call to its own node, so it can read the file; a caller
13989
+ * that finds the path outside its own data root refuses it rather than
13990
+ * archiving something it cannot place.
14180
13991
  */
14181
- var BrokerProviderInfoSchema = object({
14182
- /** Addon id of the `broker` provider this entry describes. */
14183
- addonId: string(),
14184
- /** Broker kinds this provider can create, with a display label. */
14185
- kinds: array(object({
14186
- kind: string(),
14187
- label: string(),
14188
- /**
14189
- * Relative hub path to start an interactive account link (OAuth popup).
14190
- * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
14191
- */
14192
- interactiveConnectPath: string().min(1).optional(),
14193
- /** Relative hub path to disconnect the linked account (same popup pattern). */
14194
- interactiveDisconnectPath: string().min(1).optional()
14195
- }))
14196
- });
14197
- var ListInputSchema = object({
14198
- /** Optional kind filter — `list({kind:'home-assistant'})` returns
14199
- * only HA brokers. Omit to list every broker. */
14200
- kind: string().optional() });
14201
- var GetInputSchema = object({ id: string() });
14202
- var AddInputSchema = object({
14203
- kind: string().min(1),
14204
- name: string().min(1),
13992
+ var DatabaseSnapshotResultSchema = object({
13993
+ /** The live database the snapshot copies. Never to be copied as a file. */
13994
+ livePath: string(),
13995
+ /** The verified copy: `<livePath>` + {@link DATABASE_SNAPSHOT_SUFFIX}. */
13996
+ snapshotPath: string(),
13997
+ sizeBytes: number().int().nonnegative(),
13998
+ pageCount: number().int().nonnegative(),
13999
+ pageSize: number().int().positive(),
14205
14000
  /**
14206
- * ADOPT an existing id instead of minting a new one.
14207
- *
14208
- * Written the day this cost an outage. A broker's id is not a detail: HA
14209
- * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
14210
- * lost from config and re-added as `ha_001` leaves every one of its devices
14211
- * bound to a broker that no longer exists. Re-entering the password under the
14212
- * ORIGINAL id turns a multi-step device migration back into re-entering a
14213
- * password.
14214
- *
14215
- * A provider MUST refuse an id that is already in use — adopting a live
14216
- * broker's id would silently take it over.
14001
+ * Every b-tree table with its row count. Read inside the SAME read
14002
+ * transaction the copy was taken from, and found EQUAL on the copy — so
14003
+ * this is the content of the snapshot, not an estimate of the source.
14217
14004
  */
14218
- id: string().min(1).optional(),
14219
- /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
14220
- * `{baseUrl,accessToken}`). Validated by the kind-specific provider
14221
- * branch on receipt — invalid shape rejects the add. */
14222
- settings: record(string(), unknown())
14223
- });
14224
- var AddResultSchema = object({ id: string() });
14225
- var RemoveInputSchema = object({ id: string() });
14226
- var TestConnectionResultSchema = discriminatedUnion("ok", [object({
14227
- ok: literal(true),
14228
- latencyMs: number().nonnegative()
14229
- }), object({
14230
- ok: literal(false),
14231
- error: string()
14232
- })]);
14233
- var SettingsRecordSchema$1 = record(string(), unknown());
14234
- var SettingsSchemaInputSchema = object({ kind: string() });
14235
- var TestSettingsInputSchema = object({
14236
- kind: string(),
14237
- settings: SettingsRecordSchema$1
14238
- });
14239
- var TestSettingsResultSchema = discriminatedUnion("ok", [object({
14240
- ok: literal(true),
14241
- latencyMs: number().nonnegative().optional()
14242
- }).strict(), object({
14243
- ok: literal(false),
14244
- error: string()
14245
- })]);
14246
- var SettingsSchemaResultSchema = unknown().nullable();
14247
- var PublishInputSchema = object({
14248
- brokerId: string(),
14249
- /** Kind-specific routing target.
14250
- * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
14251
- * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
14252
- target: record(string(), unknown()),
14253
- /** Kind-specific payload.
14254
- * MQTT: raw string / number / object (provider serialises).
14255
- * HA: service-call `data` object (transition, brightness, …). */
14256
- payload: unknown().optional()
14257
- });
14258
- var SubscribeInputSchema = object({
14259
- brokerId: string(),
14260
- /** Kind-specific filter.
14261
- * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
14262
- * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
14263
- filter: record(string(), unknown())
14264
- });
14265
- var SubscribeResultSchema = object({
14266
- /** Stable subscription id. Used to unsubscribe and to filter the
14267
- * matching `broker.message` events on the bus. */
14268
- subscriptionId: string() });
14269
- var UnsubscribeInputSchema = object({
14270
- brokerId: string(),
14271
- subscriptionId: string()
14005
+ tables: array(DatabaseSnapshotTableSchema),
14006
+ /** Wall time of the pinned copy (row counts included). */
14007
+ copyMs: number().nonnegative(),
14008
+ /** Wall time of the checks on the copy (`integrity_check`, row counts). */
14009
+ verifyMs: number().nonnegative(),
14010
+ /** When the pinned read transaction began (ms epoch) — the snapshot's instant. */
14011
+ takenAt: number()
14272
14012
  });
14273
- var GetStateInputSchema = object({
14274
- brokerId: string(),
14275
- /** Kind-specific lookup key.
14276
- * MQTT: topic string (returns the last retained message).
14277
- * HA: entity_id (returns the cached entity state). */
14278
- key: string()
14013
+ /** A snapshot the owner wrote, with the file it belongs to. */
14014
+ var DatabaseSnapshotWrittenSchema = DatabaseSnapshotResultSchema.extend({ databaseId: DataStoreDatabaseSchema });
14015
+ /** Which database `writeSnapshot` copies. Required: an unstated ask is not guessed. */
14016
+ var DatabaseSnapshotRequestSchema = object({ databaseId: DataStoreDatabaseSchema });
14017
+ /**
14018
+ * A snapshot as an ARCHIVE records it (`ArchiveManifest.sqliteSnapshots`):
14019
+ * the same facts, with both paths relative to the archive root (the node's
14020
+ * data dir), because an archive outlives the host that made it.
14021
+ */
14022
+ var ArchiveSqliteSnapshotSchema = DatabaseSnapshotResultSchema.extend({
14023
+ /** Archive-relative path of the live database the archive does NOT carry. */
14024
+ livePath: string(),
14025
+ /** Archive-relative path of the verified copy it carries in its place. */
14026
+ snapshotPath: string(),
14027
+ /** Absent on an archive made before several files were owned: that was `settings`. */
14028
+ databaseId: DataStoreDatabaseSchema.optional()
14279
14029
  });
14280
- method(ListInputSchema, array(BrokerInfoSchema)), method(GetInputSchema, BrokerInfoSchema.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
14281
- kind: "mutation",
14282
- auth: "admin"
14283
- }), method(RemoveInputSchema, _void(), {
14284
- kind: "mutation",
14285
- auth: "admin"
14286
- }), method(GetInputSchema, TestConnectionResultSchema, {
14287
- kind: "mutation",
14288
- auth: "admin"
14289
- }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
14290
- id: string(),
14291
- settings: SettingsRecordSchema$1
14292
- }), _void(), {
14293
- kind: "mutation",
14294
- auth: "admin"
14295
- }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
14296
- kind: "mutation",
14297
- auth: "admin"
14298
- }), method(PublishInputSchema, unknown(), {
14299
- kind: "mutation",
14300
- auth: "admin"
14301
- }), method(SubscribeInputSchema, SubscribeResultSchema, {
14302
- kind: "mutation",
14303
- auth: "admin"
14304
- }), method(UnsubscribeInputSchema, _void(), {
14030
+ method(object({}), DatabaseSnapshotTargetsSchema, { auth: "admin" }), method(DatabaseSnapshotRequestSchema, DatabaseSnapshotWrittenSchema, {
14305
14031
  kind: "mutation",
14306
- auth: "admin"
14307
- }), method(GetStateInputSchema, unknown().nullable(), { auth: "admin" }), method(_void(), RegistryStatusSchema);
14308
- DeviceType.Camera;
14309
- /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
14310
- var StreamFormatSchema = _enum([
14311
- "webrtc",
14312
- "hls",
14313
- "mjpeg",
14314
- "rtsp"
14315
- ]);
14316
- /** A container `produceEventMedia` can emit. */
14317
- var EventMediaKindSchema = _enum(["mp4", "gif"]);
14032
+ auth: "admin",
14033
+ timeoutMs: DATABASE_SNAPSHOT_TIMEOUT_MS
14034
+ });
14318
14035
  /**
14319
- * One produced artifact, referenced by HANDLE.
14036
+ * Orchestrator-side destination metadata. The orchestrator computes
14037
+ * `id = <addonId>:<subId>` from its provider lookup so consumers
14038
+ * (admin UI, restore flow) see one canonical key.
14320
14039
  *
14321
- * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
14322
- * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
14323
- * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
14324
- * on demand, compressed, by handle). `bytes` is here so a caller can decide
14325
- * whether it wants the fetch at all.
14040
+ * Phase 4 (admin UI redesign) adds the per-destination policy fields
14041
+ * (`enabled`, `retentionCount`, `label`) so the destinations table can
14042
+ * render the joined view without a follow-up round-trip. The backend
14043
+ * already joins these in `listDestinations` — we now surface them on
14044
+ * the wire.
14045
+ *
14046
+ * `lastSuccessAt` / `lastSuccessSizeBytes` are computed from each
14047
+ * location's `manifests.json` (newest archive). Optional — locations
14048
+ * with no archives yet leave them undefined.
14326
14049
  */
14327
- var EventMediaArtifactSchema = object({
14328
- kind: EventMediaKindSchema,
14329
- /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
14330
- handle: string(),
14050
+ var BackupDestinationInfoSchema = object({
14331
14051
  /**
14332
- * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
14333
- *
14334
- * `stream-broker` is a singleton cap and an unpinned call never leaves the
14335
- * hub, so a handle produced on an agent's broker would be redeemed against
14336
- * the hub's store and come back `null`. Same contract, same field name and
14337
- * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
14338
- * lives and the consumer pins to it.
14052
+ * Sub-id within this addon. Convention `default` for single-
14053
+ * destination addons; addons that host many (S3 with N buckets)
14054
+ * pick stable per-bucket strings. The orchestrator stitches
14055
+ * `<manifestId>:<subId>` for the wire format.
14339
14056
  */
14340
- nodeId: string(),
14341
- mime: string(),
14342
- bytes: number().int(),
14343
- width: number().int(),
14344
- height: number().int()
14057
+ subId: string(),
14058
+ displayName: string(),
14059
+ description: string().optional(),
14060
+ kind: string(),
14061
+ triggerSupported: boolean(),
14062
+ restoreSupported: boolean()
14063
+ }).extend({
14064
+ /** `<addonId>:<subId>` — globally-unique dispatch key. */
14065
+ id: string(),
14066
+ /**
14067
+ * Manifest id of the owning addon. The Settings button on the
14068
+ * destination card opens that addon's `globalSettingsSchema` panel.
14069
+ */
14070
+ addonId: string(),
14071
+ /** Operator-toggled enable flag from the destination-policy table. */
14072
+ enabled: boolean(),
14073
+ /** Operator-defined retention count (archives kept per destination). */
14074
+ retentionCount: number(),
14075
+ /** Operator-defined display label (overrides storage location displayName). */
14076
+ label: string().optional(),
14077
+ /** Newest-archive timestamp from `manifests.json`, or undefined. */
14078
+ lastSuccessAt: number().optional(),
14079
+ /** Newest-archive size from `manifests.json`, or undefined. */
14080
+ lastSuccessSizeBytes: number().optional(),
14081
+ /**
14082
+ * Cron cadence(s) of the ENABLED schedules that fan out to this
14083
+ * destination, comma-joined. Absent when no enabled schedule targets it
14084
+ * — a destination nothing is scheduled to write to must not advertise a
14085
+ * cadence (D384). This is never the `backup_destination_policies.cron`
14086
+ * column: that per-location cron has scheduled nothing since 2026-07-28
14087
+ * and reading it made a destination with a DISABLED schedule claim a
14088
+ * nightly run.
14089
+ */
14090
+ cron: string().optional(),
14091
+ /** ms-epoch of the next firing across those schedules (earliest), if any. */
14092
+ nextRunAt: number().optional(),
14093
+ /**
14094
+ * ms-epoch of the last time a run ATTEMPTED to write here — success or
14095
+ * failure. Never a success stamp: pair it with `lastSuccessAt` (the
14096
+ * newest archive that actually landed) and `lastError`.
14097
+ */
14098
+ lastAttemptAt: number().optional(),
14099
+ /**
14100
+ * Why the last attempt failed, verbatim. Absent when the last attempt
14101
+ * landed the archive. A destination that has never been written to has
14102
+ * neither this nor `lastAttemptAt`.
14103
+ */
14104
+ lastError: string().optional()
14345
14105
  });
14346
14106
  /**
14347
- * What a production actually covered — the answer to the only question an
14348
- * operator asks about a notification clip.
14349
- *
14350
- * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
14351
- * so a caller can state "this clip starts 4.1 s before the event" instead of
14352
- * inferring it from a duration. A production whose `fromTs` is later than the
14353
- * event is a production with no pre-roll, and that is exactly the defect this
14354
- * method exists to make visible rather than plausible.
14107
+ * How an archive is encrypted (operator decision 2026-10-09): to a backup
14108
+ * PUBLIC key the hub holds, so only the operator's secret key opens it.
14109
+ * Absent on a plain `.tar.gz` — every archive made before encryption, or
14110
+ * with it off — which restores without a key.
14355
14111
  */
14356
- var EventMediaCoverageSchema = object({
14357
- fromTs: number(),
14358
- toTs: number(),
14359
- /** Encoded packets in the muxed window. */
14360
- packets: number().int()
14112
+ var BackupArchiveEncryptionSchema = object({
14113
+ /** `x25519-hkdf-aes256gcm-stream-v1`. */
14114
+ scheme: string(),
14115
+ /** 16 hex — the backup key whose secret restores this archive. */
14116
+ keyId: string()
14361
14117
  });
14362
14118
  /**
14363
- * The result of ONE cut, in every container the caller asked for.
14364
- *
14365
- * Every artifact in `media` came out of the SAME window of the SAME rendition —
14366
- * that is the whole reason this is one method rather than one call per format.
14367
- * A consumer attaching a gif and a video can no longer show two different
14368
- * moments, because it never chose two sources.
14119
+ * The backup encryption setting. The hub keeps PUBLIC keys only: the active
14120
+ * one new archives are encrypted to, and the retired ones older archives
14121
+ * still need. The secret key is shown once, at `setupEncryption`.
14369
14122
  */
14370
- var EventMediaProductionSchema = object({
14371
- media: array(EventMediaArtifactSchema).readonly(),
14372
- coverage: EventMediaCoverageSchema,
14373
- /** The rendition actually cut from — what the default or the fallback chose. */
14374
- profile: CamProfileSchema,
14375
- /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
14376
- * source, a downscale, or a playback rate other than 1). */
14377
- video: _enum(["copy", "encode"])
14378
- });
14379
- var RtspRestreamEntrySchema = object({
14380
- brokerId: string(),
14381
- url: string(),
14382
- mutedUrl: string(),
14123
+ var BackupEncryptionStatusSchema = object({
14383
14124
  enabled: boolean(),
14125
+ active: object({
14126
+ keyId: string(),
14127
+ publicKey: string(),
14128
+ createdAt: number()
14129
+ }).nullable(),
14130
+ /** Every key ever set up, newest first. */
14131
+ keys: array(object({
14132
+ keyId: string(),
14133
+ createdAt: number(),
14134
+ retiredAt: number().nullable()
14135
+ })).readonly()
14136
+ });
14137
+ /**
14138
+ * Per-archive entry returned by `backup.listArchives({ destinationId })`.
14139
+ * Same shape the destination drill-in renders. Sourced from the per-
14140
+ * location `manifests.json` (no per-archive tarball read required).
14141
+ */
14142
+ var BackupArchiveEntrySchema = object({
14143
+ id: string(),
14144
+ filename: string(),
14145
+ createdAt: number(),
14146
+ sizeBytes: number(),
14147
+ label: string().optional(),
14148
+ /** Top-level locations included in the archive (db, addons, tls, …). */
14149
+ locations: array(string()).readonly(),
14150
+ /** Present when the archive is encrypted; restore then needs that key. */
14151
+ encryption: BackupArchiveEncryptionSchema.optional(),
14384
14152
  /**
14385
- * Source-stream codec / resolution for the camStream this entry serves
14386
- * (the broker's "high"/"mid"/"low" profile slot for this device).
14387
- * Used by exporter pickers (`pickPreferredRtspEntry`) to resolve
14388
- * `streamPreference: 'auto'` to the slot whose source is closest to
14389
- * the consumer's target — Alexa wants ~720p, HomeKit wants ~1080p,
14390
- * and there's no point dialling the 4K slot for an Echo Show. Absent
14391
- * when the source publisher never advertised the field; pickers fall
14392
- * back to first-enabled in that case.
14153
+ * The databases the run asked to carry (its retention class). Absent on an
14154
+ * archive made before several files were owned: that was `['settings']`.
14393
14155
  */
14394
- codec: string().optional(),
14395
- resolution: object({
14396
- width: number().int().positive(),
14397
- height: number().int().positive()
14398
- }).optional()
14399
- });
14400
- var BrokerRtspClientSchema = object({
14401
- sessionId: string(),
14402
- remoteAddr: string(),
14403
- /** Client `User-Agent` (e.g. `recorder` for the recorder's RTSP pull), or
14404
- * null/absent when the client sent none. Lets the UI label a consumer by
14405
- * purpose. Optional so a client built against an older schema stays valid. */
14406
- userAgent: string().nullish(),
14407
- playing: boolean(),
14408
- muted: boolean(),
14409
- connectedAt: number(),
14410
- lastRtpAt: number(),
14411
- bytesSent: number()
14156
+ databases: array(DataStoreDatabaseSchema).readonly().optional()
14412
14157
  });
14413
- var BrokerAudioClientSchema = object({
14414
- tag: string(),
14415
- subscribedAt: number(),
14416
- chunksDelivered: number()
14158
+ var BackupEntrySchema = object({
14159
+ id: string(),
14160
+ /** Addon id of the destination that owns this backup (e.g. `local-backup`, `s3-backup`). */
14161
+ destinationId: string().optional(),
14162
+ label: string().optional(),
14163
+ createdAt: number(),
14164
+ sizeBytes: number(),
14165
+ locations: array(string()).optional(),
14166
+ encryption: BackupArchiveEncryptionSchema.optional()
14417
14167
  });
14418
- var BrokerConsumerAttributionSchema = object({
14168
+ var ArchiveEntrySchema = object({
14169
+ path: string(),
14419
14170
  kind: _enum([
14420
- "alexa",
14421
- "homekit",
14422
- "webrtc-browser",
14423
- "webrtc-mobile",
14424
- "webrtc-whep",
14425
- "rtsp-listen",
14426
- "derived-broker",
14427
- "recording",
14428
- "pipeline",
14429
- "snapshot",
14430
- "warmup",
14431
- "unknown"
14171
+ "file",
14172
+ "dir",
14173
+ "symlink"
14432
14174
  ]),
14175
+ sizeBytes: number(),
14176
+ mtime: number()
14177
+ });
14178
+ var ArchiveManifestSchema = object({
14179
+ archiveVersion: literal(1),
14180
+ createdAt: number(),
14181
+ dataDir: string(),
14182
+ locations: array(string()),
14183
+ entries: array(ArchiveEntrySchema),
14184
+ totalBytes: number(),
14185
+ totalFiles: number(),
14433
14186
  /**
14434
- * Free-form label intended to disambiguate consumers OF THE SAME kind
14435
- * on the same broker — e.g. user name, device alias. Should NOT repeat
14436
- * the kind / cam / cam-stream / sessionId (those are surfaced by
14437
- * dedicated fields). When empty the widget falls back to `${kind} ·
14438
- * <sessionId tail>`.
14439
- */
14440
- label: string().optional(),
14441
- /**
14442
- * Which part of the encoded plane this subscription consumes:
14443
- * - `'audio'` — audio packets only
14444
- * - `'video'` — video packets only
14445
- * - `'both'` — both video + audio (typical for AnnexB push paths
14446
- * that don't split the source RTP)
14447
- *
14448
- * Missing field means "unknown" — older callers and the legacy
14449
- * paths that haven't been migrated yet.
14187
+ * Live SQLite databases this archive carries as their OWNER's verified
14188
+ * snapshot instead of as files (D693). Absent on archives made before.
14450
14189
  */
14451
- media: _enum([
14452
- "audio",
14453
- "video",
14454
- "both"
14455
- ]).optional(),
14190
+ sqliteSnapshots: array(ArchiveSqliteSnapshotSchema).optional(),
14456
14191
  /**
14457
- * Codec the consumer receives AFTER any per-session re-encoding. For
14458
- * a WebRTC session this is the negotiated egress codec (e.g. `H264`,
14459
- * `H265`, `Opus`, `Pcmu`); for an RTSP restreamer it mirrors the
14460
- * source codec. Surfaced in the widget chip so operators see at a
14461
- * glance whether a viewer is on H.264 (Echo) or H.265 (Safari).
14192
+ * Owned database files the run did NOT carry (not selected): left out of the
14193
+ * tar and the entry list alike. Absent when every owned file was carried.
14462
14194
  */
14463
- targetCodec: string().optional(),
14195
+ omittedDatabases: array(DataStoreDatabaseSchema).optional()
14196
+ });
14197
+ var LocationStatSchema = object({
14198
+ name: string(),
14199
+ sizeBytes: number(),
14200
+ fileCount: number(),
14201
+ present: boolean()
14202
+ });
14203
+ /** Lifecycle of a backup run. Terminal states: succeeded / failed / cancelled. */
14204
+ var BackupRunStateSchema = _enum([
14205
+ "queued",
14206
+ "running",
14207
+ "succeeded",
14208
+ "failed",
14209
+ "cancelled"
14210
+ ]);
14211
+ /**
14212
+ * Where a running backup currently is. `queued` before it starts,
14213
+ * `building` while the tar.gz is being staged, `uploading` during the
14214
+ * per-destination fan-out, `done` once terminal.
14215
+ */
14216
+ var BackupRunPhaseSchema = _enum([
14217
+ "queued",
14218
+ "building",
14219
+ "uploading",
14220
+ "done"
14221
+ ]);
14222
+ /**
14223
+ * Observable state of one backup run — readable WHILE it runs via
14224
+ * `backup.listRuns`. This is what makes the execution queue and
14225
+ * `backup.cancel` usable: the 2026-09-04 incident (two concurrent
14226
+ * multi-GB builds, staging 5.1 GB → 18 GB, load 62) was only
14227
+ * diagnosable with `du` because nothing reported that runs existed or
14228
+ * how large the staged archive had grown.
14229
+ */
14230
+ var BackupRunSchema = object({
14231
+ /** Stable run id — the handle `backup.cancel` takes. */
14232
+ id: string(),
14233
+ state: BackupRunStateSchema,
14234
+ phase: BackupRunPhaseSchema,
14464
14235
  /**
14465
- * Whether the broker is re-encoding for this consumer:
14466
- * - `'passthrough'` — bytes flow source→consumer without ffmpeg
14467
- * - `'repacketize'` — RTP payload is re-packetized but NOT re-encoded
14468
- * (preserves frames, just rebuilds headers; e.g.
14469
- * the H.265 repacketizer path)
14470
- * - `'transcode'` — a full encode/decode round (ffmpeg, libx264,
14471
- * libav…); the most expensive path
14236
+ * Resolved destination location ids. Empty while queued (targets are
14237
+ * resolved when the run starts, against the then-current policies).
14472
14238
  */
14473
- transport: _enum([
14474
- "passthrough",
14475
- "repacketize",
14476
- "transcode"
14477
- ]).optional(),
14478
- /** Remote peer IP if the consumer terminates a network socket. */
14479
- remoteAddr: string().optional(),
14239
+ destinationIds: array(string()).readonly(),
14240
+ label: string().optional(),
14241
+ /** ms-epoch when the run was submitted (trigger call / schedule fire). */
14242
+ requestedAt: number(),
14243
+ /** ms-epoch when the run left the queue and started building. */
14244
+ startedAt: number().optional(),
14245
+ /** ms-epoch when the run reached a terminal state. */
14246
+ finishedAt: number().optional(),
14247
+ /** Compressed bytes of the staging archive written so far. */
14248
+ stagedBytes: number(),
14249
+ /** Final staged archive size, once the build phase completes. */
14250
+ archiveSizeBytes: number().optional(),
14251
+ /** Bytes pushed to the destination currently uploading. */
14252
+ uploadedBytes: number(),
14253
+ /** Destinations where the archive fully landed (uploaded + indexed). */
14254
+ completedDestinationIds: array(string()).readonly(),
14255
+ /** Destinations that failed during the fan-out. */
14256
+ failedDestinationIds: array(string()).readonly(),
14257
+ /** Failure message when `state === 'failed'`. */
14258
+ error: string().optional(),
14480
14259
  /**
14481
- * Server-read User-Agent of the originating client; enriched by the hub
14482
- * from the tRPC request context (browser sessions).
14260
+ * 1-based place in the execution queue — 1 = runs next. Present only
14261
+ * while `state === 'queued'`. Stamped by the orchestrator from the
14262
+ * queue's OWN pending order, never derived from timestamps, so the
14263
+ * UI cannot show an order the executor will not honour.
14483
14264
  */
14484
- userAgent: string().optional(),
14485
- /** Authenticated user id (CamStack OAuth subject) when known. */
14486
- userId: string().optional(),
14487
- /** Higher-level session identifier (Alexa Echo sessionId, HAP session, …). */
14488
- sessionId: string().optional(),
14489
- /** Free-form key/value extras (e.g. clientHints from a WebRTC offer). */
14490
- extra: record(string(), string()).optional()
14491
- }).readonly();
14492
- var BrokerEncodedClientSchema = object({
14493
- /** Stable id assigned on attach; used by `killClient`. */
14494
- id: string(),
14495
- /** Which broker fanout the subscriber rides — annexB encoded packets vs raw RTP. */
14496
- channel: _enum(["annexb", "rtp"]),
14497
- attribution: BrokerConsumerAttributionSchema,
14498
- subscribedAt: number(),
14499
- /** Total packets delivered (annex-B EncodedPackets or RTP byte-buffers). */
14500
- packetsDelivered: number()
14501
- });
14502
- var BrokerClientsSchema = object({
14503
- rtsp: array(BrokerRtspClientSchema).readonly(),
14504
- audio: array(BrokerAudioClientSchema).readonly(),
14505
- encoded: array(BrokerEncodedClientSchema).readonly(),
14506
- pipeClients: number(),
14507
- /** Total encoded + raw-RTP callback subscriber count (sum of `encoded[].length`). */
14508
- encodedSubscribers: number()
14265
+ queuePosition: number().int().min(1).optional()
14509
14266
  });
14510
14267
  /**
14511
- * How many people are watching one camera LIVE right now — see
14512
- * `getLiveViewerCounts`. A camera with nobody watching is absent from the list
14513
- * (so `viewers` is always at least 1): the listing carries the cameras that
14514
- * have viewers, never a row of zeros.
14268
+ * Result of `backup.trigger`. The call still resolves when the run
14269
+ * terminates (compat with schedule-driven runs and the admin UI), but
14270
+ * it now names the run and says whether it had to WAIT: a trigger that
14271
+ * arrives while another run is in flight is enqueued (or joined onto
14272
+ * an identical already-queued run), never started concurrently.
14515
14273
  */
14516
- var LiveViewerCountSchema = object({
14517
- deviceId: number().int().nonnegative(),
14518
- viewers: number().int().positive()
14519
- });
14520
- _enum([
14521
- "reconnecting",
14522
- "sleeping",
14523
- "offline",
14524
- "disabled",
14525
- "waking"
14526
- ]);
14527
- var VideoCodecTargetSchema = _enum([
14528
- "h264",
14529
- "h265",
14530
- "copy"
14531
- ]);
14532
- var AudioCodecTargetSchema = _enum([
14533
- "aac",
14534
- "opus",
14535
- "pcmu",
14536
- "copy",
14537
- "none"
14538
- ]);
14539
- var GetStreamWithCodecInputSchema = object({
14540
- deviceId: number().int().nonnegative(),
14541
- /** Target codec. `'copy'` = passthrough (no re-encode). */
14542
- video: VideoCodecTargetSchema,
14543
- /** Target audio codec. `'copy'` = passthrough. Defaults to `'aac'`. */
14544
- audio: AudioCodecTargetSchema.optional(),
14545
- /** Target a profile's assigned source camStream. Required to resolve a source. */
14546
- profile: CamProfileSchema.optional(),
14547
- /** Optional output resolution target. When set: drives the transcode
14548
- * output scale (downscale toward WxH), and — when `profile` is absent —
14549
- * selects the published source closest to this resolution. Ignored for
14550
- * `video:'copy'` scaling (a copy can't be rescaled), but still used for
14551
- * source selection. */
14552
- targetResolution: object({
14553
- width: number().int().positive(),
14554
- height: number().int().positive()
14555
- }).optional(),
14556
- /** Extra ffmpeg output flags appended verbatim by the consumer (in code).
14557
- * Folded into the pipeline key so different args never share a child. */
14558
- outputArgs: array(string()).optional(),
14559
- /** Opt-in: carry transcoded audio over an RTP sidecar grafted into the
14560
- * restreamer SDP (only for re-encoded audio targets — aac/opus/pcmu).
14561
- * Default/absent = the live-verified video-only egress. */
14562
- audioEgress: boolean().optional(),
14563
- /** Host to embed in the returned restream `url`. The broker is hub-only, so
14564
- * its restream URLs default to `127.0.0.1` (consumable only from the hub).
14565
- * A REMOTE consumer (e.g. a recorder running on an agent designated as the
14566
- * recording node) passes the hub's cluster-resolvable host here so the
14567
- * returned `rtsp://<host>:<port>/<token>` is dialable from that node. The
14568
- * port/token (and the underlying restreamer, bound on 0.0.0.0) are
14569
- * unchanged — `hostname` only rewrites the client-facing dial address.
14570
- * Absent/empty = `127.0.0.1` (the hub-local default). */
14571
- hostname: string().optional(),
14572
- tag: string().optional()
14573
- });
14574
- var RtpSourceSchema = object({
14575
- url: string(),
14576
- /**
14577
- * The SENTRY counterpart of {@link url} — the dial that asks for the stream
14578
- * WITHOUT asking for the camera — or `null` when this camera offers none.
14579
- *
14580
- * The broker answers it because the broker is the only thing that can:
14581
- * eligibility is `sentryEligible` (a BATTERY camera that DECLARES the signals
14582
- * cap), asked of facts only the broker holds. A consumer re-deriving it from
14583
- * cap bindings would be a second authority, and two authorities on the same
14584
- * question is how a composite camera ends up dialling a sleeping camera
14585
- * because its own copy of the rule was a release behind.
14586
- *
14587
- * `null` is a real answer and is never a live url in disguise: a consumer
14588
- * that silently fell back to {@link url} would perform exactly the wake this
14589
- * field exists to prevent. Only a PASSTHROUGH acquisition can offer one — a
14590
- * transcode hands back an egress the broker is feeding itself, which has no
14591
- * sentry counterpart.
14592
- */
14593
- sentryUrl: string().nullable().optional(),
14594
- videoCodec: _enum(["H264", "H265"]),
14595
- audioCodec: string(),
14596
- resolution: object({
14597
- width: number().int().positive(),
14598
- height: number().int().positive()
14599
- }),
14600
- transcoded: boolean(),
14601
- encoder: string(),
14602
- pipelineKey: string()
14274
+ var BackupTriggerResultSchema = object({
14275
+ /** The run this trigger mapped to — poll it via `listRuns`, stop it via `cancel`. */
14276
+ runId: string(),
14277
+ /** True when the run waited behind an in-flight run instead of starting immediately. */
14278
+ queued: boolean(),
14279
+ /** True when this trigger was coalesced onto an identical already-queued run. */
14280
+ joined: boolean(),
14281
+ /** True when the run was cancelled before completing every destination. */
14282
+ cancelled: boolean(),
14283
+ /** One entry per destination the archive landed at (partial on cancel). */
14284
+ entries: array(BackupEntrySchema).readonly()
14603
14285
  });
14604
14286
  /**
14605
- * The encode request — **structured and serialisable, with NO raw-flag escape
14606
- * hatch.** This is deliberate and it is the one lesson taken from
14607
- * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
14608
- * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
14609
- * adding a flag silently forks the shared child, and two consumers that mean
14610
- * the same thing but spell it differently never share. Here every knob is a
14611
- * NAMED field: a new requirement becomes a schema field (and a codegen run),
14612
- * never an opaque array.
14287
+ * A backup schedule — the N:M "entry" that binds one cron cadence to a
14288
+ * SET of destination locations. Supersedes the per-location cron on
14289
+ * `BackupDestinationPolicy`: an operator creates a schedule, picks the
14290
+ * `backups` locations it should write to, and the orchestrator fans a
14291
+ * single archive out to all of them when the cron fires.
14613
14292
  *
14614
- * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
14615
- * The operator-facing derived-stream transform editor still has them — that is
14616
- * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
14617
- * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
14618
- */
14619
- var EgressEncodeSchema = EncodeProfileSchema.omit({
14620
- inputArgs: true,
14621
- outputArgs: true
14622
- });
14623
- /**
14624
- * How the encoder is bounded. `'tight'` is a one-second VBV window for a
14625
- * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
14626
- * seconds, letting a keyframe spike borrow from the next second (a browser,
14627
- * an Echo). Named rather than numeric so the INTENT survives.
14293
+ * `retentionCount` is per-schedule (D-decision 2026-07-28): every
14294
+ * location targeted by this schedule keeps this many archives from
14295
+ * this schedule's runs.
14296
+ *
14297
+ * `dataSources` optionally narrows which top-level state locations
14298
+ * (db, addons, tls, …) are archived; omitted = the orchestrator's
14299
+ * default full set.
14628
14300
  */
14629
- var EgressRateControlSchema = _enum(["tight", "relaxed"]);
14630
- var EgressTranscodeRequestSchema = object({
14631
- deviceId: number().int().nonnegative(),
14632
- /** Which published stream to read. */
14633
- source: discriminatedUnion("kind", [object({
14634
- kind: literal("profile"),
14635
- profile: CamProfileSchema
14636
- }), object({
14637
- kind: literal("cam-stream"),
14638
- camStreamId: string().min(1)
14639
- })]),
14640
- encode: EgressEncodeSchema,
14641
- rateControl: EgressRateControlSchema.optional(),
14301
+ var BackupScheduleSchema = object({
14302
+ /** Stable id. Generated by the orchestrator on first upsert if absent. */
14303
+ id: string(),
14304
+ /** Operator-facing display name. */
14305
+ label: string(),
14306
+ /** 5-field POSIX cron. Empty = disabled cadence (kept for editing). */
14307
+ cron: string(),
14308
+ /** Master on/off toggle for the whole schedule. */
14309
+ enabled: boolean(),
14310
+ /** `backups`-location ids this schedule writes to (fan-out set). */
14311
+ locationIds: array(string()).readonly(),
14312
+ /** Archives kept per targeted location for this schedule. */
14313
+ retentionCount: number().int().min(1).max(1e3),
14314
+ /** Optional subset of source locations to include; omitted = all. */
14315
+ dataSources: array(string()).readonly().optional(),
14642
14316
  /**
14643
- * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
14644
- * out-of-band extradata and needs `dump_extra` on both the copy and encode
14645
- * branches. Enumerated, not free text.
14317
+ * Which database files this schedule carries. Absent = `['settings']`
14318
+ * (an unstated ask is the minimum, D382). Events and history run weekly,
14319
+ * settings nightly (D852).
14646
14320
  */
14647
- bitstreamFilter: _enum([
14648
- "dump_extra",
14649
- "h264_mp4toannexb",
14650
- "hevc_mp4toannexb"
14651
- ]).optional(),
14321
+ databases: array(DataStoreDatabaseSchema).readonly().optional(),
14652
14322
  /**
14653
- * Publish the transcode as a LOCAL push cam stream, instead of leaving the
14654
- * consumer to dial the returned url. The broker picks the id and returns it
14655
- * as `camStreamId` — a caller-supplied one would be circular, since the
14656
- * sharing key is computed FROM this request.
14657
- *
14658
- * The url is still returned and still the contract for a transcode pinned to
14659
- * another node. But dialling it locally costs an RTSP round trip that changes
14660
- * the transport underneath the consumer: a dialled stream is an RTP source,
14661
- * so `isRtpSource()` is true and the session takes the RTP-passthrough +
14662
- * repacketizer branch. The push branch — the one the derived mechanism has
14663
- * live hours on — is never reached. Measured on Alexa: broker registered, RTP
14664
- * arriving, key frame arriving, black screen, on a chain healthy at every
14665
- * other point.
14666
- *
14667
- * Same idea the transport already applies to CALLS, where `classifyCapRoute`
14668
- * gives priority to `hub-in-process` so a local call never leaves the node.
14669
- * This is that rule for media.
14323
+ * ms-epoch of the last tick that FIRED this schedule. Stamped before the
14324
+ * archive runs (it is the dedupe anchor), so it says "attempted", never
14325
+ * "succeeded" — a run refused by every destination stamps it too.
14670
14326
  */
14671
- publishLocally: boolean().optional(),
14672
- pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
14327
+ lastAttemptAt: number().optional(),
14328
+ /** ms-epoch of the last run of this schedule that landed at ≥1 destination. */
14329
+ lastSuccessAt: number().optional(),
14330
+ /** Why the last fired run failed, verbatim. Absent when it succeeded. */
14331
+ lastError: string().optional(),
14332
+ /** ms-epoch of next computed firing (read-only, filled on list). */
14333
+ nextRunAt: number().optional()
14334
+ });
14335
+ method(_void(), array(BackupDestinationInfoSchema).readonly(), { auth: "admin" }), method(object({
14336
+ /** Subset of registered `backup-destination` addon ids to write to. */
14337
+ destinations: array(string()).optional(),
14338
+ locations: array(string()).optional(),
14339
+ label: string().optional(),
14673
14340
  /**
14674
- * Operator/consumer override for decode hardware. ABSENT is the normal case
14675
- * and the one that matters: the broker then resolves the backend from the
14676
- * DECODER ADDON's per-node `probedBestHwaccel` (see
14677
- * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
14678
- * on this hardware — never the raw kernel resolver's qsv-first order.
14341
+ * Per-run retention override applied to every targeted
14342
+ * destination. Used by schedule-driven runs (per-entry
14343
+ * retention). Omitted = each destination's own policy
14344
+ * retention (manual runs).
14679
14345
  */
14680
- decodeHwAccel: _enum([
14681
- "auto",
14682
- "none",
14683
- "videotoolbox",
14684
- "vaapi",
14685
- "qsv",
14686
- "cuda"
14687
- ]).optional(),
14346
+ retentionCount: number().int().min(1).max(1e3).optional(),
14347
+ /** Database files to carry; omitted = `['settings']`. */
14348
+ databases: array(DataStoreDatabaseSchema).optional()
14349
+ }).optional(), BackupTriggerResultSchema, {
14350
+ kind: "mutation",
14351
+ auth: "admin"
14352
+ }), method(_void(), array(BackupRunSchema).readonly(), { auth: "admin" }), method(object({ runId: string() }), object({ cancelled: boolean() }), {
14353
+ kind: "mutation",
14354
+ auth: "admin"
14355
+ }), method(_void(), array(BackupEntrySchema).readonly(), { auth: "admin" }), method(_void(), array(LocationStatSchema).readonly(), { auth: "admin" }), method(object({
14356
+ destinationId: string(),
14357
+ backupId: string()
14358
+ }), ArchiveManifestSchema.nullable(), { auth: "admin" }), method(object({
14359
+ destinationId: string(),
14360
+ backupId: string(),
14688
14361
  /**
14689
- * Host to embed in the returned restream `url`. The broker mints hub-local
14690
- * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
14691
- * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
14692
- * dialable from there. Same contract as `getStreamWithCodec.hostname` —
14693
- * `substituteRtspHost` rewrites only the dial address, never the restreamer.
14362
+ * Optional whitelist — only restore these top-level locations
14363
+ * from the archive. Default = every location the archive
14364
+ * carries (full restore). The boot-time apply hook reads
14365
+ * this list and skips entries whose path doesn't start with
14366
+ * any of them.
14694
14367
  */
14695
- hostname: string().optional(),
14696
- /** Attribution for the broker panel. Never part of the sharing key. */
14697
- tag: string().optional()
14698
- });
14699
- var EgressTranscodeSchema = object({
14700
- /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
14701
- url: string(),
14702
- /** Release handle. Refcounted — the child dies when the last holder releases. */
14703
- pipelineKey: string(),
14704
- videoCodec: _enum(["H264", "H265"]),
14705
- resolution: object({
14706
- width: number().int().positive(),
14707
- height: number().int().positive()
14708
- }),
14709
- transcoded: boolean(),
14710
- encoder: string(),
14711
- /**
14712
- * The decode backend the child ACTUALLY ran with — `null` for software.
14713
- * Returned rather than assumed: a consumer that asked for hardware and got
14714
- * software needs to be able to see that without reading the broker's logs.
14715
- */
14716
- decodeHwAccel: string().nullable(),
14368
+ locations: array(string()).optional(),
14717
14369
  /**
14718
- * Set when `publishLocally` was honoured: attach to THIS instead of dialling
14719
- * `url`, and the session takes the push/deframe transport rather than the
14720
- * RTP-passthrough one. `null` means the consumer must dial.
14370
+ * The backup SECRET key, for an encrypted archive (ignored for a plain
14371
+ * one). The archive is decrypted into the hub's private staging
14372
+ * before the restart; the key is not stored. A wrong or missing key
14373
+ * is refused before the archive is downloaded.
14721
14374
  */
14722
- camStreamId: string().nullable()
14723
- });
14724
- method(object({
14725
- deviceId: number().int().nonnegative(),
14726
- /** The provider's signal code. */
14727
- code: string().min(1),
14728
- /** Ms epoch. Absent ⇒ now. */
14729
- at: number().optional()
14730
- }), object({
14731
- /** Whether the broker acted on it, and if not, why. */
14732
- accepted: boolean(),
14733
- reason: _enum([
14734
- "woke",
14735
- "hold-extended",
14736
- "not-enabled",
14737
- "no-consumer",
14738
- "unknown-code"
14739
- ])
14740
- }), { kind: "mutation" }), method(object({
14741
- deviceId: number().int().nonnegative(),
14742
- camStreamId: string().min(1),
14743
- kind: CamStreamKindSchema,
14744
- url: string().optional(),
14745
- codec: string().optional(),
14746
- resolution: CamStreamResolutionSchema.optional(),
14747
- fps: number().positive().optional(),
14748
- label: string().optional(),
14749
- deviceFeatures: array(string()).optional(),
14750
- autoEligible: boolean().optional(),
14751
- /** The slot the publisher says this stream IS — see `CameraStreamSchema.profileHint`. */
14752
- profileHint: CamProfileSchema.optional(),
14753
- metadata: record(string(), unknown()).optional(),
14754
- /** Required when kind === 'derived' — the source camStreamId the
14755
- * derived broker reads its encoded plane from.
14756
- * The runtime guard (broker manager, Task 10) enforces presence for
14757
- * kind === 'derived'; the schema stays permissive so ts-morph emits
14758
- * full field types instead of collapsing to ZodEffects. */
14759
- sourceCamStreamId: string().min(1).optional(),
14760
- /** Required when kind === 'derived' — the ffmpeg params for the
14761
- * spawn. Reuses the same brokerId for identical params (single-flight).
14762
- * Runtime guard in broker manager; schema is permissive (see above). */
14763
- encodeProfile: EncodeProfileSchema.optional()
14764
- }), object({ success: literal(true) }), {
14765
- kind: "mutation",
14766
- auth: "admin"
14767
- }), method(object({
14768
- deviceId: number().int().nonnegative(),
14769
- camStreamId: string().min(1)
14770
- }), object({ success: literal(true) }), {
14771
- kind: "mutation",
14772
- auth: "admin"
14773
- }), method(object({
14774
- deviceId: number().int().nonnegative(),
14775
- profile: CamProfileSchema,
14776
- camStreamId: string().min(1)
14777
- }), object({ success: literal(true) }), {
14778
- kind: "mutation",
14779
- auth: "admin"
14780
- }), method(object({
14781
- deviceId: number().int().nonnegative(),
14782
- profile: CamProfileSchema
14783
- }), object({ success: literal(true) }), {
14784
- kind: "mutation",
14785
- auth: "admin"
14786
- }), method(object({ deviceId: number().int().nonnegative() }), object({
14787
- derivedStreamsDeleted: array(string()).readonly(),
14788
- assignmentsPurged: boolean(),
14789
- probeSnapshotsDropped: number().int().nonnegative(),
14790
- rtspTokenRowsDeleted: number().int().nonnegative()
14791
- }), {
14375
+ backupSecretKey: string().optional()
14376
+ }), _void(), {
14792
14377
  kind: "mutation",
14793
14378
  auth: "admin"
14794
- }), method(object({
14795
- deviceId: number(),
14796
- /** Absent = the LOWEST assigned profile — a notification attachment is
14797
- * watched on a phone, and the cheap rendition is the right default. */
14798
- profile: CamProfileSchema.optional(),
14799
- aroundMs: number(),
14800
- preRollSec: number().min(0).max(20).default(3),
14801
- postRollSec: number().min(0).max(20).default(5),
14802
- format: _enum(["gif", "mp4"]).default("gif"),
14803
- maxWidth: number().int().min(120).max(1920).default(480),
14804
- /** GIF only — MP4 keeps the source cadence. */
14805
- fps: number().int().min(1).max(15).default(5),
14806
- /**
14807
- * Playback rate. A notification clip is GLANCED at on a lock screen,
14808
- * not watched, so 2x is the default: the recipient sees the whole
14809
- * passage in half the time and the GIF is half the bytes. `1` is real
14810
- * time. Applies to MP4 as well — the operator set a speed, not a GIF
14811
- * speed.
14812
- */
14813
- speed: number().min(1).max(8).default(2)
14814
- }), object({
14815
- base64: string(),
14816
- mime: string(),
14817
- bytes: number().int(),
14818
- /** The profile actually rendered (what the default resolved to). */
14819
- profile: CamProfileSchema,
14820
- durationMs: number()
14379
+ }), method(_void(), BackupEncryptionStatusSchema, { auth: "admin" }), method(object({}), object({
14380
+ keyId: string(),
14381
+ secretKey: string(),
14382
+ publicKey: string(),
14383
+ createdAt: number(),
14384
+ replacedKeyId: string().nullable()
14821
14385
  }), {
14822
14386
  kind: "mutation",
14823
14387
  auth: "admin"
14824
- }), method(object({
14825
- deviceId: number(),
14826
- /** Absent = the largest H.264 rendition at or below 1080p, which is
14827
- * also the one that can be copied. Falls back to whatever the ring
14828
- * actually retained, and the answer says which. */
14829
- profile: CamProfileSchema.optional(),
14830
- aroundMs: number(),
14831
- preSeconds: number().min(0).max(20).default(4),
14832
- postSeconds: number().min(0).max(20).default(6),
14833
- kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
14834
- /** GIF geometry. The video keeps the source's own. */
14835
- gifMaxWidth: number().int().min(120).max(1280).default(640),
14836
- /**
14837
- * The gif's own PLAYBACK rate in frames per second — what the finished
14838
- * gif runs at, not how many source frames feed it. The decimation that
14839
- * feeds it samples `gifFps / gifSpeed` source frames per second, so at
14840
- * the defaults a 12 fps gif is built out of 3 source frames a second.
14841
- */
14842
- gifFps: number().int().min(1).max(15).default(12),
14843
- /**
14844
- * How fast the GIF plays against real time, independent of `speed`.
14845
- *
14846
- * 4× by default, by operator request: a notification gif is glanced at
14847
- * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
14848
- * a separate knob from `speed` even though both now default to 4 —
14849
- * a caller wanting a real-time video and a fast gif must not have to
14850
- * choose.
14851
- */
14852
- gifSpeed: number().min(1).max(8).default(4),
14853
- /**
14854
- * Playback rate of the VIDEO. Also 4× by default, by operator decision.
14855
- *
14856
- * `1` is real time and is the ONLY value that allows the copy branch —
14857
- * anything else forces `libx264` over the window. That was priced
14858
- * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
14859
- * on a real 615 720p cut, against 922 KB for the copy it replaces. A
14860
- * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
14861
- * once the decode is forced the width stops being free.
14862
- */
14863
- speed: number().min(1).max(8).default(4)
14864
- }), EventMediaProductionSchema, {
14865
- kind: "mutation",
14866
- auth: "admin"
14867
- }), method(object({ handle: string() }), object({
14868
- base64: string(),
14869
- mime: string(),
14870
- bytes: number().int()
14871
- }).nullable(), {
14388
+ }), method(object({}), object({ disabledKeyId: string().nullable() }), {
14872
14389
  kind: "mutation",
14873
14390
  auth: "admin"
14874
- }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
14875
- probed: boolean(),
14876
- summary: string()
14391
+ }), method(object({ backupSecretKey: string() }), object({
14392
+ valid: boolean(),
14393
+ keyId: string().nullable(),
14394
+ /** The hub knows this key (active or retired). */
14395
+ known: boolean(),
14396
+ /** It is the key new archives are encrypted to. */
14397
+ active: boolean(),
14398
+ error: string().optional()
14877
14399
  }), {
14878
14400
  kind: "mutation",
14879
14401
  auth: "admin"
14880
- }), method(object({ brokerId: string() }), BrokerClientsSchema), method(_void(), array(LiveViewerCountSchema).readonly()), method(object({
14881
- brokerId: string(),
14882
- channel: _enum(["rtsp", "audio"]),
14883
- handle: string()
14884
- }), object({ killed: boolean() }), {
14885
- kind: "mutation",
14886
- auth: "admin"
14887
- }), method(object({
14888
- deviceId: number().int().nonnegative(),
14889
- profile: CamProfileSchema
14890
- }), object({ success: boolean() }), {
14891
- kind: "mutation",
14892
- auth: "admin"
14893
14402
  }), method(object({
14894
- streamId: string(),
14895
- format: StreamFormatSchema
14896
- }), object({ url: string() })), method(GetStreamWithCodecInputSchema, RtpSourceSchema, {
14897
- kind: "mutation",
14898
- auth: "admin"
14899
- }), method(object({ pipelineKey: string() }), object({
14900
- released: boolean(),
14901
- refcount: number().int().nonnegative()
14902
- }), {
14403
+ destinationId: string(),
14404
+ backupId: string()
14405
+ }), _void(), {
14903
14406
  kind: "mutation",
14904
14407
  auth: "admin"
14905
- }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
14408
+ }), method(object({ destinationId: string() }), array(BackupArchiveEntrySchema).readonly(), { auth: "admin" }), method(object({
14409
+ locationId: string(),
14410
+ enabled: boolean(),
14411
+ retentionCount: number().int().min(1).max(1e3),
14412
+ label: string().optional()
14413
+ }), _void(), {
14906
14414
  kind: "mutation",
14907
14415
  auth: "admin"
14908
- }), method(object({ pipelineKey: string() }), object({
14909
- released: boolean(),
14910
- refcount: number().int().nonnegative()
14911
- }), {
14416
+ }), method(object({
14417
+ cron: string(),
14418
+ count: number().int().min(1).max(20).optional()
14419
+ }), object({
14420
+ ok: boolean(),
14421
+ error: string().optional(),
14422
+ nextRuns: array(number()).readonly()
14423
+ })), method(_void(), array(BackupScheduleSchema).readonly(), { auth: "admin" }), method(_void(), object({
14424
+ answered: boolean(),
14425
+ databases: array(DataStoreDatabaseSchema).readonly()
14426
+ }), { auth: "admin" }), method(object({
14427
+ id: string().optional(),
14428
+ label: string(),
14429
+ cron: string(),
14430
+ enabled: boolean(),
14431
+ locationIds: array(string()).readonly(),
14432
+ retentionCount: number().int().min(1).max(1e3),
14433
+ dataSources: array(string()).readonly().optional(),
14434
+ databases: array(DataStoreDatabaseSchema).readonly().optional()
14435
+ }), BackupScheduleSchema, {
14912
14436
  kind: "mutation",
14913
14437
  auth: "admin"
14914
- }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
14915
- subscriptionId: string(),
14916
- maxCount: number().int().positive().default(8)
14917
- }), array(DecodedAudioChunkSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(object({
14918
- brokerId: string(),
14919
- seconds: number().min(0).max(30)
14920
- }), _void(), {
14438
+ }), method(object({ id: string() }), _void(), {
14921
14439
  kind: "mutation",
14922
14440
  auth: "admin"
14923
- }), method(object({ brokerId: string() }), object({
14924
- configuredSec: number(),
14925
- bufferedMs: number(),
14926
- packetCount: number()
14927
- })), method(_void(), number()), method(object({ hostname: string().optional() }), array(RtspRestreamEntrySchema).readonly()), method(object({
14928
- brokerId: string(),
14929
- hostname: string().optional()
14930
- }), RtspRestreamEntrySchema.nullable()), method(object({ brokerId: string() }), string().nullable(), {
14931
- kind: "mutation",
14932
- auth: "admin"
14933
- }), method(object({
14934
- brokerId: string(),
14935
- enabled: boolean()
14936
- }), _void(), {
14937
- kind: "mutation",
14938
- auth: "admin"
14939
- }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
14940
- muted: boolean(),
14941
- /**
14942
- * How many live non-derived brokers currently hold the mute. Purely
14943
- * diagnostic: `muted` is the policy and is authoritative on its own
14944
- * (it applies to brokers that do not exist yet), while this says
14945
- * whether anything is presently being silenced.
14946
- */
14947
- appliedBrokers: number().int().nonnegative()
14948
- })), method(object({
14949
- deviceId: number().int(),
14950
- muted: boolean()
14951
- }), object({
14952
- muted: boolean(),
14953
- appliedBrokers: number().int().nonnegative()
14954
- }), {
14955
- kind: "mutation",
14956
- auth: "admin"
14957
- }), object({
14958
- deviceId: number().int().nonnegative(),
14959
- camStreamId: string(),
14960
- profile: CamProfileSchema
14961
- }), object({
14962
- deviceId: number().int().nonnegative(),
14963
- camStreamId: string()
14964
- }), object({
14965
- deviceId: number().int().nonnegative(),
14966
- camStreamId: string(),
14967
- brokerId: string()
14968
14441
  });
14969
- var StreamCodecSchema = _enum([
14970
- "h264",
14971
- "h265",
14972
- "hevc",
14973
- "av1",
14974
- "mjpeg",
14975
- "vp8",
14976
- "vp9"
14442
+ /**
14443
+ * `broker` — unified pub/sub broker registry, system-scoped collection.
14444
+ *
14445
+ * The cap models any kind-tagged message broker the user wants to
14446
+ * register and that other addons might consume. The first two kinds
14447
+ * are MQTT (mosquitto / aedes embedded / cloud bridge) and
14448
+ * Home Assistant (HA WebSocket — subscribe_entities + call_service).
14449
+ * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
14450
+ * the same surface.
14451
+ *
14452
+ * Why one cap, not one cap per kind:
14453
+ * - The integrations page wants a single table of "every broker"
14454
+ * across kinds; a unified cap drives it without join logic.
14455
+ * - Generic admin operations (add / remove / test / status) are
14456
+ * identical across kinds — duplicating them per cap was busywork.
14457
+ * - The `network-access` cap precedent: same interface, many
14458
+ * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
14459
+ *
14460
+ * What stays kind-specific:
14461
+ * - `getBrokerConfig` payload (kind decides whether it returns
14462
+ * mqtt URL+creds, HA baseUrl+token, …) — typed as
14463
+ * `Record<string, unknown>` at the wire; consumers narrow.
14464
+ * - `publish` / `subscribe` / `getState` arguments (target / filter /
14465
+ * key) — also typed as `Record<string, unknown>` so each kind can
14466
+ * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
14467
+ * without rebreaking the cap signature.
14468
+ * - `add` `settings` payload (different fields per kind, validated by
14469
+ * the kind-specific provider).
14470
+ *
14471
+ * Bidirectionality:
14472
+ * - `publish` is RPC — synchronous result (provider-defined).
14473
+ * - `subscribe` returns a subscription handle; the actual message
14474
+ * stream flows over the typed event-bus as `broker.message` events
14475
+ * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
14476
+ * event handler. Subscriptions persist across reconnects — the
14477
+ * provider re-subscribes upstream after a transport drop.
14478
+ *
14479
+ * Why event-bus push (not a subscription RPC stream):
14480
+ * - Matches the rest of CamStack's D8 contract — events for telemetry,
14481
+ * RPC for loss-is-a-bug. Broker messages are telemetry (a single
14482
+ * drop is recoverable via `getState({key})`).
14483
+ * - Frees `broker.subscribe` from holding a long-lived RPC channel.
14484
+ * - Cross-process: events already route through `$event-bus`; no
14485
+ * per-broker wire-shaping needed.
14486
+ */
14487
+ var BrokerStatusEnum = _enum([
14488
+ "connected",
14489
+ "disconnected",
14490
+ "connecting",
14491
+ "auth-failed",
14492
+ "unreachable",
14493
+ "error"
14977
14494
  ]);
14978
- var PickStreamRequirementsSchema = object({
14979
- /**
14980
- * Codecs the consumer can decode without an intermediate transcode.
14981
- * Match is case-insensitive against the camera stream's `codec`.
14982
- * Default (omitted / empty array): no codec filter — useful when the
14983
- * consumer just wants "the highest-quality stream of any codec".
14984
- */
14985
- acceptCodecs: array(StreamCodecSchema).readonly().optional(),
14986
- /** Minimum vertical resolution. Streams shorter than this are dropped. */
14987
- minHeight: number().int().positive().optional(),
14988
- /**
14989
- * Maximum vertical resolution. Streams TALLER than this are dropped.
14990
- *
14991
- * A consumer can have a ceiling as real as its floor: Alexa documents
14992
- * 480p to 1080p, and a 4K stream is as unusable to an Echo as a 360p one.
14993
- * Without this the ceiling had to be re-implemented by every caller — and
14994
- * one caller implementing it privately is how a second scoring authority
14995
- * gets born.
14996
- */
14997
- maxHeight: number().int().positive().optional(),
14998
- /** Minimum horizontal resolution. */
14999
- minWidth: number().int().positive().optional(),
15000
- /**
15001
- * When true (default), `derived:*` streams (transcoded broker pipes)
15002
- * are excluded — the caller wants a SOURCE stream the broker can
15003
- * forward without a re-encode. Set false to allow derived in the
15004
- * picker (useful for a "best playable" fallback).
15005
- */
15006
- excludeDerived: boolean().optional(),
15007
- /**
15008
- * Bypass gate. When set, the picker returns a hit ONLY IF the device
15009
- * also exposes a non-derived stream in one of these (rejected) codecs.
15010
- * Mirrors the Alexa "bypass only when the natural path WOULD have
15011
- * transcoded" guard: if the device is already serving the consumer's
15012
- * codec end-to-end, there's nothing to optimise.
15013
- */
15014
- requireSiblingCodec: array(StreamCodecSchema).readonly().optional(),
15015
- /**
15016
- * Whether a stream the consumer CANNOT decode may still be picked, on the
15017
- * understanding that it will be transcoded.
15018
- *
15019
- * Default `false` — today's behaviour, and the right one for a bypass
15020
- * question ("is there a stream I can forward untouched?"). Set `true` to
15021
- * ask the larger question: "what is the best stream for me, transcoding if
15022
- * I must?" A stream that satisfies `acceptCodecs` always outranks one that
15023
- * does not, so a passthrough is never lost to a transcode; the answer says
15024
- * which it is in {@link PickedCamStreamSchema.transcodes}.
14495
+ var BrokerInfoSchema = object({
14496
+ /** Stable broker id. Persisted; survives addon restarts. */
14497
+ id: string(),
14498
+ /** Addon id of the provider that OWNS this broker.
15025
14499
  *
15026
- * This is what lets one picker serve both the bypass and the full source
15027
- * choice, instead of a consumer scoring privately when the bypass misses.
15028
- */
15029
- allowTranscode: boolean().optional(),
14500
+ * The `broker` cap is a system-scoped collection: several addons
14501
+ * register a `broker` provider, each owning a DISJOINT set of
14502
+ * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
14503
+ * `ha_*`). A broker therefore belongs to exactly one addon — like a
14504
+ * device belongs to one integration. The admin UI threads this id
14505
+ * back as the `{ addonId }` system-collection selector on every
14506
+ * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
14507
+ * `testConnection`), so the call routes to the OWNING provider
14508
+ * instead of defaulting to the first-registered one. */
14509
+ addonId: string(),
14510
+ /** Human-readable name (operator-chosen at add-time). */
14511
+ name: string(),
14512
+ /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
14513
+ kind: string(),
14514
+ status: BrokerStatusEnum,
14515
+ /** Free-form provider-specific info (HA version, MQTT broker
14516
+ * flavour, latency, mTLS active, …). */
14517
+ info: record(string(), unknown()),
14518
+ /** Ms epoch of the last connection probe / status update. */
14519
+ lastCheckedAt: number().nullable(),
14520
+ /** Last error message, when `status` indicates failure. */
14521
+ error: string().nullable()
14522
+ });
14523
+ var RegistryStatusSchema = object({
14524
+ brokerCount: number().int().nonnegative(),
14525
+ connectedCount: number().int().nonnegative()
14526
+ });
14527
+ /**
14528
+ * One entry per `broker` provider — which addon provides which broker
14529
+ * kind(s). The unified create picker reads this to know, for each
14530
+ * (addonId, kind) pair the operator can pick, where to route the
14531
+ * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
14532
+ * `{ addonId }` selector). A provider only advertises a kind here when
14533
+ * it implements `getSettingsSchema` for that kind (so the picker never
14534
+ * offers a kind whose creation form can't be rendered).
14535
+ */
14536
+ var BrokerProviderInfoSchema = object({
14537
+ /** Addon id of the `broker` provider this entry describes. */
14538
+ addonId: string(),
14539
+ /** Broker kinds this provider can create, with a display label. */
14540
+ kinds: array(object({
14541
+ kind: string(),
14542
+ label: string(),
14543
+ /**
14544
+ * Relative hub path to start an interactive account link (OAuth popup).
14545
+ * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
14546
+ */
14547
+ interactiveConnectPath: string().min(1).optional(),
14548
+ /** Relative hub path to disconnect the linked account (same popup pattern). */
14549
+ interactiveDisconnectPath: string().min(1).optional()
14550
+ }))
14551
+ });
14552
+ var ListInputSchema = object({
14553
+ /** Optional kind filter — `list({kind:'home-assistant'})` returns
14554
+ * only HA brokers. Omit to list every broker. */
14555
+ kind: string().optional() });
14556
+ var GetInputSchema = object({ id: string() });
14557
+ var AddInputSchema = object({
14558
+ kind: string().min(1),
14559
+ name: string().min(1),
15030
14560
  /**
15031
- * Consider ONLY streams the broker has bound to a profile slot.
14561
+ * ADOPT an existing id instead of minting a new one.
15032
14562
  *
15033
- * A consumer that dials the broker — which is every exporter, since none
15034
- * of them may open the camera's own URL — can do nothing with a source
15035
- * that has no slot: there is no restream to dial. Without this the picker
15036
- * can name such a stream, and each consumer discovers the dead end for
15037
- * itself: Alexa's `profileForCamStream` returns `null` and it quietly
15038
- * falls back, and HomeKit would have had to grow the same function again.
14563
+ * Written the day this cost an outage. A broker's id is not a detail: HA
14564
+ * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
14565
+ * lost from config and re-added as `ha_001` leaves every one of its devices
14566
+ * bound to a broker that no longer exists. Re-entering the password under the
14567
+ * ORIGINAL id turns a multi-step device migration back into re-entering a
14568
+ * password.
15039
14569
  *
15040
- * Default `false`, and deliberately so: the BROKER itself picks through
15041
- * here when it decides which source to BIND to a slot
15042
- * (`computeInitialAssignment`), and it must see the unbound ones or it
15043
- * could never bind them.
15044
- */
15045
- requireProfileBound: boolean().optional()
15046
- }).readonly();
15047
- var PickStreamPreferencesSchema = object({
15048
- /**
15049
- * Ordered list of provider prefixes (e.g. `['native:', 'rtsp:']`) the
15050
- * picker prefers — earlier entries win ties. Defaults to
15051
- * `['native:']`, mirroring Alexa's "prefer the native dial path".
15052
- */
15053
- preferredProviders: array(string()).readonly().optional(),
15054
- /**
15055
- * Resolution preference within the surviving candidates. `'highest'`
15056
- * picks the tallest stream; `'lowest'` picks the shortest (used by
15057
- * memory-constrained consumers / Apple Home guest sessions).
14570
+ * A provider MUST refuse an id that is already in use — adopting a live
14571
+ * broker's id would silently take it over.
15058
14572
  */
15059
- resolutionPreference: _enum(["highest", "lowest"]).optional(),
15060
- /**
15061
- * The height the consumer actually wants to DELIVER.
15062
- *
15063
- * Not a constraint — an ordering. With it set, the SMALLEST stream at or
15064
- * above the target wins, and only if nothing reaches it does the tallest
15065
- * take over. Pulling 1296 lines to draw 720 on a 1280x800 Echo panel costs
15066
- * a decode and buys nothing, and `resolutionPreference: 'highest'` cannot
15067
- * express that: it says "as big as possible", which is a different wish.
15068
- *
15069
- * Ignored when absent, so every existing caller keeps its ordering.
15070
- */
15071
- targetHeight: number().int().positive().optional()
15072
- }).readonly();
15073
- var PickedCamStreamSchema = object({
15074
- camStreamId: string(),
15075
- codec: string().optional(),
15076
- resolution: CamStreamResolutionSchema.optional(),
15077
- /**
15078
- * Whether serving this stream requires a decode + re-encode.
15079
- *
15080
- * `false` is a stream the consumer can take as it stands. Only ever `true`
15081
- * when the caller asked for it with `allowTranscode`, so a caller that did
15082
- * not ask cannot be handed a cost it never agreed to pay.
15083
- */
15084
- transcodes: boolean(),
15085
- /** One-line explanation of why this stream won — for logs / debug UI. */
15086
- reason: string(),
15087
- /**
15088
- * The broker profile slot this source is bound to, when it is bound.
15089
- *
15090
- * Answered HERE because every consumer needs it and none of them can get it
15091
- * from the `camStreamId` alone. Alexa wrote `profileForCamStream` for it —
15092
- * a second cap read plus a `null` branch — and HomeKit was about to write
15093
- * the same function a second time.
15094
- *
15095
- * Absent when the source has no slot, which a caller passing
15096
- * `requireProfileBound` can never see.
15097
- */
15098
- profile: CamProfileSchema.optional(),
15099
- /** The broker id of that slot, for logs that must name what was dialled. */
15100
- brokerId: string().optional(),
15101
- /**
15102
- * The broker restream URL to dial for this stream.
15103
- *
15104
- * An exporter must never open the camera's own URL — the broker is the one
15105
- * that dials the camera, and every consumer goes through its restream. So
15106
- * the picker answers with the thing a consumer may actually use, instead of
15107
- * naming a source and leaving each caller to look the URL up.
15108
- */
15109
- url: string().optional(),
15110
- /**
15111
- * Frames per second this stream really delivers, and where the number came
15112
- * from.
15113
- *
15114
- * It is here because getting it wrong is expensive and silent: a HomeKit
15115
- * accessory advertised 720p at 24 fps while the slot serving it ran at 9,
15116
- * and the controller rendered nothing at all. The source is carried with the
15117
- * value for the same reason the bitrate evidence is: `published` is what the
15118
- * publisher declares and `measured` is what the broker observed, and they
15119
- * are not interchangeable.
15120
- */
15121
- fps: number().positive().optional(),
15122
- fpsSource: _enum(["published", "measured"]).optional()
14573
+ id: string().min(1).optional(),
14574
+ /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
14575
+ * `{baseUrl,accessToken}`). Validated by the kind-specific provider
14576
+ * branch on receipt — invalid shape rejects the add. */
14577
+ settings: record(string(), unknown())
15123
14578
  });
15124
- DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()), method(object({
15125
- deviceId: number().int().nonnegative(),
15126
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
15127
- hostname: string().optional()
15128
- }), array(RtspRestreamEntrySchema).readonly()), method(object({
15129
- deviceId: number().int().nonnegative(),
15130
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
15131
- hostname: string().optional()
15132
- }), array(ProfileRtspEntrySchema).readonly()), method(object({
15133
- deviceId: number().int().nonnegative(),
15134
- requirements: PickStreamRequirementsSchema,
15135
- preferences: PickStreamPreferencesSchema.optional()
15136
- }), PickedCamStreamSchema.nullable()), object({
15137
- deviceId: number().int().nonnegative(),
15138
- camStreams: array(CameraStreamSchema).readonly()
15139
- }), object({
15140
- deviceId: number().int().nonnegative(),
15141
- profileSlots: array(ProfileSlotSchema).readonly()
14579
+ var AddResultSchema = object({ id: string() });
14580
+ var RemoveInputSchema = object({ id: string() });
14581
+ var TestConnectionResultSchema = discriminatedUnion("ok", [object({
14582
+ ok: literal(true),
14583
+ latencyMs: number().nonnegative()
15142
14584
  }), object({
15143
- online: boolean(),
15144
- slotStatuses: object({
15145
- high: ProfileSlotStatusSchema.optional(),
15146
- mid: ProfileSlotStatusSchema.optional(),
15147
- low: ProfileSlotStatusSchema.optional()
15148
- }),
15149
- slotErrors: object({
15150
- high: string().optional(),
15151
- mid: string().optional(),
15152
- low: string().optional()
15153
- }),
15154
- lastChangedAt: number()
14585
+ ok: literal(false),
14586
+ error: string()
14587
+ })]);
14588
+ var SettingsRecordSchema$1 = record(string(), unknown());
14589
+ var SettingsSchemaInputSchema = object({ kind: string() });
14590
+ var TestSettingsInputSchema = object({
14591
+ kind: string(),
14592
+ settings: SettingsRecordSchema$1
15155
14593
  });
15156
- EventCategory.DoorbellOnPressed;
15157
- var CompositionEventPlanSchema = object({
15158
- featureIndex: number().int().nonnegative(),
15159
- cap: string(),
15160
- eventName: string(),
15161
- category: _enum(EventCategory),
15162
- stateField: string(),
15163
- fieldKey: string()
14594
+ var TestSettingsResultSchema = discriminatedUnion("ok", [object({
14595
+ ok: literal(true),
14596
+ latencyMs: number().nonnegative().optional()
14597
+ }).strict(), object({
14598
+ ok: literal(false),
14599
+ error: string()
14600
+ })]);
14601
+ var SettingsSchemaResultSchema = unknown().nullable();
14602
+ var PublishInputSchema = object({
14603
+ brokerId: string(),
14604
+ /** Kind-specific routing target.
14605
+ * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
14606
+ * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
14607
+ target: record(string(), unknown()),
14608
+ /** Kind-specific payload.
14609
+ * MQTT: raw string / number / object (provider serialises).
14610
+ * HA: service-call `data` object (transition, brightness, …). */
14611
+ payload: unknown().optional()
15164
14612
  });
15165
- /** One refusal, named (D391). `path` points into the composition, e.g. `features[0].fields.celsius`. */
15166
- var CompositionProblemSchema = object({
15167
- code: _enum([
15168
- "reserved-variant",
15169
- "unknown-capability",
15170
- "not-composable",
15171
- "wrong-device-type",
15172
- "has-commands",
15173
- "unknown-field",
15174
- "nested-field",
15175
- "missing-field",
15176
- "kind-mismatch",
15177
- "may-be-null",
15178
- "source-missing",
15179
- "source-unreadable",
15180
- "source-capability-missing",
15181
- "source-field-unknown",
15182
- "expression-invalid",
15183
- "cycle",
15184
- "duplicate-target",
15185
- "not-item-array",
15186
- "item-field-reserved",
15187
- "target-missing",
15188
- "target-unreadable",
15189
- "not-replaceable"
15190
- ]),
15191
- path: string(),
15192
- message: string()
14613
+ var SubscribeInputSchema = object({
14614
+ brokerId: string(),
14615
+ /** Kind-specific filter.
14616
+ * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
14617
+ * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
14618
+ filter: record(string(), unknown())
15193
14619
  });
15194
- var CompositionFieldKindSchema = _enum([
15195
- "string",
15196
- "number",
15197
- "boolean",
15198
- "enum"
14620
+ var SubscribeResultSchema = object({
14621
+ /** Stable subscription id. Used to unsubscribe and to filter the
14622
+ * matching `broker.message` events on the bus. */
14623
+ subscriptionId: string() });
14624
+ var UnsubscribeInputSchema = object({
14625
+ brokerId: string(),
14626
+ subscriptionId: string()
14627
+ });
14628
+ var GetStateInputSchema = object({
14629
+ brokerId: string(),
14630
+ /** Kind-specific lookup key.
14631
+ * MQTT: topic string (returns the last retained message).
14632
+ * HA: entity_id (returns the cached entity state). */
14633
+ key: string()
14634
+ });
14635
+ method(ListInputSchema, array(BrokerInfoSchema)), method(GetInputSchema, BrokerInfoSchema.nullable()), method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }), method(AddInputSchema, AddResultSchema, {
14636
+ kind: "mutation",
14637
+ auth: "admin"
14638
+ }), method(RemoveInputSchema, _void(), {
14639
+ kind: "mutation",
14640
+ auth: "admin"
14641
+ }), method(GetInputSchema, TestConnectionResultSchema, {
14642
+ kind: "mutation",
14643
+ auth: "admin"
14644
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(object({
14645
+ id: string(),
14646
+ settings: SettingsRecordSchema$1
14647
+ }), _void(), {
14648
+ kind: "mutation",
14649
+ auth: "admin"
14650
+ }), method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }), method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }), method(TestSettingsInputSchema, TestSettingsResultSchema, {
14651
+ kind: "mutation",
14652
+ auth: "admin"
14653
+ }), method(PublishInputSchema, unknown(), {
14654
+ kind: "mutation",
14655
+ auth: "admin"
14656
+ }), method(SubscribeInputSchema, SubscribeResultSchema, {
14657
+ kind: "mutation",
14658
+ auth: "admin"
14659
+ }), method(UnsubscribeInputSchema, _void(), {
14660
+ kind: "mutation",
14661
+ auth: "admin"
14662
+ }), method(GetStateInputSchema, unknown().nullable(), { auth: "admin" }), method(_void(), RegistryStatusSchema);
14663
+ DeviceType.Camera;
14664
+ /** Stream delivery format. (Relocated from the retired `streaming-engine` cap.) */
14665
+ var StreamFormatSchema = _enum([
14666
+ "webrtc",
14667
+ "hls",
14668
+ "mjpeg",
14669
+ "rtsp"
15199
14670
  ]);
14671
+ /** A container `produceEventMedia` can emit. */
14672
+ var EventMediaKindSchema = _enum(["mp4", "gif"]);
15200
14673
  /**
15201
- * How a field says "unavailable" (D393, D663): `null` when its schema allows it;
15202
- * `unknown` — the enum's own word — for an ADDED field that has one; `release`
15203
- * for a REPLACED field of an existing device, whose claim is released so the
15204
- * native provider's reading shows again; else the (composed) device goes offline.
14674
+ * One produced artifact, referenced by HANDLE.
14675
+ *
14676
+ * Never inline bytes: a produced clip is 200 KB–5 MB and every consumer of this
14677
+ * method is in another runner ([D9](../../../../docs/decisions/adr-0009.md),
14678
+ * [D18](../../../../docs/decisions/adr-0018.md) — cross-process media is fetched
14679
+ * on demand, compressed, by handle). `bytes` is here so a caller can decide
14680
+ * whether it wants the fetch at all.
15205
14681
  */
15206
- var CompositionUnavailableModeSchema = _enum([
15207
- "null",
15208
- "offline",
15209
- "unknown",
15210
- "release"
15211
- ]);
14682
+ var EventMediaArtifactSchema = object({
14683
+ kind: EventMediaKindSchema,
14684
+ /** Opaque, single-camera, short-lived. Redeem with `fetchEventMedia`. */
14685
+ handle: string(),
14686
+ /**
14687
+ * The node holding the bytes — the ROUTING key for `fetchEventMedia`.
14688
+ *
14689
+ * `stream-broker` is a singleton cap and an unpinned call never leaves the
14690
+ * hub, so a handle produced on an agent's broker would be redeemed against
14691
+ * the hub's store and come back `null`. Same contract, same field name and
14692
+ * the same reason as `FrameHandleSchema.nodeId`: the producer stamps where it
14693
+ * lives and the consumer pins to it.
14694
+ */
14695
+ nodeId: string(),
14696
+ mime: string(),
14697
+ bytes: number().int(),
14698
+ width: number().int(),
14699
+ height: number().int()
14700
+ });
15212
14701
  /**
15213
- * `derived` reads a source · `clock` reads only `now`, recomputed when a sibling
15214
- * field of its cap changes value · `constant` reads nothing, computed once ·
15215
- * `accumulator` advances from the target's seed/current output.
14702
+ * What a production actually covered — the answer to the only question an
14703
+ * operator asks about a notification clip.
14704
+ *
14705
+ * `fromTs`/`toTs` are WALL CLOCK, derived from the ring's own packet timeline,
14706
+ * so a caller can state "this clip starts 4.1 s before the event" instead of
14707
+ * inferring it from a duration. A production whose `fromTs` is later than the
14708
+ * event is a production with no pre-roll, and that is exactly the defect this
14709
+ * method exists to make visible rather than plausible.
15216
14710
  */
15217
- var CompositionFieldRoleSchema = _enum([
15218
- "derived",
15219
- "clock",
15220
- "constant",
15221
- "accumulator"
15222
- ]);
15223
- /** The item a planned field belongs to; its `path` is then the dotted path INSIDE the item. */
15224
- var CompositionFieldItemSchema = object({
15225
- arrayPath: string(),
15226
- key: string(),
15227
- label: string()
15228
- });
15229
- var CompositionFieldPlanSchema = object({
15230
- cap: string(),
15231
- /** Top-level field, or — when `item` is set — the dotted path inside that item. */
15232
- path: string(),
15233
- item: CompositionFieldItemSchema.optional(),
15234
- /** Index into `composition.features` this field's capability came from —
15235
- * lets a source/cycle problem name its path as `features[N].fields.<path>`
15236
- * (the same convention `planComposition`'s own problems already use). */
15237
- featureIndex: number().int().nonnegative(),
15238
- kind: CompositionFieldKindSchema,
15239
- nullable: boolean(),
15240
- required: boolean(),
15241
- unavailable: CompositionUnavailableModeSchema,
15242
- role: CompositionFieldRoleSchema,
15243
- source: CompositionFieldSourceSchema,
15244
- reads: array(CompositionFieldReadSchema)
14711
+ var EventMediaCoverageSchema = object({
14712
+ fromTs: number(),
14713
+ toTs: number(),
14714
+ /** Encoded packets in the muxed window. */
14715
+ packets: number().int()
15245
14716
  });
15246
14717
  /**
15247
- * What a feature does to its target (D663): `new` builds a capability on a new
15248
- * device; on an existing device it `add`s a capability the device lacks, or
15249
- * `replace`s named fields of one it has natively.
14718
+ * The result of ONE cut, in every container the caller asked for.
14719
+ *
14720
+ * Every artifact in `media` came out of the SAME window of the SAME rendition —
14721
+ * that is the whole reason this is one method rather than one call per format.
14722
+ * A consumer attaching a gif and a video can no longer show two different
14723
+ * moments, because it never chose two sources.
15250
14724
  */
15251
- var CompositionFeatureModeSchema = _enum([
15252
- "new",
15253
- "add",
15254
- "replace"
15255
- ]);
15256
- var CompositionFeaturePlanSchema = object({
15257
- /** Index in the stored feature array; disabled features are omitted rather than re-indexed. */
15258
- featureIndex: number().int().nonnegative(),
15259
- cap: string(),
15260
- mode: CompositionFeatureModeSchema
14725
+ var EventMediaProductionSchema = object({
14726
+ media: array(EventMediaArtifactSchema).readonly(),
14727
+ coverage: EventMediaCoverageSchema,
14728
+ /** The rendition actually cut from — what the default or the fallback chose. */
14729
+ profile: CamProfileSchema,
14730
+ /** `copy` = the camera's own H.264, untouched. `encode` = re-encoded (H.265
14731
+ * source, a downscale, or a playback rate other than 1). */
14732
+ video: _enum(["copy", "encode"])
15261
14733
  });
15262
- var CompositionValidationSchema = object({
15263
- ok: boolean(),
15264
- problems: array(CompositionProblemSchema),
15265
- fields: array(CompositionFieldPlanSchema),
15266
- features: array(CompositionFeaturePlanSchema),
15267
- events: array(CompositionEventPlanSchema)
14734
+ var RtspRestreamEntrySchema = object({
14735
+ brokerId: string(),
14736
+ url: string(),
14737
+ mutedUrl: string(),
14738
+ enabled: boolean(),
14739
+ /**
14740
+ * Source-stream codec / resolution for the camStream this entry serves
14741
+ * (the broker's "high"/"mid"/"low" profile slot for this device).
14742
+ * Used by exporter pickers (`pickPreferredRtspEntry`) to resolve
14743
+ * `streamPreference: 'auto'` to the slot whose source is closest to
14744
+ * the consumer's target — Alexa wants ~720p, HomeKit wants ~1080p,
14745
+ * and there's no point dialling the 4K slot for an Echo Show. Absent
14746
+ * when the source publisher never advertised the field; pickers fall
14747
+ * back to first-enabled in that case.
14748
+ */
14749
+ codec: string().optional(),
14750
+ resolution: object({
14751
+ width: number().int().positive(),
14752
+ height: number().int().positive()
14753
+ }).optional()
15268
14754
  });
15269
- /**
15270
- * Mirrors `CoreBlockStatus`, pinned by `core-blocks-source.spec.ts`. `degraded`:
15271
- * running, but a replaced field is released to the native provider while its
15272
- * source is unavailable (ruling S1b, D663) — the reason names each such field.
15273
- */
15274
- var CompositionRunStatusSchema = _enum([
15275
- "stopped",
15276
- "starting",
15277
- "running",
15278
- "degraded",
15279
- "failed"
15280
- ]);
15281
- var CompositionFieldStateKindSchema = _enum([
15282
- "fresh",
15283
- "stale",
15284
- "unavailable"
15285
- ]);
15286
- var CompositionFieldStateSchema = object({
15287
- cap: string(),
15288
- path: string(),
15289
- /** Same as the plan's: two items' `status` are told apart by it. */
15290
- item: CompositionFieldItemSchema.optional(),
15291
- state: CompositionFieldStateKindSchema,
15292
- /** `null` whenever `state === 'unavailable'`; never a plausible stand-in (D393). */
15293
- value: union([
15294
- string(),
15295
- number(),
15296
- boolean(),
15297
- _null()
15298
- ]),
15299
- reason: string().optional()
14755
+ var BrokerRtspClientSchema = object({
14756
+ sessionId: string(),
14757
+ remoteAddr: string(),
14758
+ /** Client `User-Agent` (e.g. `recorder` for the recorder's RTSP pull), or
14759
+ * null/absent when the client sent none. Lets the UI label a consumer by
14760
+ * purpose. Optional so a client built against an older schema stays valid. */
14761
+ userAgent: string().nullish(),
14762
+ playing: boolean(),
14763
+ muted: boolean(),
14764
+ connectedAt: number(),
14765
+ lastRtpAt: number(),
14766
+ bytesSent: number()
15300
14767
  });
15301
- var CompositionBlockStateSchema = object({
15302
- blockId: string(),
15303
- deviceId: number().nullable(),
15304
- status: CompositionRunStatusSchema,
15305
- reason: string().optional(),
15306
- fields: array(CompositionFieldStateSchema)
14768
+ var BrokerAudioClientSchema = object({
14769
+ tag: string(),
14770
+ subscribedAt: number(),
14771
+ chunksDelivered: number()
15307
14772
  });
15308
- var ComposerBlockSchema = object({
15309
- id: string().min(1),
15310
- name: string().min(1).max(120),
15311
- enabled: boolean(),
15312
- updatedAt: number(),
15313
- composition: CompositionSchema
14773
+ var BrokerConsumerAttributionSchema = object({
14774
+ kind: _enum([
14775
+ "alexa",
14776
+ "homekit",
14777
+ "webrtc-browser",
14778
+ "webrtc-mobile",
14779
+ "webrtc-whep",
14780
+ "rtsp-listen",
14781
+ "derived-broker",
14782
+ "recording",
14783
+ "pipeline",
14784
+ "snapshot",
14785
+ "warmup",
14786
+ "unknown"
14787
+ ]),
14788
+ /**
14789
+ * Free-form label intended to disambiguate consumers OF THE SAME kind
14790
+ * on the same broker — e.g. user name, device alias. Should NOT repeat
14791
+ * the kind / cam / cam-stream / sessionId (those are surfaced by
14792
+ * dedicated fields). When empty the widget falls back to `${kind} ·
14793
+ * <sessionId tail>`.
14794
+ */
14795
+ label: string().optional(),
14796
+ /**
14797
+ * Which part of the encoded plane this subscription consumes:
14798
+ * - `'audio'` — audio packets only
14799
+ * - `'video'` — video packets only
14800
+ * - `'both'` — both video + audio (typical for AnnexB push paths
14801
+ * that don't split the source RTP)
14802
+ *
14803
+ * Missing field means "unknown" — older callers and the legacy
14804
+ * paths that haven't been migrated yet.
14805
+ */
14806
+ media: _enum([
14807
+ "audio",
14808
+ "video",
14809
+ "both"
14810
+ ]).optional(),
14811
+ /**
14812
+ * Codec the consumer receives AFTER any per-session re-encoding. For
14813
+ * a WebRTC session this is the negotiated egress codec (e.g. `H264`,
14814
+ * `H265`, `Opus`, `Pcmu`); for an RTSP restreamer it mirrors the
14815
+ * source codec. Surfaced in the widget chip so operators see at a
14816
+ * glance whether a viewer is on H.264 (Echo) or H.265 (Safari).
14817
+ */
14818
+ targetCodec: string().optional(),
14819
+ /**
14820
+ * Whether the broker is re-encoding for this consumer:
14821
+ * - `'passthrough'` — bytes flow source→consumer without ffmpeg
14822
+ * - `'repacketize'` — RTP payload is re-packetized but NOT re-encoded
14823
+ * (preserves frames, just rebuilds headers; e.g.
14824
+ * the H.265 repacketizer path)
14825
+ * - `'transcode'` — a full encode/decode round (ffmpeg, libx264,
14826
+ * libav…); the most expensive path
14827
+ */
14828
+ transport: _enum([
14829
+ "passthrough",
14830
+ "repacketize",
14831
+ "transcode"
14832
+ ]).optional(),
14833
+ /** Remote peer IP if the consumer terminates a network socket. */
14834
+ remoteAddr: string().optional(),
14835
+ /**
14836
+ * Server-read User-Agent of the originating client; enriched by the hub
14837
+ * from the tRPC request context (browser sessions).
14838
+ */
14839
+ userAgent: string().optional(),
14840
+ /** Authenticated user id (CamStack OAuth subject) when known. */
14841
+ userId: string().optional(),
14842
+ /** Higher-level session identifier (Alexa Echo sessionId, HAP session, …). */
14843
+ sessionId: string().optional(),
14844
+ /** Free-form key/value extras (e.g. clientHints from a WebRTC offer). */
14845
+ extra: record(string(), string()).optional()
14846
+ }).readonly();
14847
+ var BrokerEncodedClientSchema = object({
14848
+ /** Stable id assigned on attach; used by `killClient`. */
14849
+ id: string(),
14850
+ /** Which broker fanout the subscriber rides — annexB encoded packets vs raw RTP. */
14851
+ channel: _enum(["annexb", "rtp"]),
14852
+ attribution: BrokerConsumerAttributionSchema,
14853
+ subscribedAt: number(),
14854
+ /** Total packets delivered (annex-B EncodedPackets or RTP byte-buffers). */
14855
+ packetsDelivered: number()
14856
+ });
14857
+ var BrokerClientsSchema = object({
14858
+ rtsp: array(BrokerRtspClientSchema).readonly(),
14859
+ audio: array(BrokerAudioClientSchema).readonly(),
14860
+ encoded: array(BrokerEncodedClientSchema).readonly(),
14861
+ pipeClients: number(),
14862
+ /** Total encoded + raw-RTP callback subscriber count (sum of `encoded[].length`). */
14863
+ encodedSubscribers: number()
15314
14864
  });
15315
14865
  /**
15316
- * A stored composition row that no longer PARSES — a schema a release
15317
- * tightened, a row written by a newer build. Sent by id so the composer keeps
15318
- * its device as a placeholder and reports the block `failed` naming the parse
15319
- * error. Leaving it out would read, twice, exactly like a delete (D49).
14866
+ * How many people are watching one camera LIVE right now — see
14867
+ * `getLiveViewerCounts`. A camera with nobody watching is absent from the list
14868
+ * (so `viewers` is always at least 1): the listing carries the cameras that
14869
+ * have viewers, never a row of zeros.
15320
14870
  */
15321
- var ComposerUnreadableBlockSchema = object({
15322
- id: string().min(1),
15323
- /** The row's stored name column; null when even that is unreadable (the device keeps the name it has). */
15324
- name: string().nullable(),
15325
- /** The parse error, in the operator's words. */
15326
- error: string()
14871
+ var LiveViewerCountSchema = object({
14872
+ deviceId: number().int().nonnegative(),
14873
+ viewers: number().int().positive()
15327
14874
  });
15328
- method(object({
15329
- /** The shared Blocks integration; null until `core-blocks` has reconciled it. */
15330
- integrationId: string().nullable(),
15331
- /** False when the `core_blocks` read failed: then nothing may be withdrawn (D49). */
15332
- storeLoaded: boolean(),
15333
- blocks: array(ComposerBlockSchema),
15334
- /** Operator restarts: rebuild these runtimes and re-resolve their sources. Stores nothing (D62). */
15335
- restartBlockIds: array(string()),
15336
- /** Composition rows that exist but do not parse: held, never withdrawn, reported failed. */
15337
- unreadableBlocks: array(ComposerUnreadableBlockSchema)
15338
- }), object({
15339
- blocks: array(object({
15340
- blockId: string(),
15341
- status: CompositionRunStatusSchema,
15342
- reason: string().optional()
15343
- })),
14875
+ _enum([
14876
+ "reconnecting",
14877
+ "sleeping",
14878
+ "offline",
14879
+ "disabled",
14880
+ "waking"
14881
+ ]);
14882
+ var VideoCodecTargetSchema = _enum([
14883
+ "h264",
14884
+ "h265",
14885
+ "copy"
14886
+ ]);
14887
+ var AudioCodecTargetSchema = _enum([
14888
+ "aac",
14889
+ "opus",
14890
+ "pcmu",
14891
+ "copy",
14892
+ "none"
14893
+ ]);
14894
+ var GetStreamWithCodecInputSchema = object({
14895
+ deviceId: number().int().nonnegative(),
14896
+ /** Target codec. `'copy'` = passthrough (no re-encode). */
14897
+ video: VideoCodecTargetSchema,
14898
+ /** Target audio codec. `'copy'` = passthrough. Defaults to `'aac'`. */
14899
+ audio: AudioCodecTargetSchema.optional(),
14900
+ /** Target a profile's assigned source camStream. Required to resolve a source. */
14901
+ profile: CamProfileSchema.optional(),
14902
+ /** Optional output resolution target. When set: drives the transcode
14903
+ * output scale (downscale toward WxH), and — when `profile` is absent —
14904
+ * selects the published source closest to this resolution. Ignored for
14905
+ * `video:'copy'` scaling (a copy can't be rescaled), but still used for
14906
+ * source selection. */
14907
+ targetResolution: object({
14908
+ width: number().int().positive(),
14909
+ height: number().int().positive()
14910
+ }).optional(),
14911
+ /** Extra ffmpeg output flags appended verbatim by the consumer (in code).
14912
+ * Folded into the pipeline key so different args never share a child. */
14913
+ outputArgs: array(string()).optional(),
14914
+ /** Opt-in: carry transcoded audio over an RTP sidecar grafted into the
14915
+ * restreamer SDP (only for re-encoded audio targets — aac/opus/pcmu).
14916
+ * Default/absent = the live-verified video-only egress. */
14917
+ audioEgress: boolean().optional(),
14918
+ /** Host to embed in the returned restream `url`. The broker is hub-only, so
14919
+ * its restream URLs default to `127.0.0.1` (consumable only from the hub).
14920
+ * A REMOTE consumer (e.g. a recorder running on an agent designated as the
14921
+ * recording node) passes the hub's cluster-resolvable host here so the
14922
+ * returned `rtsp://<host>:<port>/<token>` is dialable from that node. The
14923
+ * port/token (and the underlying restreamer, bound on 0.0.0.0) are
14924
+ * unchanged — `hostname` only rewrites the client-facing dial address.
14925
+ * Absent/empty = `127.0.0.1` (the hub-local default). */
14926
+ hostname: string().optional(),
14927
+ tag: string().optional()
14928
+ });
14929
+ var RtpSourceSchema = object({
14930
+ url: string(),
15344
14931
  /**
15345
- * False when the composer declared and withdrew NOTHING (store not loaded,
15346
- * integration unknown, its own rows unread or read empty once). An empty
15347
- * apply the caller owes is paid off only by one the composer acted on (D49).
14932
+ * The SENTRY counterpart of {@link url} — the dial that asks for the stream
14933
+ * WITHOUT asking for the camera — or `null` when this camera offers none.
14934
+ *
14935
+ * The broker answers it because the broker is the only thing that can:
14936
+ * eligibility is `sentryEligible` (a BATTERY camera that DECLARES the signals
14937
+ * cap), asked of facts only the broker holds. A consumer re-deriving it from
14938
+ * cap bindings would be a second authority, and two authorities on the same
14939
+ * question is how a composite camera ends up dialling a sleeping camera
14940
+ * because its own copy of the rule was a release behind.
14941
+ *
14942
+ * `null` is a real answer and is never a live url in disguise: a consumer
14943
+ * that silently fell back to {@link url} would perform exactly the wake this
14944
+ * field exists to prevent. Only a PASSTHROUGH acquisition can offer one — a
14945
+ * transcode hands back an egress the broker is feeding itself, which has no
14946
+ * sentry counterpart.
15348
14947
  */
15349
- acted: boolean()
15350
- }), {
15351
- kind: "mutation",
15352
- auth: "admin"
15353
- }), method(object({ blockIds: array(string()) }), object({ states: array(CompositionBlockStateSchema) }), { auth: "admin" });
15354
- var NUMBER = {
15355
- kind: "fixed",
15356
- result: ["number"]
15357
- };
15358
- var BOOLEAN = {
15359
- kind: "fixed",
15360
- result: ["boolean"]
15361
- };
15362
- Object.freeze({
15363
- min: NUMBER,
15364
- max: NUMBER,
15365
- abs: NUMBER,
15366
- floor: NUMBER,
15367
- ceil: NUMBER,
15368
- sqrt: NUMBER,
15369
- round: NUMBER,
15370
- pow: NUMBER,
15371
- clamp: NUMBER,
15372
- avg: NUMBER,
15373
- sum: NUMBER,
15374
- age: NUMBER,
15375
- convert: NUMBER,
15376
- coalesce: { kind: "coalesce" },
15377
- any: BOOLEAN,
15378
- all: BOOLEAN,
15379
- within: BOOLEAN,
15380
- latest: {
15381
- kind: "fixed",
15382
- result: ["number", "null"]
15383
- },
15384
- number: {
15385
- kind: "fixed",
15386
- result: ["number", "null"]
15387
- },
15388
- rose: {
15389
- kind: "fixed",
15390
- result: ["number", "null"]
15391
- },
15392
- count: NUMBER
14948
+ sentryUrl: string().nullable().optional(),
14949
+ videoCodec: _enum(["H264", "H265"]),
14950
+ audioCodec: string(),
14951
+ resolution: object({
14952
+ width: number().int().positive(),
14953
+ height: number().int().positive()
14954
+ }),
14955
+ transcoded: boolean(),
14956
+ encoder: string(),
14957
+ pipelineKey: string()
15393
14958
  });
15394
- `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
15395
14959
  /**
15396
- * What a composition may BUILD on a device type (G2, D676): the caps a new
15397
- * device of that type, or an existing one that lacks them, can be given. The
15398
- * blockers are the validator's own refusal codes, and a spec compares this
15399
- * listing with `planComposition` for every capability — the editor can never
15400
- * offer a feature the save would refuse for the same reason.
14960
+ * The encode request — **structured and serialisable, with NO raw-flag escape
14961
+ * hatch.** This is deliberate and it is the one lesson taken from
14962
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
14963
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
14964
+ * adding a flag silently forks the shared child, and two consumers that mean
14965
+ * the same thing but spell it differently never share. Here every knob is a
14966
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
14967
+ * never an opaque array.
14968
+ *
14969
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
14970
+ * The operator-facing derived-stream transform editor still has them — that is
14971
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
14972
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
15401
14973
  */
15402
- var ComposableFieldSchema = object({
15403
- path: string(),
15404
- kind: CompositionFieldKindSchema,
15405
- nullable: boolean(),
15406
- required: boolean(),
15407
- enumValues: array(string()).optional()
14974
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
14975
+ inputArgs: true,
14976
+ outputArgs: true
15408
14977
  });
15409
- var ComposableCapabilitiesSchema = object({ caps: array(object({
15410
- cap: string(),
15411
- fields: array(ComposableFieldSchema),
15412
- /** Present for an item-array cap (`consumables.items`). */
15413
- itemArray: object({
15414
- path: string(),
15415
- keyField: string()
15416
- }).optional()
15417
- })) });
15418
- /** Where a block runs. The operator chooses — a block driving a device on an
15419
- * agent is the reason placement is not fixed to the hub. */
15420
- var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
15421
- /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
15422
- * a failing block reads the same way a failing addon does. `degraded` is a
15423
- * composition on an existing device with a field handed back to the native
15424
- * provider while its source is unavailable (S1b, D663); the error names it. */
15425
- var CoreBlockStatusSchema = _enum([
15426
- "stopped",
15427
- "starting",
15428
- "running",
15429
- "degraded",
15430
- "failed"
15431
- ]);
15432
- /** path → TypeScript source for a multi-file block. */
15433
- var CoreBlockFilesSchema = record(string().min(1).max(260), string().max(2e5));
15434
- /**
15435
- * A block's settings form — the same ConfigUISchema addons declare.
15436
- * Loose on the wire (field variants are polymorphic); typed for callers.
15437
- */
15438
- var CoreBlockSettingsSchemaBridge = custom();
15439
14978
  /**
15440
- * Client-authored fields (plain object). Kept separate from the refined create
15441
- * schema because Zod 4 refuses `.partial()` on schemas that carry refinements —
15442
- * `update` needs a partial of these fields.
14979
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
14980
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
14981
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
14982
+ * an Echo). Named rather than numeric so the INTENT survives.
15443
14983
  */
15444
- var CoreBlockInputFieldsSchema = object({
15445
- name: string().min(1).max(120),
15446
- /**
15447
- * Single-file TypeScript source. Kept for backward compatibility: when
15448
- * `files` is absent, this IS `files[entry]`. On write, if `files` is
15449
- * provided, the server derives `code` from `files[entry]` so compile and
15450
- * runtime paths that still read `code` keep working.
15451
- */
15452
- code: string().max(2e5).optional(),
15453
- /** Multi-file project (path → source). Absent = legacy single-file `code`. */
15454
- files: CoreBlockFilesSchema.optional(),
15455
- /** Entry file inside `files`. Default {@link DEFAULT_CORE_BLOCK_ENTRY}. */
15456
- entry: string().min(1).max(260).optional(),
15457
- enabled: boolean(),
15458
- placement: CoreBlockPlacementSchema,
14984
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
14985
+ var EgressTranscodeRequestSchema = object({
14986
+ deviceId: number().int().nonnegative(),
14987
+ /** Which published stream to read. */
14988
+ source: discriminatedUnion("kind", [object({
14989
+ kind: literal("profile"),
14990
+ profile: CamProfileSchema
14991
+ }), object({
14992
+ kind: literal("cam-stream"),
14993
+ camStreamId: string().min(1)
14994
+ })]),
14995
+ encode: EgressEncodeSchema,
14996
+ rateControl: EgressRateControlSchema.optional(),
15459
14997
  /**
15460
- * Integration the block's devices hang from. Absent = the system integration
15461
- * blocks share. A block may declare its own instead.
14998
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
14999
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
15000
+ * branches. Enumerated, not free text.
15462
15001
  */
15463
- integrationId: string().optional(),
15002
+ bitstreamFilter: _enum([
15003
+ "dump_extra",
15004
+ "h264_mp4toannexb",
15005
+ "hevc_mp4toannexb"
15006
+ ]).optional(),
15464
15007
  /**
15465
- * Cached settings form for this block. Prefer extracting `settingsSchema`
15466
- * from the compiled entry after a successful load; the editor may also set
15467
- * it. Absent ⇒ `getBlockSettings` returns null until one is known.
15008
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
15009
+ * consumer to dial the returned url. The broker picks the id and returns it
15010
+ * as `camStreamId` — a caller-supplied one would be circular, since the
15011
+ * sharing key is computed FROM this request.
15012
+ *
15013
+ * The url is still returned and still the contract for a transcode pinned to
15014
+ * another node. But dialling it locally costs an RTSP round trip that changes
15015
+ * the transport underneath the consumer: a dialled stream is an RTP source,
15016
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
15017
+ * repacketizer branch. The push branch — the one the derived mechanism has
15018
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
15019
+ * arriving, key frame arriving, black screen, on a chain healthy at every
15020
+ * other point.
15021
+ *
15022
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
15023
+ * gives priority to `hub-in-process` so a local call never leaves the node.
15024
+ * This is that rule for media.
15468
15025
  */
15469
- settingsSchema: CoreBlockSettingsSchemaBridge.optional()
15470
- });
15471
- /** Client-authored fields for create — requires `code` or `files[entry]`. */
15472
- var CoreBlockInputSchema = CoreBlockInputFieldsSchema.superRefine((val, ctx) => {
15473
- const entry = val.entry ?? "main.ts";
15474
- if (val.files !== void 0) {
15475
- if (val.files[entry] === void 0) ctx.addIssue({
15476
- code: "custom",
15477
- message: `files must include the entry "${entry}"`,
15478
- path: ["files"]
15479
- });
15480
- return;
15481
- }
15482
- if (val.code === void 0) ctx.addIssue({
15483
- code: "custom",
15484
- message: "code or files is required",
15485
- path: ["code"]
15486
- });
15026
+ publishLocally: boolean().optional(),
15027
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
15028
+ /**
15029
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
15030
+ * and the one that matters: the broker then resolves the backend from the
15031
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
15032
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
15033
+ * on this hardware — never the raw kernel resolver's qsv-first order.
15034
+ */
15035
+ decodeHwAccel: _enum([
15036
+ "auto",
15037
+ "none",
15038
+ "videotoolbox",
15039
+ "vaapi",
15040
+ "qsv",
15041
+ "cuda"
15042
+ ]).optional(),
15043
+ /**
15044
+ * Host to embed in the returned restream `url`. The broker mints hub-local
15045
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
15046
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
15047
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
15048
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
15049
+ */
15050
+ hostname: string().optional(),
15051
+ /** Attribution for the broker panel. Never part of the sharing key. */
15052
+ tag: string().optional()
15487
15053
  });
15488
- /**
15489
- * What a block IS (D659). Absent means `code` — every row stored before this
15490
- * union keeps working unchanged.
15491
- * - `code`: the TypeScript in `code`, compiled and run in its own runner;
15492
- * - `recipe`: RESERVED (automations), stored shape only; refused at run by name;
15493
- * - `composition`: declarative, run by the shared `composer`; `code` is `''`.
15494
- */
15495
- var CoreBlockSourceSchema = discriminatedUnion("kind", [
15496
- object({ kind: literal("code") }),
15497
- object({
15498
- kind: literal("recipe"),
15499
- recipe: AutomationRecipeSchema
15054
+ var EgressTranscodeSchema = object({
15055
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
15056
+ url: string(),
15057
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
15058
+ pipelineKey: string(),
15059
+ videoCodec: _enum(["H264", "H265"]),
15060
+ resolution: object({
15061
+ width: number().int().positive(),
15062
+ height: number().int().positive()
15500
15063
  }),
15501
- object({
15502
- kind: literal("composition"),
15503
- composition: CompositionSchema
15504
- })
15505
- ]);
15506
- /**
15507
- * The client-authored half of a composition block. No `code`, no placement (hub-only; Phase 7).
15508
- *
15509
- * `name`: REQUIRED for a `new` target — the block's name IS the device's name
15510
- * (D659). ABSENT for an `existing` target — a customization has no name of its
15511
- * own; the server derives one (`customizationBlockName`, D663) and refuses a
15512
- * client-sent name by name.
15513
- */
15514
- var CompositionBlockInputSchema = object({
15515
- name: string().min(1).max(120).optional(),
15516
- enabled: boolean(),
15517
- composition: CompositionSchema
15518
- });
15519
- /** A stored block. `code` is always present after normalize (derived from files). */
15520
- var CoreBlockSchema = object({
15521
- name: string().min(1).max(120),
15522
- /** Single-file source, or `files[entry]` when multi-file. Always set on disk. */
15523
- code: string().max(2e5),
15524
- files: CoreBlockFilesSchema.optional(),
15525
- entry: string().min(1).max(260).optional(),
15526
- enabled: boolean(),
15527
- placement: CoreBlockPlacementSchema,
15528
- integrationId: string().optional(),
15529
- settingsSchema: CoreBlockSettingsSchemaBridge.optional(),
15530
- id: string(),
15531
- createdAt: number(),
15532
- updatedAt: number(),
15533
- /** Server-stamped author. */
15534
- createdBy: string(),
15535
- status: CoreBlockStatusSchema,
15064
+ transcoded: boolean(),
15065
+ encoder: string(),
15536
15066
  /**
15537
- * Why the block is not running, when it is not. The operator's ONLY window
15538
- * into a block that failed at load — a block that is silently absent is the
15539
- * failure mode this whole feature has to avoid.
15067
+ * The decode backend the child ACTUALLY ran with — `null` for software.
15068
+ * Returned rather than assumed: a consumer that asked for hardware and got
15069
+ * software needs to be able to see that without reading the broker's logs.
15540
15070
  */
15541
- lastError: string().optional(),
15542
- /** Ms epoch of the last state change. */
15543
- lastChangedAt: number(),
15544
- /** What the block is. Absent means `code` (D659). */
15545
- source: CoreBlockSourceSchema.optional()
15546
- });
15547
- /** A stored composition block and what the composer says about it. */
15548
- var CompositionViewSchema = object({
15549
- block: CoreBlockSchema,
15550
- state: discriminatedUnion("kind", [object({
15551
- kind: literal("answered"),
15552
- state: CompositionBlockStateSchema.nullable()
15553
- }), object({
15554
- kind: literal("unreachable"),
15555
- error: string()
15556
- })])
15557
- });
15558
- /**
15559
- * A read of the stored compositions. `not-loaded` while the `core_blocks`
15560
- * table has not been READ: an empty answer then would make "not yet known"
15561
- * look like "none", and invite the operator to recreate what exists (D315).
15562
- */
15563
- var CompositionsNotLoadedSchema = object({
15564
- state: literal("not-loaded"),
15565
- reason: string()
15566
- });
15567
- var CompositionListReadSchema = discriminatedUnion("state", [object({
15568
- state: literal("loaded"),
15569
- views: array(CompositionViewSchema)
15570
- }), CompositionsNotLoadedSchema]);
15571
- var CompositionGetReadSchema = discriminatedUnion("state", [object({
15572
- state: literal("loaded"),
15573
- view: CompositionViewSchema.nullable()
15574
- }), CompositionsNotLoadedSchema]);
15575
- /** The customization of one existing device, found by its TARGET, or `not-loaded` (D315). */
15576
- var CompositionCustomizationReadSchema = discriminatedUnion("state", [object({
15577
- state: literal("loaded"),
15578
- view: CompositionViewSchema.nullable()
15579
- }), CompositionsNotLoadedSchema]);
15580
- /** What a compile attempt produced. */
15581
- var CoreBlockCompileResultSchema = object({
15582
- ok: boolean(),
15583
- /** Present when `ok` is false — the first error, in the author's words. */
15584
- error: string().optional(),
15585
- line: number().optional(),
15586
- column: number().optional()
15587
- });
15588
- /**
15589
- * Compile input: legacy single-file `{ code }`, or a multi-file project.
15590
- * When `files` is present, it wins over `code`.
15591
- */
15592
- var CoreBlockCompileInputSchema = object({
15593
- code: string().optional(),
15594
- files: CoreBlockFilesSchema.optional(),
15595
- entry: string().min(1).max(260).optional()
15596
- }).superRefine((val, ctx) => {
15597
- if (val.files !== void 0) {
15598
- const entry = val.entry ?? "main.ts";
15599
- if (val.files[entry] === void 0) ctx.addIssue({
15600
- code: "custom",
15601
- message: `files must include the entry "${entry}"`,
15602
- path: ["files"]
15603
- });
15604
- return;
15605
- }
15606
- if (val.code === void 0) ctx.addIssue({
15607
- code: "custom",
15608
- message: "code or files is required",
15609
- path: ["code"]
15610
- });
15071
+ decodeHwAccel: string().nullable(),
15072
+ /**
15073
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
15074
+ * `url`, and the session takes the push/deframe transport rather than the
15075
+ * RTP-passthrough one. `null` means the consumer must dial.
15076
+ */
15077
+ camStreamId: string().nullable()
15611
15078
  });
15612
- method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }), method(object({ blockId: string() }), object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }), method(object({ block: CoreBlockInputSchema }), object({ block: CoreBlockSchema }), {
15079
+ method(object({
15080
+ deviceId: number().int().nonnegative(),
15081
+ /** The provider's signal code. */
15082
+ code: string().min(1),
15083
+ /** Ms epoch. Absent ⇒ now. */
15084
+ at: number().optional()
15085
+ }), object({
15086
+ /** Whether the broker acted on it, and if not, why. */
15087
+ accepted: boolean(),
15088
+ reason: _enum([
15089
+ "woke",
15090
+ "hold-extended",
15091
+ "not-enabled",
15092
+ "no-consumer",
15093
+ "unknown-code"
15094
+ ])
15095
+ }), { kind: "mutation" }), method(object({
15096
+ deviceId: number().int().nonnegative(),
15097
+ camStreamId: string().min(1),
15098
+ kind: CamStreamKindSchema,
15099
+ url: string().optional(),
15100
+ codec: string().optional(),
15101
+ resolution: CamStreamResolutionSchema.optional(),
15102
+ fps: number().positive().optional(),
15103
+ label: string().optional(),
15104
+ deviceFeatures: array(string()).optional(),
15105
+ autoEligible: boolean().optional(),
15106
+ /** The slot the publisher says this stream IS — see `CameraStreamSchema.profileHint`. */
15107
+ profileHint: CamProfileSchema.optional(),
15108
+ metadata: record(string(), unknown()).optional(),
15109
+ /** Required when kind === 'derived' — the source camStreamId the
15110
+ * derived broker reads its encoded plane from.
15111
+ * The runtime guard (broker manager, Task 10) enforces presence for
15112
+ * kind === 'derived'; the schema stays permissive so ts-morph emits
15113
+ * full field types instead of collapsing to ZodEffects. */
15114
+ sourceCamStreamId: string().min(1).optional(),
15115
+ /** Required when kind === 'derived' — the ffmpeg params for the
15116
+ * spawn. Reuses the same brokerId for identical params (single-flight).
15117
+ * Runtime guard in broker manager; schema is permissive (see above). */
15118
+ encodeProfile: EncodeProfileSchema.optional()
15119
+ }), object({ success: literal(true) }), {
15120
+ kind: "mutation",
15121
+ auth: "admin"
15122
+ }), method(object({
15123
+ deviceId: number().int().nonnegative(),
15124
+ camStreamId: string().min(1)
15125
+ }), object({ success: literal(true) }), {
15126
+ kind: "mutation",
15127
+ auth: "admin"
15128
+ }), method(object({
15129
+ deviceId: number().int().nonnegative(),
15130
+ profile: CamProfileSchema,
15131
+ camStreamId: string().min(1)
15132
+ }), object({ success: literal(true) }), {
15133
+ kind: "mutation",
15134
+ auth: "admin"
15135
+ }), method(object({
15136
+ deviceId: number().int().nonnegative(),
15137
+ profile: CamProfileSchema
15138
+ }), object({ success: literal(true) }), {
15139
+ kind: "mutation",
15140
+ auth: "admin"
15141
+ }), method(object({ deviceId: number().int().nonnegative() }), object({
15142
+ derivedStreamsDeleted: array(string()).readonly(),
15143
+ assignmentsPurged: boolean(),
15144
+ probeSnapshotsDropped: number().int().nonnegative(),
15145
+ rtspTokenRowsDeleted: number().int().nonnegative()
15146
+ }), {
15147
+ kind: "mutation",
15148
+ auth: "admin"
15149
+ }), method(object({
15150
+ deviceId: number(),
15151
+ /** Absent = the LOWEST assigned profile — a notification attachment is
15152
+ * watched on a phone, and the cheap rendition is the right default. */
15153
+ profile: CamProfileSchema.optional(),
15154
+ aroundMs: number(),
15155
+ preRollSec: number().min(0).max(20).default(3),
15156
+ postRollSec: number().min(0).max(20).default(5),
15157
+ format: _enum(["gif", "mp4"]).default("gif"),
15158
+ maxWidth: number().int().min(120).max(1920).default(480),
15159
+ /** GIF only — MP4 keeps the source cadence. */
15160
+ fps: number().int().min(1).max(15).default(5),
15161
+ /**
15162
+ * Playback rate. A notification clip is GLANCED at on a lock screen,
15163
+ * not watched, so 2x is the default: the recipient sees the whole
15164
+ * passage in half the time and the GIF is half the bytes. `1` is real
15165
+ * time. Applies to MP4 as well — the operator set a speed, not a GIF
15166
+ * speed.
15167
+ */
15168
+ speed: number().min(1).max(8).default(2)
15169
+ }), object({
15170
+ base64: string(),
15171
+ mime: string(),
15172
+ bytes: number().int(),
15173
+ /** The profile actually rendered (what the default resolved to). */
15174
+ profile: CamProfileSchema,
15175
+ durationMs: number()
15176
+ }), {
15177
+ kind: "mutation",
15178
+ auth: "admin"
15179
+ }), method(object({
15180
+ deviceId: number(),
15181
+ /** Absent = the largest H.264 rendition at or below 1080p, which is
15182
+ * also the one that can be copied. Falls back to whatever the ring
15183
+ * actually retained, and the answer says which. */
15184
+ profile: CamProfileSchema.optional(),
15185
+ aroundMs: number(),
15186
+ preSeconds: number().min(0).max(20).default(4),
15187
+ postSeconds: number().min(0).max(20).default(6),
15188
+ kinds: array(EventMediaKindSchema).min(1).default(["mp4"]),
15189
+ /** GIF geometry. The video keeps the source's own. */
15190
+ gifMaxWidth: number().int().min(120).max(1280).default(640),
15191
+ /**
15192
+ * The gif's own PLAYBACK rate in frames per second — what the finished
15193
+ * gif runs at, not how many source frames feed it. The decimation that
15194
+ * feeds it samples `gifFps / gifSpeed` source frames per second, so at
15195
+ * the defaults a 12 fps gif is built out of 3 source frames a second.
15196
+ */
15197
+ gifFps: number().int().min(1).max(15).default(12),
15198
+ /**
15199
+ * How fast the GIF plays against real time, independent of `speed`.
15200
+ *
15201
+ * 4× by default, by operator request: a notification gif is glanced at
15202
+ * on a lock screen, so a ~12 s window has to be over in ~3 s. It stays
15203
+ * a separate knob from `speed` even though both now default to 4 —
15204
+ * a caller wanting a real-time video and a fast gif must not have to
15205
+ * choose.
15206
+ */
15207
+ gifSpeed: number().min(1).max(8).default(4),
15208
+ /**
15209
+ * Playback rate of the VIDEO. Also 4× by default, by operator decision.
15210
+ *
15211
+ * `1` is real time and is the ONLY value that allows the copy branch —
15212
+ * anything else forces `libx264` over the window. That was priced
15213
+ * before it was chosen: a per-event burst measured at 0.23 s and 254 KB
15214
+ * on a real 615 720p cut, against 922 KB for the copy it replaces. A
15215
+ * re-encode is capped at 720p (`EVENT_CLIP_ENCODE_MAX_WIDTH`), because
15216
+ * once the decode is forced the width stops being free.
15217
+ */
15218
+ speed: number().min(1).max(8).default(4)
15219
+ }), EventMediaProductionSchema, {
15613
15220
  kind: "mutation",
15614
- auth: "admin",
15615
- caller: "required"
15616
- }), method(object({
15617
- blockId: string(),
15618
- block: CoreBlockInputFieldsSchema.partial()
15619
- }), object({ block: CoreBlockSchema }), {
15221
+ auth: "admin"
15222
+ }), method(object({ handle: string() }), object({
15223
+ base64: string(),
15224
+ mime: string(),
15225
+ bytes: number().int()
15226
+ }).nullable(), {
15620
15227
  kind: "mutation",
15621
- auth: "admin",
15622
- caller: "required"
15623
- }), method(object({ blockId: string() }), object({ success: literal(true) }), {
15228
+ auth: "admin"
15229
+ }), method(_void(), array(CameraStreamSchema).readonly()), method(_void(), array(ProfileSlotSchema).readonly()), method(object({ brokerId: string() }), BrokerStatsSchema), method(object({ brokerId: string() }), object({
15230
+ probed: boolean(),
15231
+ summary: string()
15232
+ }), {
15233
+ kind: "mutation",
15234
+ auth: "admin"
15235
+ }), method(object({ brokerId: string() }), BrokerClientsSchema), method(_void(), array(LiveViewerCountSchema).readonly()), method(object({
15236
+ brokerId: string(),
15237
+ channel: _enum(["rtsp", "audio"]),
15238
+ handle: string()
15239
+ }), object({ killed: boolean() }), {
15624
15240
  kind: "mutation",
15625
15241
  auth: "admin"
15626
15242
  }), method(object({
15627
- blockId: string(),
15628
- enabled: boolean()
15629
- }), object({ block: CoreBlockSchema }), {
15243
+ deviceId: number().int().nonnegative(),
15244
+ profile: CamProfileSchema
15245
+ }), object({ success: boolean() }), {
15630
15246
  kind: "mutation",
15631
15247
  auth: "admin"
15632
- }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
15248
+ }), method(object({
15249
+ streamId: string(),
15250
+ format: StreamFormatSchema
15251
+ }), object({ url: string() })), method(GetStreamWithCodecInputSchema, RtpSourceSchema, {
15633
15252
  kind: "mutation",
15634
15253
  auth: "admin"
15635
- }), method(CoreBlockCompileInputSchema, CoreBlockCompileResultSchema, {
15254
+ }), method(object({ pipelineKey: string() }), object({
15255
+ released: boolean(),
15256
+ refcount: number().int().nonnegative()
15257
+ }), {
15636
15258
  kind: "mutation",
15637
15259
  auth: "admin"
15638
- }), method(object({ blockId: string() }), SettingsSchemaWithValuesSchema.nullable(), { auth: "admin" }), method(object({
15639
- blockId: string(),
15640
- patch: SettingsPatchSchema
15641
- }), SettingsUpdateResultSchema, {
15260
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
15642
15261
  kind: "mutation",
15643
15262
  auth: "admin"
15644
- }), method(object({}), object({ libs: array(object({
15645
- filePath: string(),
15646
- content: string()
15647
- })) }), { auth: "admin" }), method(object({ block: CompositionBlockInputSchema }), object({ block: CoreBlockSchema }), {
15263
+ }), method(object({ pipelineKey: string() }), object({
15264
+ released: boolean(),
15265
+ refcount: number().int().nonnegative()
15266
+ }), {
15648
15267
  kind: "mutation",
15649
- auth: "admin",
15650
- caller: "required"
15268
+ auth: "admin"
15269
+ }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
15270
+ subscriptionId: string(),
15271
+ maxCount: number().int().positive().default(8)
15272
+ }), array(DecodedAudioChunkSchema).readonly()), method(object({ subscriptionId: string() }), object({ released: boolean() }), { kind: "mutation" }), method(object({
15273
+ brokerId: string(),
15274
+ seconds: number().min(0).max(30)
15275
+ }), _void(), {
15276
+ kind: "mutation",
15277
+ auth: "admin"
15278
+ }), method(object({ brokerId: string() }), object({
15279
+ configuredSec: number(),
15280
+ bufferedMs: number(),
15281
+ packetCount: number()
15282
+ })), method(_void(), number()), method(object({ hostname: string().optional() }), array(RtspRestreamEntrySchema).readonly()), method(object({
15283
+ brokerId: string(),
15284
+ hostname: string().optional()
15285
+ }), RtspRestreamEntrySchema.nullable()), method(object({ brokerId: string() }), string().nullable(), {
15286
+ kind: "mutation",
15287
+ auth: "admin"
15651
15288
  }), method(object({
15652
- blockId: string(),
15653
- expectedUpdatedAt: number(),
15654
- block: CompositionBlockInputSchema.partial()
15655
- }), object({ block: CoreBlockSchema }), {
15289
+ brokerId: string(),
15290
+ enabled: boolean()
15291
+ }), _void(), {
15656
15292
  kind: "mutation",
15657
- auth: "admin",
15658
- caller: "required"
15659
- }), method(object({ blockId: string() }), CompositionGetReadSchema, { auth: "admin" }), method(object({}), CompositionListReadSchema, { auth: "admin" }), method(object({
15660
- addonId: string().min(1),
15661
- stableId: string().min(1)
15662
- }), CompositionCustomizationReadSchema, { auth: "admin" }), method(object({ deviceType: _enum(DeviceType) }), ComposableCapabilitiesSchema, { auth: "admin" }), method(object({
15663
- composition: CompositionSchema,
15664
- blockId: string().optional()
15665
- }), CompositionValidationSchema, {
15293
+ auth: "admin"
15294
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
15295
+ muted: boolean(),
15296
+ /**
15297
+ * How many live non-derived brokers currently hold the mute. Purely
15298
+ * diagnostic: `muted` is the policy and is authoritative on its own
15299
+ * (it applies to brokers that do not exist yet), while this says
15300
+ * whether anything is presently being silenced.
15301
+ */
15302
+ appliedBrokers: number().int().nonnegative()
15303
+ })), method(object({
15304
+ deviceId: number().int(),
15305
+ muted: boolean()
15306
+ }), object({
15307
+ muted: boolean(),
15308
+ appliedBrokers: number().int().nonnegative()
15309
+ }), {
15666
15310
  kind: "mutation",
15667
- auth: "admin",
15668
- access: "view"
15669
- });
15670
- /**
15671
- * `custom-model-registry` — collection cap exposing operator-registered
15672
- * custom detection models. Each provider (today: `addon-model-studio`)
15673
- * contributes a list of `CustomModelDescriptor`s; the hub auto-concatenates
15674
- * them across providers (`concatCollection`).
15675
- *
15676
- * The detection-pipeline is *aware* of this cap: when at least one provider
15677
- * exists it unions these descriptors into the per-step model picker and the
15678
- * runtime model-resolution path, alongside the static catalog. When no
15679
- * provider exists the consumer no-ops entirely (identical to the catalog-only
15680
- * behaviour).
15681
- *
15682
- * A descriptor carries a full `ModelCatalogEntry` directly — the same shape
15683
- * the static catalog uses — so the existing download/resolution code consumes
15684
- * it unchanged. `stepId` is the detection step the model targets
15685
- * (e.g. `'object-detection'`).
15686
- */
15687
- var CustomModelDescriptorSchema = object({
15688
- stepId: string(),
15689
- entry: ModelCatalogEntrySchema
15690
- });
15691
- /** One regular file of an imported bundle, as the producer measured it. */
15692
- var ModelBundleFileSchema = object({
15693
- /** Relative path inside the bundle tar; an mlpackage member is `<x>.mlpackage/<member>`. */
15694
- path: string().min(1),
15695
- bytes: number().int().nonnegative(),
15696
- sha256: string().regex(/^[0-9a-f]{64}$/)
15311
+ auth: "admin"
15312
+ }), object({
15313
+ deviceId: number().int().nonnegative(),
15314
+ camStreamId: string(),
15315
+ profile: CamProfileSchema
15316
+ }), object({
15317
+ deviceId: number().int().nonnegative(),
15318
+ camStreamId: string()
15319
+ }), object({
15320
+ deviceId: number().int().nonnegative(),
15321
+ camStreamId: string(),
15322
+ brokerId: string()
15697
15323
  });
15698
- var ImportModelBundleInputSchema = object({
15699
- /** The registry provider that keeps the model (Model Studio). */
15700
- addonId: string(),
15701
- stepId: string().min(1),
15324
+ var StreamCodecSchema = _enum([
15325
+ "h264",
15326
+ "h265",
15327
+ "hevc",
15328
+ "av1",
15329
+ "mjpeg",
15330
+ "vp8",
15331
+ "vp9"
15332
+ ]);
15333
+ var PickStreamRequirementsSchema = object({
15334
+ /**
15335
+ * Codecs the consumer can decode without an intermediate transcode.
15336
+ * Match is case-insensitive against the camera stream's `codec`.
15337
+ * Default (omitted / empty array): no codec filter — useful when the
15338
+ * consumer just wants "the highest-quality stream of any codec".
15339
+ */
15340
+ acceptCodecs: array(StreamCodecSchema).readonly().optional(),
15341
+ /** Minimum vertical resolution. Streams shorter than this are dropped. */
15342
+ minHeight: number().int().positive().optional(),
15343
+ /**
15344
+ * Maximum vertical resolution. Streams TALLER than this are dropped.
15345
+ *
15346
+ * A consumer can have a ceiling as real as its floor: Alexa documents
15347
+ * 480p to 1080p, and a 4K stream is as unusable to an Echo as a 360p one.
15348
+ * Without this the ceiling had to be re-implemented by every caller — and
15349
+ * one caller implementing it privately is how a second scoring authority
15350
+ * gets born.
15351
+ */
15352
+ maxHeight: number().int().positive().optional(),
15353
+ /** Minimum horizontal resolution. */
15354
+ minWidth: number().int().positive().optional(),
15355
+ /**
15356
+ * When true (default), `derived:*` streams (transcoded broker pipes)
15357
+ * are excluded — the caller wants a SOURCE stream the broker can
15358
+ * forward without a re-encode. Set false to allow derived in the
15359
+ * picker (useful for a "best playable" fallback).
15360
+ */
15361
+ excludeDerived: boolean().optional(),
15362
+ /**
15363
+ * Bypass gate. When set, the picker returns a hit ONLY IF the device
15364
+ * also exposes a non-derived stream in one of these (rejected) codecs.
15365
+ * Mirrors the Alexa "bypass only when the natural path WOULD have
15366
+ * transcoded" guard: if the device is already serving the consumer's
15367
+ * codec end-to-end, there's nothing to optimise.
15368
+ */
15369
+ requireSiblingCodec: array(StreamCodecSchema).readonly().optional(),
15370
+ /**
15371
+ * Whether a stream the consumer CANNOT decode may still be picked, on the
15372
+ * understanding that it will be transcoded.
15373
+ *
15374
+ * Default `false` — today's behaviour, and the right one for a bypass
15375
+ * question ("is there a stream I can forward untouched?"). Set `true` to
15376
+ * ask the larger question: "what is the best stream for me, transcoding if
15377
+ * I must?" A stream that satisfies `acceptCodecs` always outranks one that
15378
+ * does not, so a passthrough is never lost to a transcode; the answer says
15379
+ * which it is in {@link PickedCamStreamSchema.transcodes}.
15380
+ *
15381
+ * This is what lets one picker serve both the bypass and the full source
15382
+ * choice, instead of a consumer scoring privately when the bypass misses.
15383
+ */
15384
+ allowTranscode: boolean().optional(),
15385
+ /**
15386
+ * Consider ONLY streams the broker has bound to a profile slot.
15387
+ *
15388
+ * A consumer that dials the broker — which is every exporter, since none
15389
+ * of them may open the camera's own URL — can do nothing with a source
15390
+ * that has no slot: there is no restream to dial. Without this the picker
15391
+ * can name such a stream, and each consumer discovers the dead end for
15392
+ * itself: Alexa's `profileForCamStream` returns `null` and it quietly
15393
+ * falls back, and HomeKit would have had to grow the same function again.
15394
+ *
15395
+ * Default `false`, and deliberately so: the BROKER itself picks through
15396
+ * here when it decides which source to BIND to a slot
15397
+ * (`computeInitialAssignment`), and it must see the unbound ones or it
15398
+ * could never bind them.
15399
+ */
15400
+ requireProfileBound: boolean().optional()
15401
+ }).readonly();
15402
+ var PickStreamPreferencesSchema = object({
15702
15403
  /**
15703
- * The catalog rows to register, complete, every format URL already
15704
- * `camstack-local://<modelId>/<file>` naming a file of the bundle. One
15705
- * bundle may carry several rows (a base and its INT8 variant).
15404
+ * Ordered list of provider prefixes (e.g. `['native:', 'rtsp:']`) the
15405
+ * picker prefers — earlier entries win ties. Defaults to
15406
+ * `['native:']`, mirroring Alexa's "prefer the native dial path".
15706
15407
  */
15707
- entries: array(ModelCatalogEntrySchema).min(1).max(4),
15708
- /** The bundle tar, offered by the producer over `ctx.peerBytes` (same host). */
15709
- bundle: object({
15710
- ticket: PeerBytesTicketSchema,
15711
- bytes: number().int().positive(),
15712
- sha256: string().regex(/^[0-9a-f]{64}$/)
15713
- }),
15714
- /** Every file the tar must hold; anything missing, extra or different is refused. */
15715
- files: array(ModelBundleFileSchema).min(1).max(64),
15716
- /** Convert missing engine formats and push the model to every agent afterwards. */
15717
- distribute: boolean()
15408
+ preferredProviders: array(string()).readonly().optional(),
15409
+ /**
15410
+ * Resolution preference within the surviving candidates. `'highest'`
15411
+ * picks the tallest stream; `'lowest'` picks the shortest (used by
15412
+ * memory-constrained consumers / Apple Home guest sessions).
15413
+ */
15414
+ resolutionPreference: _enum(["highest", "lowest"]).optional(),
15415
+ /**
15416
+ * The height the consumer actually wants to DELIVER.
15417
+ *
15418
+ * Not a constraint — an ordering. With it set, the SMALLEST stream at or
15419
+ * above the target wins, and only if nothing reaches it does the tallest
15420
+ * take over. Pulling 1296 lines to draw 720 on a 1280x800 Echo panel costs
15421
+ * a decode and buys nothing, and `resolutionPreference: 'highest'` cannot
15422
+ * express that: it says "as big as possible", which is a different wish.
15423
+ *
15424
+ * Ignored when absent, so every existing caller keeps its ordering.
15425
+ */
15426
+ targetHeight: number().int().positive().optional()
15427
+ }).readonly();
15428
+ var PickedCamStreamSchema = object({
15429
+ camStreamId: string(),
15430
+ codec: string().optional(),
15431
+ resolution: CamStreamResolutionSchema.optional(),
15432
+ /**
15433
+ * Whether serving this stream requires a decode + re-encode.
15434
+ *
15435
+ * `false` is a stream the consumer can take as it stands. Only ever `true`
15436
+ * when the caller asked for it with `allowTranscode`, so a caller that did
15437
+ * not ask cannot be handed a cost it never agreed to pay.
15438
+ */
15439
+ transcodes: boolean(),
15440
+ /** One-line explanation of why this stream won — for logs / debug UI. */
15441
+ reason: string(),
15442
+ /**
15443
+ * The broker profile slot this source is bound to, when it is bound.
15444
+ *
15445
+ * Answered HERE because every consumer needs it and none of them can get it
15446
+ * from the `camStreamId` alone. Alexa wrote `profileForCamStream` for it —
15447
+ * a second cap read plus a `null` branch — and HomeKit was about to write
15448
+ * the same function a second time.
15449
+ *
15450
+ * Absent when the source has no slot, which a caller passing
15451
+ * `requireProfileBound` can never see.
15452
+ */
15453
+ profile: CamProfileSchema.optional(),
15454
+ /** The broker id of that slot, for logs that must name what was dialled. */
15455
+ brokerId: string().optional(),
15456
+ /**
15457
+ * The broker restream URL to dial for this stream.
15458
+ *
15459
+ * An exporter must never open the camera's own URL — the broker is the one
15460
+ * that dials the camera, and every consumer goes through its restream. So
15461
+ * the picker answers with the thing a consumer may actually use, instead of
15462
+ * naming a source and leaving each caller to look the URL up.
15463
+ */
15464
+ url: string().optional(),
15465
+ /**
15466
+ * Frames per second this stream really delivers, and where the number came
15467
+ * from.
15468
+ *
15469
+ * It is here because getting it wrong is expensive and silent: a HomeKit
15470
+ * accessory advertised 720p at 24 fps while the slot serving it ran at 9,
15471
+ * and the controller rendered nothing at all. The source is carried with the
15472
+ * value for the same reason the bitrate evidence is: `published` is what the
15473
+ * publisher declares and `measured` is what the broker observed, and they
15474
+ * are not interchangeable.
15475
+ */
15476
+ fps: number().positive().optional(),
15477
+ fpsSource: _enum(["published", "measured"]).optional()
15718
15478
  });
15719
- var ImportModelBundleResultSchema = discriminatedUnion("kind", [object({
15720
- kind: literal("imported"),
15721
- modelIds: array(string()).readonly(),
15722
- distributed: array(object({
15723
- modelId: string(),
15724
- nodeId: string(),
15725
- format: _enum(MODEL_FORMATS),
15726
- ok: boolean(),
15727
- error: string().optional()
15728
- })).readonly()
15479
+ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(CameraStreamSchema).readonly()), method(object({ deviceId: number().int().nonnegative() }), array(ProfileSlotSchema).readonly()), method(object({
15480
+ deviceId: number().int().nonnegative(),
15481
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
15482
+ hostname: string().optional()
15483
+ }), array(RtspRestreamEntrySchema).readonly()), method(object({
15484
+ deviceId: number().int().nonnegative(),
15485
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
15486
+ hostname: string().optional()
15487
+ }), array(ProfileRtspEntrySchema).readonly()), method(object({
15488
+ deviceId: number().int().nonnegative(),
15489
+ requirements: PickStreamRequirementsSchema,
15490
+ preferences: PickStreamPreferencesSchema.optional()
15491
+ }), PickedCamStreamSchema.nullable()), object({
15492
+ deviceId: number().int().nonnegative(),
15493
+ camStreams: array(CameraStreamSchema).readonly()
15729
15494
  }), object({
15730
- kind: literal("refused"),
15731
- reason: _enum([
15732
- "transfer-failed",
15733
- "checksum-mismatch",
15734
- "bundle-mismatch",
15735
- "invalid-entry",
15736
- "id-taken"
15495
+ deviceId: number().int().nonnegative(),
15496
+ profileSlots: array(ProfileSlotSchema).readonly()
15497
+ }), object({
15498
+ online: boolean(),
15499
+ slotStatuses: object({
15500
+ high: ProfileSlotStatusSchema.optional(),
15501
+ mid: ProfileSlotStatusSchema.optional(),
15502
+ low: ProfileSlotStatusSchema.optional()
15503
+ }),
15504
+ slotErrors: object({
15505
+ high: string().optional(),
15506
+ mid: string().optional(),
15507
+ low: string().optional()
15508
+ }),
15509
+ lastChangedAt: number()
15510
+ });
15511
+ EventCategory.DoorbellOnPressed;
15512
+ var CompositionEventPlanSchema = object({
15513
+ featureIndex: number().int().nonnegative(),
15514
+ cap: string(),
15515
+ eventName: string(),
15516
+ category: _enum(EventCategory),
15517
+ stateField: string(),
15518
+ fieldKey: string()
15519
+ });
15520
+ /** One refusal, named (D391). `path` points into the composition, e.g. `features[0].fields.celsius`. */
15521
+ var CompositionProblemSchema = object({
15522
+ code: _enum([
15523
+ "reserved-variant",
15524
+ "unknown-capability",
15525
+ "not-composable",
15526
+ "wrong-device-type",
15527
+ "has-commands",
15528
+ "unknown-field",
15529
+ "nested-field",
15530
+ "missing-field",
15531
+ "kind-mismatch",
15532
+ "may-be-null",
15533
+ "source-missing",
15534
+ "source-unreadable",
15535
+ "source-capability-missing",
15536
+ "source-field-unknown",
15537
+ "expression-invalid",
15538
+ "cycle",
15539
+ "duplicate-target",
15540
+ "not-item-array",
15541
+ "item-field-reserved",
15542
+ "target-missing",
15543
+ "target-unreadable",
15544
+ "not-replaceable"
15737
15545
  ]),
15738
- detail: string()
15739
- })]);
15740
- var customModelRegistryCapability = {
15741
- name: "custom-model-registry",
15742
- scope: "system",
15743
- mode: "collection",
15744
- internal: true,
15745
- methods: {
15746
- /**
15747
- * `internal: true` did not gate the mount (see the 2026-08-26 note on
15748
- * `data-store-provider`) — this was reachable on the AppRouter by ANY
15749
- * authenticated session at the default `auth: 'protected'`. The only
15750
- * consumer, the detection-pipeline's model resolution
15751
- * (`addon-pipeline/src/detection-pipeline/provider.ts`), calls
15752
- * `ctx.api.customModelRegistry.listModels.query()` — UDS/Moleculer,
15753
- * never tRPC — so `auth: 'admin'` costs it nothing.
15754
- */
15755
- listModels: method(_void(), array(CustomModelDescriptorSchema).readonly(), { auth: "admin" }),
15756
- /**
15757
- * Register a model whose files arrive as ONE bundle tar (D806): the tar
15758
- * is taken over `ctx.peerBytes`, checked against `bundle.sha256` and
15759
- * every row of `files`, unpacked into the hub's flat modelsDir (directory
15760
- * formats included), each format's `.revision` record written, the rows
15761
- * upserted under the operator's name, the hub marked available and —
15762
- * when asked — engine formats converted and the model distributed. A
15763
- * model id already registered by another source is refused, never
15764
- * overwritten.
15765
- */
15766
- importModelBundle: method(ImportModelBundleInputSchema, ImportModelBundleResultSchema, {
15767
- kind: "mutation",
15768
- auth: "admin",
15769
- timeoutMs: 30 * 6e4
15770
- })
15771
- }
15772
- };
15546
+ path: string(),
15547
+ message: string()
15548
+ });
15549
+ var CompositionFieldKindSchema = _enum([
15550
+ "string",
15551
+ "number",
15552
+ "boolean",
15553
+ "enum"
15554
+ ]);
15773
15555
  /**
15774
- * Query filter for settings-store collections.
15556
+ * How a field says "unavailable" (D393, D663): `null` when its schema allows it;
15557
+ * `unknown` — the enum's own word — for an ADDED field that has one; `release`
15558
+ * for a REPLACED field of an existing device, whose claim is released so the
15559
+ * native provider's reading shows again; else the (composed) device goes offline.
15775
15560
  */
15776
- var QueryFilterSchema = object({
15777
- where: record(string(), unknown()).optional(),
15778
- whereIn: record(string(), array(unknown())).optional(),
15779
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
15780
- /** NULL-safe exclusion: matches rows whose field is NULL OR != the value. */
15781
- whereNot: record(string(), unknown()).optional(),
15782
- /** NULL-safe exclusion of a SET — `whereNot` for more than one value. An
15783
- * empty list excludes nothing. See `QueryFilter.whereNotIn`. */
15784
- whereNotIn: record(string(), array(unknown())).optional(),
15785
- orderBy: object({
15786
- field: string(),
15787
- direction: _enum(["asc", "desc"])
15788
- }).optional(),
15789
- limit: number().optional(),
15790
- offset: number().optional()
15561
+ var CompositionUnavailableModeSchema = _enum([
15562
+ "null",
15563
+ "offline",
15564
+ "unknown",
15565
+ "release"
15566
+ ]);
15567
+ /**
15568
+ * `derived` reads a source · `clock` reads only `now`, recomputed when a sibling
15569
+ * field of its cap changes value · `constant` reads nothing, computed once ·
15570
+ * `accumulator` advances from the target's seed/current output.
15571
+ */
15572
+ var CompositionFieldRoleSchema = _enum([
15573
+ "derived",
15574
+ "clock",
15575
+ "constant",
15576
+ "accumulator"
15577
+ ]);
15578
+ /** The item a planned field belongs to; its `path` is then the dotted path INSIDE the item. */
15579
+ var CompositionFieldItemSchema = object({
15580
+ arrayPath: string(),
15581
+ key: string(),
15582
+ label: string()
15583
+ });
15584
+ var CompositionFieldPlanSchema = object({
15585
+ cap: string(),
15586
+ /** Top-level field, or — when `item` is set — the dotted path inside that item. */
15587
+ path: string(),
15588
+ item: CompositionFieldItemSchema.optional(),
15589
+ /** Index into `composition.features` this field's capability came from —
15590
+ * lets a source/cycle problem name its path as `features[N].fields.<path>`
15591
+ * (the same convention `planComposition`'s own problems already use). */
15592
+ featureIndex: number().int().nonnegative(),
15593
+ kind: CompositionFieldKindSchema,
15594
+ nullable: boolean(),
15595
+ required: boolean(),
15596
+ unavailable: CompositionUnavailableModeSchema,
15597
+ role: CompositionFieldRoleSchema,
15598
+ source: CompositionFieldSourceSchema,
15599
+ reads: array(CompositionFieldReadSchema)
15791
15600
  });
15792
15601
  /**
15793
- * The predicate half of a filter, for BULK MUTATIONS.
15794
- *
15795
- * Deliberately not `QueryFilterSchema`: `orderBy` / `limit` / `offset` have no
15796
- * meaning for a statement that rewrites a set, and accepting them would invite
15797
- * a caller to believe `limit` bounds the damage. Every field is optional here
15798
- * only so the shape stays composable — the implementation REJECTS a filter
15799
- * that compiles to no predicate, because that is the whole collection.
15602
+ * What a feature does to its target (D663): `new` builds a capability on a new
15603
+ * device; on an existing device it `add`s a capability the device lacks, or
15604
+ * `replace`s named fields of one it has natively.
15800
15605
  */
15801
- var MutationFilterSchema = object({
15802
- where: record(string(), unknown()).optional(),
15803
- whereIn: record(string(), array(unknown())).optional(),
15804
- whereBetween: record(string(), tuple([unknown(), unknown()])).optional(),
15805
- whereNot: record(string(), unknown()).optional(),
15806
- whereNotIn: record(string(), array(unknown())).optional()
15606
+ var CompositionFeatureModeSchema = _enum([
15607
+ "new",
15608
+ "add",
15609
+ "replace"
15610
+ ]);
15611
+ var CompositionFeaturePlanSchema = object({
15612
+ /** Index in the stored feature array; disabled features are omitted rather than re-indexed. */
15613
+ featureIndex: number().int().nonnegative(),
15614
+ cap: string(),
15615
+ mode: CompositionFeatureModeSchema
15616
+ });
15617
+ var CompositionValidationSchema = object({
15618
+ ok: boolean(),
15619
+ problems: array(CompositionProblemSchema),
15620
+ fields: array(CompositionFieldPlanSchema),
15621
+ features: array(CompositionFeaturePlanSchema),
15622
+ events: array(CompositionEventPlanSchema)
15807
15623
  });
15808
15624
  /**
15809
- * One scalar an {@link settingsStoreCapability.methods.aggregate} call asks for.
15810
- *
15811
- * `as` names the slot in the result, so the SAME column may be asked twice with
15812
- * two operations (`MIN(startMs)` and `MAX(startMs)` in one round trip) — which
15813
- * a `Record<column, op>` shape could not express.
15625
+ * Mirrors `CoreBlockStatus`, pinned by `core-blocks-source.spec.ts`. `degraded`:
15626
+ * running, but a replaced field is released to the native provider while its
15627
+ * source is unavailable (ruling S1b, D663) — the reason names each such field.
15814
15628
  */
15815
- var AggregateFieldSchema = object({
15816
- /** Result key. */
15817
- as: string().min(1),
15818
- /** Column to aggregate. Must be a real column of a declared collection. */
15819
- field: string().min(1),
15820
- op: _enum([
15821
- "sum",
15822
- "min",
15823
- "max"
15824
- ])
15629
+ var CompositionRunStatusSchema = _enum([
15630
+ "stopped",
15631
+ "starting",
15632
+ "running",
15633
+ "degraded",
15634
+ "failed"
15635
+ ]);
15636
+ var CompositionFieldStateKindSchema = _enum([
15637
+ "fresh",
15638
+ "stale",
15639
+ "unavailable"
15640
+ ]);
15641
+ var CompositionFieldStateSchema = object({
15642
+ cap: string(),
15643
+ path: string(),
15644
+ /** Same as the plan's: two items' `status` are told apart by it. */
15645
+ item: CompositionFieldItemSchema.optional(),
15646
+ state: CompositionFieldStateKindSchema,
15647
+ /** `null` whenever `state === 'unavailable'`; never a plausible stand-in (D393). */
15648
+ value: union([
15649
+ string(),
15650
+ number(),
15651
+ boolean(),
15652
+ _null()
15653
+ ]),
15654
+ reason: string().optional()
15655
+ });
15656
+ var CompositionBlockStateSchema = object({
15657
+ blockId: string(),
15658
+ deviceId: number().nullable(),
15659
+ status: CompositionRunStatusSchema,
15660
+ reason: string().optional(),
15661
+ fields: array(CompositionFieldStateSchema)
15662
+ });
15663
+ var ComposerBlockSchema = object({
15664
+ id: string().min(1),
15665
+ name: string().min(1).max(120),
15666
+ enabled: boolean(),
15667
+ updatedAt: number(),
15668
+ composition: CompositionSchema
15825
15669
  });
15826
15670
  /**
15827
- * `COUNT(*)` plus one number per requested field.
15828
- *
15829
- * `null` means NO ROW MATCHED, never zero: a `SUM` over an empty set and a sum
15830
- * that really is 0 are different facts, and an accounting caller that renders
15831
- * "0 bytes, oldest = 0" for "nothing here" reports a lie about a disk.
15671
+ * A stored composition row that no longer PARSES — a schema a release
15672
+ * tightened, a row written by a newer build. Sent by id so the composer keeps
15673
+ * its device as a placeholder and reports the block `failed` naming the parse
15674
+ * error. Leaving it out would read, twice, exactly like a delete (D49).
15832
15675
  */
15833
- var AggregateResultSchema = object({
15834
- count: number().int(),
15835
- values: record(string(), number().nullable())
15676
+ var ComposerUnreadableBlockSchema = object({
15677
+ id: string().min(1),
15678
+ /** The row's stored name column; null when even that is unreadable (the device keeps the name it has). */
15679
+ name: string().nullable(),
15680
+ /** The parse error, in the operator's words. */
15681
+ error: string()
15836
15682
  });
15837
- /** A single stored record: `{ id, data }`. */
15838
- var SettingsRecordSchema = object({
15839
- id: string(),
15840
- data: record(string(), unknown())
15683
+ method(object({
15684
+ /** The shared Blocks integration; null until `core-blocks` has reconciled it. */
15685
+ integrationId: string().nullable(),
15686
+ /** False when the `core_blocks` read failed: then nothing may be withdrawn (D49). */
15687
+ storeLoaded: boolean(),
15688
+ blocks: array(ComposerBlockSchema),
15689
+ /** Operator restarts: rebuild these runtimes and re-resolve their sources. Stores nothing (D62). */
15690
+ restartBlockIds: array(string()),
15691
+ /** Composition rows that exist but do not parse: held, never withdrawn, reported failed. */
15692
+ unreadableBlocks: array(ComposerUnreadableBlockSchema)
15693
+ }), object({
15694
+ blocks: array(object({
15695
+ blockId: string(),
15696
+ status: CompositionRunStatusSchema,
15697
+ reason: string().optional()
15698
+ })),
15699
+ /**
15700
+ * False when the composer declared and withdrew NOTHING (store not loaded,
15701
+ * integration unknown, its own rows unread or read empty once). An empty
15702
+ * apply the caller owes is paid off only by one the composer acted on (D49).
15703
+ */
15704
+ acted: boolean()
15705
+ }), {
15706
+ kind: "mutation",
15707
+ auth: "admin"
15708
+ }), method(object({ blockIds: array(string()) }), object({ states: array(CompositionBlockStateSchema) }), { auth: "admin" });
15709
+ var NUMBER = {
15710
+ kind: "fixed",
15711
+ result: ["number"]
15712
+ };
15713
+ var BOOLEAN = {
15714
+ kind: "fixed",
15715
+ result: ["boolean"]
15716
+ };
15717
+ Object.freeze({
15718
+ min: NUMBER,
15719
+ max: NUMBER,
15720
+ abs: NUMBER,
15721
+ floor: NUMBER,
15722
+ ceil: NUMBER,
15723
+ sqrt: NUMBER,
15724
+ round: NUMBER,
15725
+ pow: NUMBER,
15726
+ clamp: NUMBER,
15727
+ avg: NUMBER,
15728
+ sum: NUMBER,
15729
+ age: NUMBER,
15730
+ convert: NUMBER,
15731
+ coalesce: { kind: "coalesce" },
15732
+ any: BOOLEAN,
15733
+ all: BOOLEAN,
15734
+ within: BOOLEAN,
15735
+ latest: {
15736
+ kind: "fixed",
15737
+ result: ["number", "null"]
15738
+ },
15739
+ number: {
15740
+ kind: "fixed",
15741
+ result: ["number", "null"]
15742
+ },
15743
+ rose: {
15744
+ kind: "fixed",
15745
+ result: ["number", "null"]
15746
+ },
15747
+ count: NUMBER
15841
15748
  });
15749
+ `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
15842
15750
  /**
15843
- * One record of a BULK insert — {@link SettingsRecordSchema} with the id made
15844
- * optional.
15845
- *
15846
- * A separate schema rather than loosening the shared one: every other method
15847
- * on this cap addresses a row BY its id, and making that field optional
15848
- * everywhere would turn a forgotten key into a silently generated one on
15849
- * `update` and `delete` as well.
15751
+ * What a composition may BUILD on a device type (G2, D676): the caps a new
15752
+ * device of that type, or an existing one that lacks them, can be given. The
15753
+ * blockers are the validator's own refusal codes, and a spec compares this
15754
+ * listing with `planComposition` for every capability — the editor can never
15755
+ * offer a feature the save would refuse for the same reason.
15850
15756
  */
15851
- var BulkRecordSchema = object({
15852
- id: string().optional(),
15853
- data: record(string(), unknown())
15757
+ var ComposableFieldSchema = object({
15758
+ path: string(),
15759
+ kind: CompositionFieldKindSchema,
15760
+ nullable: boolean(),
15761
+ required: boolean(),
15762
+ enumValues: array(string()).optional()
15854
15763
  });
15764
+ var ComposableCapabilitiesSchema = object({ caps: array(object({
15765
+ cap: string(),
15766
+ fields: array(ComposableFieldSchema),
15767
+ /** Present for an item-array cap (`consumables.items`). */
15768
+ itemArray: object({
15769
+ path: string(),
15770
+ keyField: string()
15771
+ }).optional()
15772
+ })) });
15773
+ /** Where a block runs. The operator chooses — a block driving a device on an
15774
+ * agent is the reason placement is not fixed to the hub. */
15775
+ var CoreBlockPlacementSchema = union([literal("hub"), string().min(1)]);
15776
+ /** What a block's process is doing. Mirrors the addon runner's own lifecycle so
15777
+ * a failing block reads the same way a failing addon does. `degraded` is a
15778
+ * composition on an existing device with a field handed back to the native
15779
+ * provider while its source is unavailable (S1b, D663); the error names it. */
15780
+ var CoreBlockStatusSchema = _enum([
15781
+ "stopped",
15782
+ "starting",
15783
+ "running",
15784
+ "degraded",
15785
+ "failed"
15786
+ ]);
15787
+ /** path → TypeScript source for a multi-file block. */
15788
+ var CoreBlockFilesSchema = record(string().min(1).max(260), string().max(2e5));
15789
+ /**
15790
+ * A block's settings form — the same ConfigUISchema addons declare.
15791
+ * Loose on the wire (field variants are polymorphic); typed for callers.
15792
+ */
15793
+ var CoreBlockSettingsSchemaBridge = custom();
15855
15794
  /**
15856
- * The database FILES one `sqlite-settings` engine process owns (D851).
15857
- * Closed on purpose: a file is a thing the engine opens, backs up and gives a
15858
- * serving thread, so a new one is a code change, never a string a caller
15859
- * invents. `settings` is `camstack.db` and the default for every collection
15860
- * that names no file.
15795
+ * Client-authored fields (plain object). Kept separate from the refined create
15796
+ * schema because Zod 4 refuses `.partial()` on schemas that carry refinements —
15797
+ * `update` needs a partial of these fields.
15861
15798
  */
15862
- var DataStoreDatabaseSchema = _enum([
15863
- "settings",
15864
- "events",
15865
- "history"
15866
- ]);
15867
- DataStoreDatabaseSchema.options;
15799
+ var CoreBlockInputFieldsSchema = object({
15800
+ name: string().min(1).max(120),
15801
+ /**
15802
+ * Single-file TypeScript source. Kept for backward compatibility: when
15803
+ * `files` is absent, this IS `files[entry]`. On write, if `files` is
15804
+ * provided, the server derives `code` from `files[entry]` so compile and
15805
+ * runtime paths that still read `code` keep working.
15806
+ */
15807
+ code: string().max(2e5).optional(),
15808
+ /** Multi-file project (path → source). Absent = legacy single-file `code`. */
15809
+ files: CoreBlockFilesSchema.optional(),
15810
+ /** Entry file inside `files`. Default {@link DEFAULT_CORE_BLOCK_ENTRY}. */
15811
+ entry: string().min(1).max(260).optional(),
15812
+ enabled: boolean(),
15813
+ placement: CoreBlockPlacementSchema,
15814
+ /**
15815
+ * Integration the block's devices hang from. Absent = the system integration
15816
+ * blocks share. A block may declare its own instead.
15817
+ */
15818
+ integrationId: string().optional(),
15819
+ /**
15820
+ * Cached settings form for this block. Prefer extracting `settingsSchema`
15821
+ * from the compiled entry after a successful load; the editor may also set
15822
+ * it. Absent ⇒ `getBlockSettings` returns null until one is known.
15823
+ */
15824
+ settingsSchema: CoreBlockSettingsSchemaBridge.optional()
15825
+ });
15826
+ /** Client-authored fields for create — requires `code` or `files[entry]`. */
15827
+ var CoreBlockInputSchema = CoreBlockInputFieldsSchema.superRefine((val, ctx) => {
15828
+ const entry = val.entry ?? "main.ts";
15829
+ if (val.files !== void 0) {
15830
+ if (val.files[entry] === void 0) ctx.addIssue({
15831
+ code: "custom",
15832
+ message: `files must include the entry "${entry}"`,
15833
+ path: ["files"]
15834
+ });
15835
+ return;
15836
+ }
15837
+ if (val.code === void 0) ctx.addIssue({
15838
+ code: "custom",
15839
+ message: "code or files is required",
15840
+ path: ["code"]
15841
+ });
15842
+ });
15868
15843
  /**
15869
- * One write inside a {@link settingsStoreCapability.methods.transact} call.
15870
- * The three that a "replace this set, bump that revision" write needs, plus
15871
- * the batched upsert a rollup writer needs.
15844
+ * What a block IS (D659). Absent means `code` — every row stored before this
15845
+ * union keeps working unchanged.
15846
+ * - `code`: the TypeScript in `code`, compiled and run in its own runner;
15847
+ * - `recipe`: RESERVED (automations), stored shape only; refused at run by name;
15848
+ * - `composition`: declarative, run by the shared `composer`; `code` is `''`.
15872
15849
  */
15873
- var TransactOpSchema = discriminatedUnion("op", [
15874
- object({
15875
- op: literal("deleteWhere"),
15876
- collection: string(),
15877
- filter: MutationFilterSchema
15878
- }),
15879
- object({
15880
- op: literal("insertMany"),
15881
- collection: string(),
15882
- records: array(BulkRecordSchema).readonly()
15883
- }),
15850
+ var CoreBlockSourceSchema = discriminatedUnion("kind", [
15851
+ object({ kind: literal("code") }),
15884
15852
  object({
15885
- op: literal("update"),
15886
- collection: string(),
15887
- id: string(),
15888
- data: record(string(), unknown())
15853
+ kind: literal("recipe"),
15854
+ recipe: AutomationRecipeSchema
15889
15855
  }),
15890
15856
  object({
15891
- op: literal("upsertMany"),
15892
- collection: string(),
15893
- conflict: array(string()).readonly(),
15894
- records: array(BulkRecordSchema).readonly()
15857
+ kind: literal("composition"),
15858
+ composition: CompositionSchema
15895
15859
  })
15896
15860
  ]);
15897
15861
  /**
15898
- * The precondition of a transaction: the row `key` of `collection` must hold
15899
- * `equals` in `column`, read INSIDE the transaction.
15900
- */
15901
- var TransactGuardSchema = object({
15902
- collection: string(),
15903
- key: string(),
15904
- column: string(),
15905
- equals: unknown()
15906
- });
15907
- /**
15908
- * Column declaration for a structured (SQL-backed) collection.
15862
+ * The client-authored half of a composition block. No `code`, no placement (hub-only; Phase 7).
15909
15863
  *
15910
- * Logical types — the backend translates each to the matching SQLite
15911
- * storage class and handles per-type marshaling:
15912
- * - `TEXT` / `INTEGER` / `REAL` — native SQLite types, pass-through
15913
- * - `JSON` — TEXT under the hood; serialised on write, parsed on read
15914
- * - `BOOLEAN` — INTEGER 0/1 under the hood; coerced both directions
15864
+ * `name`: REQUIRED for a `new` target — the block's name IS the device's name
15865
+ * (D659). ABSENT for an `existing` target — a customization has no name of its
15866
+ * own; the server derives one (`customizationBlockName`, D663) and refuses a
15867
+ * client-sent name by name.
15915
15868
  */
15916
- var CollectionColumnSchema = object({
15917
- name: string(),
15918
- type: _enum([
15919
- "TEXT",
15920
- "INTEGER",
15921
- "REAL",
15922
- "JSON",
15923
- "BOOLEAN"
15924
- ]),
15925
- primaryKey: boolean().optional(),
15926
- notNull: boolean().optional(),
15927
- unique: boolean().optional(),
15869
+ var CompositionBlockInputSchema = object({
15870
+ name: string().min(1).max(120).optional(),
15871
+ enabled: boolean(),
15872
+ composition: CompositionSchema
15873
+ });
15874
+ /** A stored block. `code` is always present after normalize (derived from files). */
15875
+ var CoreBlockSchema = object({
15876
+ name: string().min(1).max(120),
15877
+ /** Single-file source, or `files[entry]` when multi-file. Always set on disk. */
15878
+ code: string().max(2e5),
15879
+ files: CoreBlockFilesSchema.optional(),
15880
+ entry: string().min(1).max(260).optional(),
15881
+ enabled: boolean(),
15882
+ placement: CoreBlockPlacementSchema,
15883
+ integrationId: string().optional(),
15884
+ settingsSchema: CoreBlockSettingsSchemaBridge.optional(),
15885
+ id: string(),
15886
+ createdAt: number(),
15887
+ updatedAt: number(),
15888
+ /** Server-stamped author. */
15889
+ createdBy: string(),
15890
+ status: CoreBlockStatusSchema,
15928
15891
  /**
15929
- * Column DEFAULT. Required for B2: the four `ensureTable` consumers declare
15930
- * columns like `enabled INTEGER NOT NULL DEFAULT 1`, and without this the
15931
- * collection surface simply cannot express their existing tables — which is
15932
- * why they were still on the uncapped `table*` API.
15892
+ * Why the block is not running, when it is not. The operator's ONLY window
15893
+ * into a block that failed at load — a block that is silently absent is the
15894
+ * failure mode this whole feature has to avoid.
15933
15895
  */
15934
- defaultValue: union([
15935
- string(),
15936
- number(),
15937
- boolean()
15938
- ]).optional()
15896
+ lastError: string().optional(),
15897
+ /** Ms epoch of the last state change. */
15898
+ lastChangedAt: number(),
15899
+ /** What the block is. Absent means `code` (D659). */
15900
+ source: CoreBlockSourceSchema.optional()
15939
15901
  });
15940
- var CollectionIndexSchema = object({
15941
- name: string(),
15942
- columns: array(string()).readonly(),
15943
- unique: boolean().optional()
15902
+ /** A stored composition block and what the composer says about it. */
15903
+ var CompositionViewSchema = object({
15904
+ block: CoreBlockSchema,
15905
+ state: discriminatedUnion("kind", [object({
15906
+ kind: literal("answered"),
15907
+ state: CompositionBlockStateSchema.nullable()
15908
+ }), object({
15909
+ kind: literal("unreachable"),
15910
+ error: string()
15911
+ })])
15944
15912
  });
15945
15913
  /**
15946
- * The input of `declareCollection` — ONE schema for the door and for the
15947
- * engine behind it, so the two surfaces cannot drift (the dispatcher forwards
15948
- * the payload untouched).
15949
- *
15950
- * - `database` — the file the collection lives in (D851). Absent means
15951
- * `settings`. A declaration naming a file other than the one the table is
15952
- * already in is REFUSED by the engine, never moved.
15953
- * - `primaryKey` — a COMPOSITE key, as column names. A single name is the same
15954
- * as `primaryKey: true` on that column. A composite-keyed collection has no
15955
- * `id`: it is written with `insertMany` / `upsertMany` / `transact` and read
15956
- * with `query` / `count` / `aggregate` / `deleteWhere`; the calls that
15957
- * address one row by id are refused.
15958
- * - `withoutRowid` — emit `WITHOUT ROWID`. Requires a primary key.
15914
+ * A read of the stored compositions. `not-loaded` while the `core_blocks`
15915
+ * table has not been READ: an empty answer then would make "not yet known"
15916
+ * look like "none", and invite the operator to recreate what exists (D315).
15959
15917
  */
15960
- var DeclareCollectionInputSchema = object({
15961
- namespace: string().optional(),
15962
- collection: string(),
15963
- columns: array(CollectionColumnSchema).readonly(),
15964
- indexes: array(CollectionIndexSchema).readonly().optional(),
15965
- database: DataStoreDatabaseSchema.optional(),
15966
- primaryKey: array(string()).readonly().optional(),
15967
- withoutRowid: boolean().optional()
15918
+ var CompositionsNotLoadedSchema = object({
15919
+ state: literal("not-loaded"),
15920
+ reason: string()
15921
+ });
15922
+ var CompositionListReadSchema = discriminatedUnion("state", [object({
15923
+ state: literal("loaded"),
15924
+ views: array(CompositionViewSchema)
15925
+ }), CompositionsNotLoadedSchema]);
15926
+ var CompositionGetReadSchema = discriminatedUnion("state", [object({
15927
+ state: literal("loaded"),
15928
+ view: CompositionViewSchema.nullable()
15929
+ }), CompositionsNotLoadedSchema]);
15930
+ /** The customization of one existing device, found by its TARGET, or `not-loaded` (D315). */
15931
+ var CompositionCustomizationReadSchema = discriminatedUnion("state", [object({
15932
+ state: literal("loaded"),
15933
+ view: CompositionViewSchema.nullable()
15934
+ }), CompositionsNotLoadedSchema]);
15935
+ /** What a compile attempt produced. */
15936
+ var CoreBlockCompileResultSchema = object({
15937
+ ok: boolean(),
15938
+ /** Present when `ok` is false — the first error, in the author's words. */
15939
+ error: string().optional(),
15940
+ line: number().optional(),
15941
+ column: number().optional()
15968
15942
  });
15969
- method(object({
15970
- namespace: string().optional(),
15971
- collection: string(),
15972
- key: string()
15973
- }), unknown(), { auth: "admin" }), method(object({
15974
- namespace: string().optional(),
15975
- collection: string(),
15976
- key: string(),
15977
- value: unknown()
15978
- }), _void(), {
15979
- kind: "mutation",
15980
- auth: "admin"
15981
- }), method(object({
15982
- namespace: string().optional(),
15983
- collection: string(),
15984
- filter: QueryFilterSchema.optional(),
15985
- columns: array(string()).readonly().optional()
15986
- }), array(SettingsRecordSchema).readonly(), { auth: "admin" }), method(object({
15987
- namespace: string().optional(),
15988
- collection: string(),
15989
- /** Declared columns identifying the slot; must be covered by a UNIQUE
15990
- * index on the collection. Never empty. */
15991
- conflict: array(string()).readonly(),
15992
- record: BulkRecordSchema
15993
- }), object({
15994
- /** The id the row HAS: assigned by SQLite on an insert, or the existing
15995
- * row's own id on an update. */
15996
- id: union([string(), number()]) }), {
15997
- kind: "mutation",
15998
- auth: "admin"
15999
- }), method(object({
16000
- namespace: string().optional(),
16001
- collection: string(),
16002
- conflict: array(string()).readonly(),
16003
- records: array(BulkRecordSchema).readonly()
16004
- }), object({ upserted: number().int() }), {
16005
- kind: "mutation",
16006
- auth: "admin"
16007
- }), method(object({
16008
- namespace: string().optional(),
16009
- collection: string(),
16010
- record: object({
16011
- id: string().optional(),
16012
- data: record(string(), unknown())
16013
- })
16014
- }), object({
16015
15943
  /**
16016
- * The id the row ACTUALLY got (D473): the one supplied, the UUID minted
16017
- * for an absent one, or the ROWID SQLite assigned on an `INTEGER`
16018
- * primary key — which is the only place an auto key is knowable.
15944
+ * Compile input: legacy single-file `{ code }`, or a multi-file project.
15945
+ * When `files` is present, it wins over `code`.
16019
15946
  */
16020
- id: union([string(), number()]) }), {
15947
+ var CoreBlockCompileInputSchema = object({
15948
+ code: string().optional(),
15949
+ files: CoreBlockFilesSchema.optional(),
15950
+ entry: string().min(1).max(260).optional()
15951
+ }).superRefine((val, ctx) => {
15952
+ if (val.files !== void 0) {
15953
+ const entry = val.entry ?? "main.ts";
15954
+ if (val.files[entry] === void 0) ctx.addIssue({
15955
+ code: "custom",
15956
+ message: `files must include the entry "${entry}"`,
15957
+ path: ["files"]
15958
+ });
15959
+ return;
15960
+ }
15961
+ if (val.code === void 0) ctx.addIssue({
15962
+ code: "custom",
15963
+ message: "code or files is required",
15964
+ path: ["code"]
15965
+ });
15966
+ });
15967
+ method(object({}), object({ blocks: array(CoreBlockSchema) }), { auth: "admin" }), method(object({ blockId: string() }), object({ block: CoreBlockSchema.nullable() }), { auth: "admin" }), method(object({ block: CoreBlockInputSchema }), object({ block: CoreBlockSchema }), {
16021
15968
  kind: "mutation",
16022
- auth: "admin"
15969
+ auth: "admin",
15970
+ caller: "required"
16023
15971
  }), method(object({
16024
- namespace: string().optional(),
16025
- collection: string(),
16026
- records: array(BulkRecordSchema).readonly()
16027
- }), object({ inserted: number().int() }), {
15972
+ blockId: string(),
15973
+ block: CoreBlockInputFieldsSchema.partial()
15974
+ }), object({ block: CoreBlockSchema }), {
16028
15975
  kind: "mutation",
16029
- auth: "admin"
16030
- }), method(object({
16031
- namespace: string().optional(),
16032
- guard: TransactGuardSchema.optional(),
16033
- ops: array(TransactOpSchema).readonly()
16034
- }), object({
16035
- applied: boolean(),
16036
- current: unknown().optional()
16037
- }), {
15976
+ auth: "admin",
15977
+ caller: "required"
15978
+ }), method(object({ blockId: string() }), object({ success: literal(true) }), {
16038
15979
  kind: "mutation",
16039
15980
  auth: "admin"
16040
15981
  }), method(object({
16041
- namespace: string().optional(),
16042
- collection: string(),
16043
- id: string(),
16044
- data: record(string(), unknown())
16045
- }), _void(), {
15982
+ blockId: string(),
15983
+ enabled: boolean()
15984
+ }), object({ block: CoreBlockSchema }), {
16046
15985
  kind: "mutation",
16047
15986
  auth: "admin"
16048
- }), method(object({
16049
- namespace: string().optional(),
16050
- collection: string(),
16051
- key: string()
16052
- }), _void(), {
15987
+ }), method(object({ blockId: string() }), object({ block: CoreBlockSchema }), {
16053
15988
  kind: "mutation",
16054
15989
  auth: "admin"
16055
- }), method(object({
16056
- namespace: string().optional(),
16057
- collection: string(),
16058
- filter: MutationFilterSchema
16059
- }), object({ deleted: number().int() }), {
15990
+ }), method(CoreBlockCompileInputSchema, CoreBlockCompileResultSchema, {
16060
15991
  kind: "mutation",
16061
15992
  auth: "admin"
16062
- }), method(object({
16063
- namespace: string().optional(),
16064
- collection: string(),
16065
- filter: MutationFilterSchema,
16066
- data: record(string(), unknown())
16067
- }), object({ updated: number().int() }), {
15993
+ }), method(object({ blockId: string() }), SettingsSchemaWithValuesSchema.nullable(), { auth: "admin" }), method(object({
15994
+ blockId: string(),
15995
+ patch: SettingsPatchSchema
15996
+ }), SettingsUpdateResultSchema, {
16068
15997
  kind: "mutation",
16069
15998
  auth: "admin"
15999
+ }), method(object({}), object({ libs: array(object({
16000
+ filePath: string(),
16001
+ content: string()
16002
+ })) }), { auth: "admin" }), method(object({ block: CompositionBlockInputSchema }), object({ block: CoreBlockSchema }), {
16003
+ kind: "mutation",
16004
+ auth: "admin",
16005
+ caller: "required"
16070
16006
  }), method(object({
16071
- namespace: string().optional(),
16072
- collection: string(),
16073
- filter: QueryFilterSchema.optional()
16074
- }), number(), { auth: "admin" }), method(object({
16075
- namespace: string().optional(),
16076
- collection: string(),
16077
- fields: array(AggregateFieldSchema).readonly(),
16078
- filter: QueryFilterSchema.optional()
16079
- }), AggregateResultSchema, { auth: "admin" }), method(object({
16080
- namespace: string().optional(),
16081
- collection: string(),
16082
- field: string(),
16083
- bucketSize: number().int().positive(),
16084
- origin: number().int(),
16085
- filter: QueryFilterSchema.optional()
16086
- }), array(object({
16087
- bucket: number().int(),
16088
- count: number().int()
16089
- })).readonly(), { auth: "admin" }), method(object({
16090
- namespace: string().optional(),
16091
- collection: string()
16092
- }), boolean(), { auth: "admin" }), method(DeclareCollectionInputSchema, _void(), {
16007
+ blockId: string(),
16008
+ expectedUpdatedAt: number(),
16009
+ block: CompositionBlockInputSchema.partial()
16010
+ }), object({ block: CoreBlockSchema }), {
16093
16011
  kind: "mutation",
16094
- auth: "admin"
16012
+ auth: "admin",
16013
+ caller: "required"
16014
+ }), method(object({ blockId: string() }), CompositionGetReadSchema, { auth: "admin" }), method(object({}), CompositionListReadSchema, { auth: "admin" }), method(object({
16015
+ addonId: string().min(1),
16016
+ stableId: string().min(1)
16017
+ }), CompositionCustomizationReadSchema, { auth: "admin" }), method(object({ deviceType: _enum(DeviceType) }), ComposableCapabilitiesSchema, { auth: "admin" }), method(object({
16018
+ composition: CompositionSchema,
16019
+ blockId: string().optional()
16020
+ }), CompositionValidationSchema, {
16021
+ kind: "mutation",
16022
+ auth: "admin",
16023
+ access: "view"
16024
+ });
16025
+ /**
16026
+ * `custom-model-registry` — collection cap exposing operator-registered
16027
+ * custom detection models. Each provider (today: `addon-model-studio`)
16028
+ * contributes a list of `CustomModelDescriptor`s; the hub auto-concatenates
16029
+ * them across providers (`concatCollection`).
16030
+ *
16031
+ * The detection-pipeline is *aware* of this cap: when at least one provider
16032
+ * exists it unions these descriptors into the per-step model picker and the
16033
+ * runtime model-resolution path, alongside the static catalog. When no
16034
+ * provider exists the consumer no-ops entirely (identical to the catalog-only
16035
+ * behaviour).
16036
+ *
16037
+ * A descriptor carries a full `ModelCatalogEntry` directly — the same shape
16038
+ * the static catalog uses — so the existing download/resolution code consumes
16039
+ * it unchanged. `stepId` is the detection step the model targets
16040
+ * (e.g. `'object-detection'`).
16041
+ */
16042
+ var CustomModelDescriptorSchema = object({
16043
+ stepId: string(),
16044
+ entry: ModelCatalogEntrySchema
16045
+ });
16046
+ /** One regular file of an imported bundle, as the producer measured it. */
16047
+ var ModelBundleFileSchema = object({
16048
+ /** Relative path inside the bundle tar; an mlpackage member is `<x>.mlpackage/<member>`. */
16049
+ path: string().min(1),
16050
+ bytes: number().int().nonnegative(),
16051
+ sha256: string().regex(/^[0-9a-f]{64}$/)
16052
+ });
16053
+ var ImportModelBundleInputSchema = object({
16054
+ /** The registry provider that keeps the model (Model Studio). */
16055
+ addonId: string(),
16056
+ stepId: string().min(1),
16057
+ /**
16058
+ * The catalog rows to register, complete, every format URL already
16059
+ * `camstack-local://<modelId>/<file>` naming a file of the bundle. One
16060
+ * bundle may carry several rows (a base and its INT8 variant).
16061
+ */
16062
+ entries: array(ModelCatalogEntrySchema).min(1).max(4),
16063
+ /** The bundle tar, offered by the producer over `ctx.peerBytes` (same host). */
16064
+ bundle: object({
16065
+ ticket: PeerBytesTicketSchema,
16066
+ bytes: number().int().positive(),
16067
+ sha256: string().regex(/^[0-9a-f]{64}$/)
16068
+ }),
16069
+ /** Every file the tar must hold; anything missing, extra or different is refused. */
16070
+ files: array(ModelBundleFileSchema).min(1).max(64),
16071
+ /** Convert missing engine formats and push the model to every agent afterwards. */
16072
+ distribute: boolean()
16095
16073
  });
16074
+ var ImportModelBundleResultSchema = discriminatedUnion("kind", [object({
16075
+ kind: literal("imported"),
16076
+ modelIds: array(string()).readonly(),
16077
+ distributed: array(object({
16078
+ modelId: string(),
16079
+ nodeId: string(),
16080
+ format: _enum(MODEL_FORMATS),
16081
+ ok: boolean(),
16082
+ error: string().optional()
16083
+ })).readonly()
16084
+ }), object({
16085
+ kind: literal("refused"),
16086
+ reason: _enum([
16087
+ "transfer-failed",
16088
+ "checksum-mismatch",
16089
+ "bundle-mismatch",
16090
+ "invalid-entry",
16091
+ "id-taken"
16092
+ ]),
16093
+ detail: string()
16094
+ })]);
16095
+ var customModelRegistryCapability = {
16096
+ name: "custom-model-registry",
16097
+ scope: "system",
16098
+ mode: "collection",
16099
+ internal: true,
16100
+ methods: {
16101
+ /**
16102
+ * `internal: true` did not gate the mount (see the 2026-08-26 note on
16103
+ * `data-store-provider`) — this was reachable on the AppRouter by ANY
16104
+ * authenticated session at the default `auth: 'protected'`. The only
16105
+ * consumer, the detection-pipeline's model resolution
16106
+ * (`addon-pipeline/src/detection-pipeline/provider.ts`), calls
16107
+ * `ctx.api.customModelRegistry.listModels.query()` — UDS/Moleculer,
16108
+ * never tRPC — so `auth: 'admin'` costs it nothing.
16109
+ */
16110
+ listModels: method(_void(), array(CustomModelDescriptorSchema).readonly(), { auth: "admin" }),
16111
+ /**
16112
+ * Register a model whose files arrive as ONE bundle tar (D806): the tar
16113
+ * is taken over `ctx.peerBytes`, checked against `bundle.sha256` and
16114
+ * every row of `files`, unpacked into the hub's flat modelsDir (directory
16115
+ * formats included), each format's `.revision` record written, the rows
16116
+ * upserted under the operator's name, the hub marked available and —
16117
+ * when asked — engine formats converted and the model distributed. A
16118
+ * model id already registered by another source is refused, never
16119
+ * overwritten.
16120
+ */
16121
+ importModelBundle: method(ImportModelBundleInputSchema, ImportModelBundleResultSchema, {
16122
+ kind: "mutation",
16123
+ auth: "admin",
16124
+ timeoutMs: 30 * 6e4
16125
+ })
16126
+ }
16127
+ };
16096
16128
  /**
16097
16129
  * What one engine says about itself. The orchestrator uses `kind` to pick
16098
16130
  * a registrant for a collection; `engineId` is what a log line names when
@@ -32522,6 +32554,18 @@ var CalendarInfoSchema = object({
32522
32554
  source: CalendarSourceKindSchema,
32523
32555
  enabled: boolean()
32524
32556
  });
32557
+ /**
32558
+ * Time contract of {@link CalendarEvent}.
32559
+ *
32560
+ * - Timed event: `startMs` / `endMs` are real instants; `endMs` is exclusive.
32561
+ * - All-day event (`allDay: true`): the event is a run of calendar DATES, not
32562
+ * instants. `startMs` is UTC midnight of the first date and `endMs` is UTC
32563
+ * midnight of the day AFTER the last date (RFC 5545 exclusive DTEND), so a
32564
+ * one-day event spans exactly 24 h. Every provider produces that and no
32565
+ * consumer may read the instants in a local timezone: use
32566
+ * {@link calendarEventLocalSpan}. Reading UTC midnight as local time put an
32567
+ * all-day event on the following day for any viewer ahead of UTC.
32568
+ */
32525
32569
  var CalendarEventSchema = object({
32526
32570
  id: string().min(1),
32527
32571
  calendarId: string().min(1),
@@ -34664,7 +34708,15 @@ var HubAddressBookSchema = object({
34664
34708
  publicEndpoint: HubAddressRoleResolutionSchema
34665
34709
  }),
34666
34710
  /** The `hub=` an app link carries (D709), or null. */
34667
- appLinkHub: string().nullable()
34711
+ appLinkHub: string().nullable(),
34712
+ /**
34713
+ * Every origin a browser can really reach this hub at, in the scheme each
34714
+ * listener serves: `https://<lan>:<httpsPort>`, and `http://<lan>:<httpPort>`
34715
+ * while LAN HTTP is on, plus the public and custom addresses. Distinct from
34716
+ * `rows`, which show `http://` only while LAN HTTP is on. Never loopback,
34717
+ * never the app link. Absent on a hub older than D794's amendment.
34718
+ */
34719
+ listenerOrigins: array(string()).readonly().optional()
34668
34720
  });
34669
34721
  var AllowedAddressesSchema = object({
34670
34722
  /**
@@ -41621,6 +41673,12 @@ Object.freeze({
41621
41673
  addonId: null,
41622
41674
  access: "view"
41623
41675
  },
41676
+ "backup.listAvailableDatabases": {
41677
+ capName: "backup",
41678
+ capScope: "system",
41679
+ addonId: null,
41680
+ access: "view"
41681
+ },
41624
41682
  "backup.listDestinations": {
41625
41683
  capName: "backup",
41626
41684
  capScope: "system",