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