@camstack/system 1.2.313 → 1.2.315

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 (111) hide show
  1. package/dist/addon-runner.js +1 -1
  2. package/dist/addon-runner.mjs +1 -1
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/autotrack/index.js +1 -1
  10. package/dist/builtins/autotrack/index.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/camera-grid/index.js +1 -1
  14. package/dist/builtins/camera-grid/index.mjs +1 -1
  15. package/dist/builtins/composer/claim-gate.d.ts +32 -0
  16. package/dist/builtins/composer/composed-device-set.d.ts +38 -0
  17. package/dist/builtins/composer/composed-device.d.ts +4 -3
  18. package/dist/builtins/composer/composer-grafts.d.ts +54 -0
  19. package/dist/builtins/composer/composer-plan.d.ts +96 -0
  20. package/dist/builtins/composer/composer-verdict.d.ts +19 -0
  21. package/dist/builtins/composer/composer.addon.js +2717 -477
  22. package/dist/builtins/composer/composer.addon.mjs +2722 -482
  23. package/dist/builtins/composer/composer.d.ts +71 -52
  24. package/dist/builtins/composer/composition-runtime.d.ts +93 -9
  25. package/dist/builtins/composer/confirmed-seed.d.ts +32 -0
  26. package/dist/builtins/composer/existing-target.d.ts +96 -0
  27. package/dist/builtins/composer/field-record.d.ts +50 -0
  28. package/dist/builtins/composer/graft-host.d.ts +47 -0
  29. package/dist/builtins/composer/held-claims.d.ts +12 -0
  30. package/dist/builtins/composer/owned-fields-target.d.ts +55 -0
  31. package/dist/builtins/composer/slice-assembler.d.ts +10 -0
  32. package/dist/builtins/composer/source-readings.d.ts +122 -0
  33. package/dist/builtins/composer/source-tracker.d.ts +35 -42
  34. package/dist/builtins/console-logging/index.js +1 -1
  35. package/dist/builtins/console-logging/index.mjs +1 -1
  36. package/dist/builtins/core-blocks/composition-api.d.ts +7 -4
  37. package/dist/builtins/core-blocks/composition-peers.d.ts +9 -0
  38. package/dist/builtins/core-blocks/composition-sources.d.ts +16 -3
  39. package/dist/builtins/core-blocks/core-block-store.d.ts +8 -1
  40. package/dist/builtins/core-blocks/core-blocks.addon.d.ts +4 -3
  41. package/dist/builtins/core-blocks/core-blocks.addon.js +255 -79
  42. package/dist/builtins/core-blocks/core-blocks.addon.mjs +255 -79
  43. package/dist/builtins/device-manager/claimed-status-overlay.d.ts +12 -0
  44. package/dist/builtins/device-manager/device-manager.addon.d.ts +16 -0
  45. package/dist/builtins/device-manager/device-manager.addon.js +1825 -317
  46. package/dist/builtins/device-manager/device-manager.addon.mjs +1825 -317
  47. package/dist/builtins/device-manager/device-provider-context.d.ts +24 -2
  48. package/dist/builtins/device-manager/device-row-store.d.ts +2 -0
  49. package/dist/builtins/device-manager/device-state-claim-views.d.ts +77 -0
  50. package/dist/builtins/device-manager/device-state-claims.d.ts +43 -0
  51. package/dist/builtins/device-manager/device-state-mirror.d.ts +145 -61
  52. package/dist/builtins/device-manager/field-claims-index.d.ts +69 -0
  53. package/dist/builtins/device-manager/field-ownership.d.ts +53 -0
  54. package/dist/builtins/device-manager/migrate-device.d.ts +27 -0
  55. package/dist/builtins/device-manager/migrate-guard.d.ts +7 -0
  56. package/dist/builtins/device-manager/migrate-hardware-state.d.ts +55 -0
  57. package/dist/builtins/device-manager/migration-refused.d.ts +23 -0
  58. package/dist/builtins/device-manager/mirror-row-writer.d.ts +138 -0
  59. package/dist/builtins/device-manager/runtime-state-persist-gate.d.ts +4 -0
  60. package/dist/builtins/doorbell/binding-mirror.d.ts +1 -0
  61. package/dist/builtins/doorbell/doorbell-composition-migration.d.ts +59 -0
  62. package/dist/builtins/doorbell/virtual-doorbell.addon.d.ts +3 -0
  63. package/dist/builtins/doorbell/virtual-doorbell.addon.js +315 -10
  64. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +315 -10
  65. package/dist/builtins/hub-forwarder/index.js +1 -1
  66. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  67. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  68. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  69. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  70. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  71. package/dist/builtins/local-network/local-network.addon.js +1 -1
  72. package/dist/builtins/local-network/local-network.addon.mjs +1 -1
  73. package/dist/builtins/loki-logging/index.js +1 -1
  74. package/dist/builtins/loki-logging/index.mjs +1 -1
  75. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  76. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  77. package/dist/builtins/platform-probe/index.js +1 -1
  78. package/dist/builtins/platform-probe/index.mjs +1 -1
  79. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  80. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  81. package/dist/builtins/snapshot/index.js +1 -1
  82. package/dist/builtins/snapshot/index.mjs +1 -1
  83. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  84. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  85. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  86. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  87. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  88. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  89. package/dist/builtins/system-config/system-config.addon.js +1 -1
  90. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  91. package/dist/builtins/winston-logging/index.js +1 -1
  92. package/dist/builtins/winston-logging/index.mjs +1 -1
  93. package/dist/{child-cap-dispatch-CHTiHbsv.mjs → child-cap-dispatch-AAltfBUH.mjs} +1484 -1417
  94. package/dist/{child-cap-dispatch-Dyn_7ezH.js → child-cap-dispatch-DzcUd_ct.js} +1503 -1424
  95. package/dist/composition-sources-DYqQLRzF.js +122 -0
  96. package/dist/composition-sources-yonsRneP.mjs +111 -0
  97. package/dist/{dist-CkRwnhbo.js → dist-DVKv5i-e.js} +5396 -4331
  98. package/dist/{dist-Cj7pJmXq.mjs → dist-MzYJVCLE.mjs} +5330 -4331
  99. package/dist/index.js +137 -8
  100. package/dist/index.mjs +133 -8
  101. package/dist/kernel/capability-registry.d.ts +43 -1
  102. package/dist/kernel/index.d.ts +3 -1
  103. package/dist/kernel/moleculer/device-cap-proxy.d.ts +0 -10
  104. package/dist/kernel/status-overlay.d.ts +18 -0
  105. package/dist/kernel/transport/claimed-get-status.d.ts +15 -0
  106. package/dist/kernel/transport/index.d.ts +2 -0
  107. package/dist/kernel/transport/local-child-registry.d.ts +23 -0
  108. package/dist/kernel/transport/parent-unowned-call.d.ts +25 -0
  109. package/dist/{retired-settings-keys-C1duPikd.mjs → retired-settings-keys-8yY2sTc-.mjs} +1 -1
  110. package/dist/{retired-settings-keys-CGSuXhP8.js → retired-settings-keys-iY5I0nKM.js} +1 -1
  111. package/package.json +1 -1
@@ -2,8 +2,12 @@ import { AddonContext, ICapabilityRegistry, IDeviceAdoptionProvider, IDeviceMana
2
2
  import { AdoptionJobEngine } from './adoption-job-engine.js';
3
3
  import { BindingsDeps } from './device-bindings-store.js';
4
4
  import { DeviceManagerSettings, DeviceMetaStore } from './device-meta-store.js';
5
+ import { FieldClaimsIndex } from './field-claims-index.js';
6
+ import { OwnedCaps } from './field-ownership.js';
5
7
  import { MigrationGuard } from './migrate-guard.js';
6
8
  import { RemovalFence } from './removal-fence.js';
9
+ import { SeedMode } from './device-state-mirror.js';
10
+ import { MirrorWriteHold } from './mirror-row-writer.js';
7
11
  type IDeviceProviderCap = InferProvider<typeof deviceProviderCapability>;
8
12
  /**
9
13
  * The addon-instance surface the extracted provider functions need. Exposed by
@@ -18,10 +22,28 @@ export interface ProviderHost {
18
22
  addonId: string;
19
23
  nodeId: string;
20
24
  }>>;
21
- /** Seed the runtime-state mirror for a device (boot path, no events). */
22
- seedMirror(deviceId: number, blob: Record<string, unknown>): void;
25
+ /**
26
+ * Seed the runtime-state mirror for a device (no events). `fill` (the boot
27
+ * path) only fills caps memory lacks; `replace` (the migration reseed) makes
28
+ * the mirror answer exactly this blob — the row now belongs to different
29
+ * hardware, so whatever memory held is the wrong device's state (D663).
30
+ */
31
+ seedMirror(deviceId: number, blob: Record<string, unknown>, mode?: SeedMode): void;
32
+ /**
33
+ * Drain the mirror's debounced writer on these devices and hold it off their
34
+ * rows until `release` — a migration swapping the rows must not race a write
35
+ * that read the pre-swap hardware's blob (D663).
36
+ */
37
+ holdMirrorWrites(deviceIds: readonly number[], settings: DeviceManagerSettings): Promise<MirrorWriteHold>;
38
+ /**
39
+ * The claims the mirror holds for a device — accepted ones included, whose
40
+ * write-through may not have landed yet. A migration's second look (D663).
41
+ */
42
+ mirrorOwnedCaps(deviceId: number): OwnedCaps;
23
43
  /** Reset a stale per-session `feature-probe` timestamp before mirror seeding. */
24
44
  withResetSessionProbe(blob: Record<string, unknown>): Record<string, unknown>;
45
+ /** The per-field claims index (D663) — `removeDevice` prunes a removed device from it. */
46
+ readonly fieldClaims: FieldClaimsIndex;
25
47
  /** Resolve a device's real `online` flag from the mirrored device-status slice. */
26
48
  resolveDeviceOnline(deviceId: number, fallbackOnline: boolean): boolean;
27
49
  /** Resolve a device's `probed` flag from the mirrored feature-probe slice. */
@@ -378,6 +378,8 @@ export declare class DeviceRowStore {
378
378
  * functions on both devices in the same critical section.
379
379
  */
380
380
  swapIds(a: number, b: number, tempId: number): Promise<void>;
381
+ /** The read-only half of {@link swapIds}: every refusal, every read, no write. */
382
+ private planSwap;
381
383
  /** Rewrite a row under a new id and drop the old key. Not exported: the only
382
384
  * legitimate reason to move a row is {@link swapIds}. */
383
385
  private moveRow;
@@ -0,0 +1,77 @@
1
+ import { z } from 'zod';
2
+ import { ClaimOutcome, IScopedLogger } from '@camstack/types';
3
+ import { MergedSlice, OwnedCap, OwnedCaps } from './field-ownership.js';
4
+ /** Resolves a cap's `runtimeState` schema by name; null when it declares none. */
5
+ export type RuntimeStateSchemaLookup = (capName: string) => z.ZodType | null;
6
+ /** Per (device, cap) state of a claimed cap: what the merge last said, and the
7
+ * last GOOD merged slice — kept readable while a bad overlay holds the cap. */
8
+ export interface ClaimedView {
9
+ readonly merged: MergedSlice;
10
+ readonly visible: Readonly<Record<string, unknown>> | null;
11
+ }
12
+ /** What the views need from their owner: the schema, the log and the public emit. */
13
+ export interface ClaimViewSink {
14
+ readonly logger: IScopedLogger;
15
+ readonly schemaFor: RuntimeStateSchemaLookup;
16
+ emitSlice(deviceId: number, capName: string, slice: Record<string, unknown>): void;
17
+ }
18
+ /** Lazily indexes the codegen'd cap set — the hub already carries the barrel. */
19
+ export declare function defaultSchemaFor(capName: string): z.ZodType | null;
20
+ export declare function sameOwnedCap(a: OwnedCap, b: OwnedCap): boolean;
21
+ type ClaimRefusal = Extract<ClaimOutcome, {
22
+ ok: false;
23
+ }>;
24
+ export declare function refused(code: ClaimRefusal['code'], message: string): ClaimOutcome;
25
+ export declare class ClaimViews {
26
+ private readonly sink;
27
+ /** Claims by device, by cap. Absent for every device without one. */
28
+ private readonly owned;
29
+ /** The merged view of each claimed cap; recomputed on every native or owned write. */
30
+ private readonly views;
31
+ constructor(sink: ClaimViewSink);
32
+ claim(deviceId: number, capName: string): OwnedCap | undefined;
33
+ setClaim(deviceId: number, capName: string, claim: OwnedCap): void;
34
+ /** Removes the claim AND its view. */
35
+ deleteClaim(deviceId: number, capName: string): void;
36
+ /** Fill gaps only: a claim already in memory was written through and is newer than the row. */
37
+ seedClaims(deviceId: number, claims: OwnedCaps): void;
38
+ clearDevice(deviceId: number): void;
39
+ ownedCaps(deviceId: number): OwnedCaps;
40
+ claimsOf(deviceId: number): Iterable<[string, OwnedCap]>;
41
+ /** Devices with at least one view — for the whole-system dump. */
42
+ deviceIds(): Iterable<number>;
43
+ isClaimed(deviceId: number, capName: string): boolean;
44
+ /** `undefined` when the cap is not claimed; `null` when it is claimed and held with nothing to show. */
45
+ visible(deviceId: number, capName: string): Readonly<Record<string, unknown>> | null | undefined;
46
+ /** Every claimed cap of a device that currently has something to show. */
47
+ visibleCaps(deviceId: number): Iterable<[string, Readonly<Record<string, unknown>>]>;
48
+ /** What the merge says for a claimed cap; null when the cap is not claimed. */
49
+ effective(deviceId: number, capName: string): MergedSlice | null;
50
+ view(deviceId: number, capName: string): ClaimedView | undefined;
51
+ /** Put a view back (rollback of a failed write-through); `undefined` removes it. */
52
+ restoreView(deviceId: number, capName: string, view: ClaimedView | undefined): void;
53
+ /**
54
+ * Recompute a claimed cap's merged view and emit it when it is a slice and
55
+ * either changed or `emitUnchanged` (the native moved). A held view emits
56
+ * nothing and logs once per transition, naming the fields; a bad overlay
57
+ * keeps the last good slice readable, a missing value or native does not.
58
+ */
59
+ recompute(deviceId: number, capName: string, claim: OwnedCap, native: Readonly<Record<string, unknown>> | null, emitUnchanged: boolean): void;
60
+ /**
61
+ * The view a claim has NOW — set, never emitted, never logged. A claim is
62
+ * written through, and readers must see it from the moment it is accepted
63
+ * (S5): without this, `capSlice` answered the NATIVE for an already-claimed
64
+ * field for the whole row round-trip (D663 re-review N-5). The emit is judged
65
+ * later, by `recompute`, against what consumers last saw.
66
+ */
67
+ previewView(deviceId: number, capName: string, claim: OwnedCap, native: Readonly<Record<string, unknown>> | null): void;
68
+ /** The one rule for what a claimed cap shows: the merge when it is a slice; on
69
+ * a bad overlay the last good slice stays readable; a value or native not yet
70
+ * arrived shows nothing. */
71
+ private derive;
72
+ /** The merged view for a claim that arrived by seed: computed, never emitted. */
73
+ seedView(deviceId: number, capName: string, claim: OwnedCap, native: Readonly<Record<string, unknown>> | null): void;
74
+ private ownedFor;
75
+ private viewsFor;
76
+ }
77
+ export {};
@@ -0,0 +1,43 @@
1
+ import { ClaimOutcome, ClaimsListing, FieldClaimMode, IScopedLogger } from '@camstack/types';
2
+ import { DeviceManagerSettings } from './device-meta-store.js';
3
+ import { DeviceStateMirror } from './device-state-mirror.js';
4
+ import { FieldClaimsIndex } from './field-claims-index.js';
5
+ export interface ClaimFieldsInput {
6
+ readonly deviceId: number;
7
+ readonly capName: string;
8
+ readonly owner: string;
9
+ readonly mode: FieldClaimMode;
10
+ readonly fields: readonly string[];
11
+ readonly values: Readonly<Record<string, unknown>>;
12
+ }
13
+ export interface PatchOwnedFieldsInput {
14
+ readonly deviceId: number;
15
+ readonly capName: string;
16
+ readonly owner: string;
17
+ readonly values: Readonly<Record<string, unknown>>;
18
+ }
19
+ export interface ReleaseClaimInput {
20
+ readonly deviceId: number;
21
+ readonly capName: string;
22
+ readonly owner: string;
23
+ }
24
+ export interface ListClaimsInput {
25
+ readonly ownerPrefix?: string;
26
+ }
27
+ export interface DeviceStateClaimMethods {
28
+ claimFields(input: ClaimFieldsInput): Promise<ClaimOutcome>;
29
+ patchOwnedFields(input: PatchOwnedFieldsInput): Promise<ClaimOutcome>;
30
+ releaseClaim(input: ReleaseClaimInput): Promise<ClaimOutcome>;
31
+ listClaims(input: ListClaimsInput): Promise<ClaimsListing>;
32
+ }
33
+ export interface DeviceStateClaimDeps {
34
+ readonly mirror: Pick<DeviceStateMirror, 'claim' | 'patchOwned' | 'release'>;
35
+ readonly index: FieldClaimsIndex;
36
+ readonly settings: DeviceManagerSettings;
37
+ readonly logger: IScopedLogger;
38
+ /** Is this a device the ledger knows? `resolvePersistedById(id) !== null`. */
39
+ deviceExists(deviceId: number): Promise<boolean>;
40
+ /** Is a migration involving this device running right now? */
41
+ migrationInFlight(deviceId: number): boolean;
42
+ }
43
+ export declare function createDeviceStateClaimMethods(deps: DeviceStateClaimDeps): DeviceStateClaimMethods;
@@ -1,6 +1,28 @@
1
- import { AddonContext } from '@camstack/types';
1
+ import { AddonContext, ClaimOutcome } from '@camstack/types';
2
2
  import { DeviceManagerSettings } from './device-meta-store.js';
3
+ import { RuntimeStateSchemaLookup } from './device-state-claim-views.js';
4
+ import { MergedSlice, OwnedCap, OwnedCaps } from './field-ownership.js';
5
+ import { MirrorWriteHold } from './mirror-row-writer.js';
3
6
  import { RuntimeStatePolicyLookup } from './runtime-state-persist-gate.js';
7
+ /**
8
+ * What the seed consults first: the claims index (Task 6). It is written
9
+ * BEFORE the row, so a loaded index is a superset of the rows' claims and a
10
+ * device it does not name has nothing to merge — no row read (D447).
11
+ */
12
+ export type ClaimsIndexView = {
13
+ readonly state: 'loaded';
14
+ hasClaims(deviceId: number): boolean;
15
+ } | {
16
+ readonly state: 'not-loaded';
17
+ };
18
+ /**
19
+ * How `seedMirror` treats what memory already holds. `fill` (boot, first
20
+ * write) only adds caps memory lacks — memory is never behind the row.
21
+ * `replace` (the migration reseed) drops the device's slices, claims and views
22
+ * first: the row now describes different hardware, so everything memory held
23
+ * under that number is the wrong device's state.
24
+ */
25
+ export type SeedMode = 'fill' | 'replace';
4
26
  export declare class DeviceStateMirror {
5
27
  private readonly ctx;
6
28
  /**
@@ -10,46 +32,41 @@ export declare class DeviceStateMirror {
10
32
  */
11
33
  private readonly policyFor;
12
34
  /**
13
- * Hub-side mirror of every device's cap-keyed runtime state.
14
- * Key: deviceId. Value: per-cap slice map. Empty by default —
35
+ * Hub-side mirror of every device's cap-keyed NATIVE runtime state — what the
36
+ * provider wrote. Key: deviceId. Value: per-cap slice map. Empty by default —
15
37
  * slices show up as `setCapSlice` calls trickle in.
16
38
  */
17
39
  private readonly stateMirror;
18
- /**
19
- * Per-device disk-write debouncer for runtime-state. `setCapSlice`
20
- * updates the in-memory mirror synchronously and emits the change
21
- * event immediately, but the disk write is coalesced.
22
- */
23
- private readonly runtimeStateDebounce;
24
- private static readonly RUNTIME_STATE_DEBOUNCE_MS;
25
- /**
26
- * What is believed to be ON DISK for each device — the persistable projection
27
- * of the last blob actually written (or seeded at boot). The effective-change
28
- * gate compares against THIS, not against the previous mirror state: two
29
- * clock-only ticks in a row must not add up to a write just because each was
30
- * compared with its immediate predecessor.
31
- */
32
- private readonly lastPersisted;
33
- /**
34
- * Per-device count of writes the effective-change gate skipped since the last
35
- * real one. Reported on the next write that DOES happen, so the log says how
36
- * much churn the gate absorbed instead of saying nothing at all — a branch
37
- * that drops work silently reads as "never happened".
38
- */
39
- private readonly skippedSinceWrite;
40
+ /** The claims and the merged view of every claimed cap (D663). */
41
+ private readonly claims;
42
+ /** Devices whose row has been consulted (or ruled empty by the index). */
43
+ private readonly seeded;
44
+ /** Devices whose row was actually READ into memory — a write-through needs this. */
45
+ private readonly rowRead;
46
+ /** One row read per device, however many writes arrive during it. */
47
+ private readonly seeding;
48
+ /** Devices whose seed refusal is already logged — once per transition. */
49
+ private readonly seedRefusalLogged;
50
+ /** Every write of a device's row, and what is believed to be on it. */
51
+ private readonly writer;
40
52
  constructor(ctx: AddonContext,
41
53
  /**
42
54
  * Per-cap durability + volatile-field policy. Defaults to the codegen'd
43
55
  * projection of every `*.cap.ts` (D4: the cap declares, the kernel reads).
44
56
  * Injectable so a spec can exercise the gate without the real cap set.
45
57
  */
46
- policyFor?: RuntimeStatePolicyLookup);
58
+ policyFor?: RuntimeStatePolicyLookup,
59
+ /** The cap's `runtimeState` schema a merged slice is parsed with (D663). */
60
+ schemaFor?: RuntimeStateSchemaLookup);
47
61
  /**
48
62
  * Single-cap mirror update — diff against the current mirror,
49
63
  * persist the new slice in-memory, emit `DeviceStateChanged` for
50
64
  * this cap. No-op on identical writes (both same shape and same
51
65
  * values). Called by `setCapSlice` provider.
52
66
  *
67
+ * This is the NATIVE path, unchanged for every writer. Under a claim the
68
+ * native slice is stored as the shadow and what is emitted is the merge.
69
+ *
53
70
  * @returns whether the mirror actually changed. The caller uses this to
54
71
  * decide whether the (synchronous, disk-blocking) runtime-state write is
55
72
  * worth scheduling — see `scheduleRuntimeStateDiskWrite`. Returning void
@@ -58,41 +75,95 @@ export declare class DeviceStateMirror {
58
75
  */
59
76
  applySingleCapUpdate(deviceId: number, capName: string, slice: Record<string, unknown>): boolean;
60
77
  /**
61
- * Debounced disk writer, behind two gates.
62
- *
63
- * GATE — DURABILITY: a change to a `durability: 'session'` cap never even
64
- * arms the timer. That is where the fleet's write rate goes: `audio-metrics`
65
- * and `zone-analytics` are ~90 % of the offered 12–19 writes/s, they change
66
- * genuinely every second, and their restored value is worthless.
67
- *
68
- * GATE — EFFECTIVE CHANGE (at flush): the persistable blob is compared with
69
- * what is believed to be on disk, ignoring the clock fields each cap declared
70
- * volatile. `battery` wrote its blob 526 times in 25 minutes without one
71
- * percentage moving; this is the gate that turns those into one write.
72
- *
73
- * The blob is read from the live mirror at flush time, so the disk picture is
74
- * always the latest state — no risk of writing a stale snapshot.
78
+ * Claim `cap.fields` of `capName` on `deviceId` for `cap.owner`. Written
79
+ * through: resolves after the row write. A re-claim by the same owner
80
+ * replaces `fields`; values of fields no longer claimed are dropped and the
81
+ * native shows for them (ruling S1b). An empty `fields` is what the composer
82
+ * never sends — it releases — and is refused.
75
83
  */
76
- scheduleRuntimeStateDiskWrite(deviceId: number, settings: DeviceManagerSettings, changedCap: string): void;
84
+ claim(deviceId: number, capName: string, cap: OwnedCap, settings: DeviceManagerSettings): Promise<ClaimOutcome>;
85
+ /** Is this exact claim what the row carries, per `lastPersisted`? */
86
+ private isClaimPersisted;
77
87
  /**
78
- * True when `blob` differs from what is believed to be on disk only in fields
79
- * the owning caps declared volatile. Counts the skip so the next real write
80
- * can report how much churn was absorbed.
88
+ * Write owned values. Partial patches MERGE into `values` (ruling S5); a key
89
+ * outside `fields` is refused. Debounced like a native slice.
81
90
  */
82
- private isAlreadyPersisted;
83
- /** The device's mirror, restricted to the slices that may reach disk. */
91
+ patchOwned(deviceId: number, capName: string, owner: string, values: Readonly<Record<string, unknown>>, settings: DeviceManagerSettings): Promise<ClaimOutcome>;
92
+ /**
93
+ * Remove the claim. A `replace` hands the cap back: the native slice is
94
+ * emitted exactly as last written. An `add` had no native — the cap leaves
95
+ * the mirror and nothing is emitted (consumer mirrors keep the last value
96
+ * until they re-read; recorded for Task 15). Written through.
97
+ */
98
+ release(deviceId: number, capName: string, owner: string, settings: DeviceManagerSettings): Promise<ClaimOutcome>;
99
+ /** The claims on a device; empty for every device without one. */
100
+ ownedCaps(deviceId: number): OwnedCaps;
101
+ /** The NATIVE shadow of one cap — for the owner-side mechanism only (D224). */
102
+ nativeSlice(deviceId: number, cap: string): Record<string, unknown> | null;
103
+ /** What the merge says for a claimed cap; null when the cap is not claimed. */
104
+ effectiveIfClaimed(deviceId: number, cap: string): MergedSlice | null;
105
+ isSeeded(deviceId: number): boolean;
106
+ /**
107
+ * The SYNCHRONOUS half of {@link ensureSeeded}, branch (c): a LOADED claims
108
+ * index that does not name the device vouches for it — seeded, no read.
109
+ * Refused (false) while a read is in flight, because that read may be
110
+ * landing a claim the index does not know yet; refused when the index cannot
111
+ * vouch. The overlay's `peek` calls this instead of `ensureSeeded`: a sync
112
+ * path must never hold a promise that can reject (D391 — an unhandled
113
+ * rejection is an untagged line on hub-main).
114
+ */
115
+ seedIfIndexClears(deviceId: number, index: ClaimsIndexView): boolean;
116
+ /**
117
+ * Make sure the device's claims are known before a native write is applied
118
+ * (D49). Index first, single-flight (ruling S6):
119
+ * (a) already seeded → nothing;
120
+ * (b) a seed in flight → await it;
121
+ * (c) a LOADED index that does not name the device → seeded, no read;
122
+ * (d) otherwise read the row once and seed it.
123
+ * A read that throws propagates — the write is refused — and is logged once
124
+ * per device transition (D391).
125
+ */
126
+ ensureSeeded(deviceId: number, settings: DeviceManagerSettings, index: ClaimsIndexView): Promise<void>;
127
+ /** A write-through replaces the whole row: memory must hold what the row held. */
128
+ private ensureRowRead;
129
+ /** An unreadable row is a NAMED refusal (D49): the answer is unknown — a
130
+ * throw could be read as "gone", `not-claimed` as "never was". Logged by `readRowOnce`. */
131
+ private readRowOrRefuse;
132
+ private readRowOnce;
133
+ private nativeOf;
134
+ /**
135
+ * Write the row now — a claim is atomic with the row — and refresh the
136
+ * baseline. Queued behind any write in flight for the device (one row write
137
+ * at a time, N-4), and the blob is taken when the write RUNS, so it carries
138
+ * whatever memory holds by then. A failed write is rolled back in memory IF
139
+ * what this op put there is still there (the two never disagree); when a
140
+ * newer same-owner state landed in the window it stands, and the debounced
141
+ * writer converges the row to it. Either way the failure is logged hub-side
142
+ * (D391) and rethrown; a repeat of the same claim is then a real write, never
143
+ * `changed: false`, and consumers are handed the truth that stands.
144
+ */
145
+ private writeThrough;
146
+ /** After a rollback, re-emit what is true now: the restored claim's merge, or the native. */
147
+ private reemitAfterRollback;
148
+ /** Arm the debounced writer for a change to `changedCap` — see `MirrorRowWriter.schedule`. */
149
+ scheduleRuntimeStateDiskWrite(deviceId: number, settings: DeviceManagerSettings, changedCap: string): void;
150
+ /** Hold the writer off these rows for a migration — see `MirrorRowWriter.holdDiskWrites`. */
151
+ holdDiskWrites(deviceIds: readonly number[], settings: DeviceManagerSettings): Promise<MirrorWriteHold>;
152
+ /** Flush every pending debounced disk write (graceful shutdown). */
153
+ flushPendingWrites(settings: DeviceManagerSettings | undefined): Promise<void>;
154
+ /** The device's row as it may reach disk: NATIVE slices plus `$owned`, restricted by durability. */
84
155
  private persistableForDevice;
85
156
  /**
86
- * One-shot mirror seed used by `loadRuntimeState` at boot so the
87
- * hub knows about every persisted slice without waiting for the
88
- * first `setCapSlice` call. No events emitted — this is
89
- * initial-state population, not a transition.
90
- *
91
- * Callers that must not carry a stale per-session probe across a
92
- * restart pass the blob through `withResetSessionProbe` first (see
93
- * `loadRuntimeState`).
157
+ * One-shot mirror seed used by `loadRuntimeState` at boot so the hub knows
158
+ * every persisted slice before the first `setCapSlice`. No events emitted —
159
+ * population, not a transition. `fill` (default) fills GAPS and never
160
+ * overwrites memory (ruling S6: a slice in memory is at least as new as the
161
+ * row, a claim in memory was written through); `replace` (the migration
162
+ * reseed) drops the device's memory first — the row now belongs to different
163
+ * hardware. Malformed `$owned` entries are dropped by name and logged once.
164
+ * Callers pass the blob through `withResetSessionProbe` first.
94
165
  */
95
- seedMirror(deviceId: number, blob: Record<string, unknown>): void;
166
+ seedMirror(deviceId: number, blob: Record<string, unknown>, mode?: SeedMode): void;
96
167
  /**
97
168
  * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
98
169
  * signal — it means "this worker process completed a probe THIS session".
@@ -140,16 +211,29 @@ export declare class DeviceStateMirror {
140
211
  * `true` — a brief transient window, same as `resolveDeviceOnline`.
141
212
  */
142
213
  resolveDeviceProbed(deviceId: number): boolean;
214
+ /** The EFFECTIVE slices of a device: native for an unclaimed cap, the merge
215
+ * for a claimed one (a held cap is not reported). */
143
216
  snapshotForDevice(deviceId: number): Record<string, Record<string, unknown>>;
144
- /** One cap's mirrored slice, cloned. Null when the device has never written
145
- * it — callers read that as "the cap has no state", never as an empty slice. */
217
+ /** One cap's EFFECTIVE slice, cloned. Null when the device has never written
218
+ * it — callers read that as "the cap has no state", never as an empty slice —
219
+ * and null while a claim holds it. */
146
220
  capSlice(deviceId: number, cap: string): Record<string, unknown> | null;
221
+ /** The device's NATIVE slices — what the row stores under the cap names. */
222
+ private nativeSnapshotForDevice;
147
223
  /** Whole-system mirror dump. Backs `deviceState.getAllSnapshots` — one
148
224
  * round-trip for a warm boot instead of N per-device calls. */
149
225
  allSnapshots(): Record<string, Record<string, Record<string, unknown>>>;
226
+ /**
227
+ * The PUBLIC event: exactly `{ deviceId, capName, slice }` for every device,
228
+ * claimed or not. The native shadow never rides here (D224) — see
229
+ * `emitNativeShadow`.
230
+ */
150
231
  private emitStateChanged;
151
- /** Flush every pending debounced disk write (graceful shutdown). Clears the
152
- * debounce slots after awaiting in-flight + scheduled writes so shutdown is
153
- * lossless. */
154
- flushPendingWrites(settings: DeviceManagerSettings | undefined): Promise<void>;
232
+ /**
233
+ * The owner-only event (D224, D663): the native slice as the provider wrote
234
+ * it, for the composer's native self-reads. Its category is refused by the
235
+ * public event routers (`isOwnerOnlyEventCategory`), so no UI or share-scope
236
+ * subscriber ever sees a second truth beside the merged slice.
237
+ */
238
+ private emitNativeShadow;
155
239
  }
@@ -0,0 +1,69 @@
1
+ import { ClaimsListing, FieldClaim, IScopedLogger } from '@camstack/types';
2
+ import { DeviceManagerSettings } from './device-meta-store.js';
3
+ import { ClaimsIndexView } from './device-state-mirror.js';
4
+ import { WriteLock } from './write-lock.js';
5
+ /** The addon-store key the index lives under. */
6
+ export declare const FIELD_CLAIMS_INDEX_KEY = "field-claims-index";
7
+ /** One claim's identity in the index: a cap on a device has at most one owner. */
8
+ export interface ClaimKey {
9
+ readonly deviceId: number;
10
+ readonly capName: string;
11
+ }
12
+ /** A read-modify-write step over the loaded entries; null means nothing to write. */
13
+ export type IndexTransform = (current: ReadonlyMap<string, FieldClaim>) => ReadonlyMap<string, FieldClaim> | null;
14
+ export interface FieldClaimsIndexDeps {
15
+ readonly logger: IScopedLogger;
16
+ /**
17
+ * The addon's own settings row-set lock, shared with every other writer of
18
+ * it: `writeAddonStore` is a read-modify-write of the WHOLE key range on the
19
+ * store side, so unordered writers destroy each other's keys.
20
+ */
21
+ readonly withAddonStoreWriteLock: WriteLock;
22
+ }
23
+ export declare class FieldClaimsIndex {
24
+ private readonly deps;
25
+ /** The loaded entries by key, or null until a load has succeeded. */
26
+ private entries;
27
+ /** Devices with at least one entry — what the seed asks, per native write. */
28
+ private claimedDevices;
29
+ /** Why the last load did not succeed. */
30
+ private notLoadedReason;
31
+ /** One load in flight at a time; concurrent callers join it. */
32
+ private loading;
33
+ constructor(deps: FieldClaimsIndexDeps);
34
+ /**
35
+ * Load the blob. Resolves `true` once the index is loaded (now or already);
36
+ * `false` when the store could not answer — the reason is kept for `list`.
37
+ * Never throws: an index that cannot load is a state, not a failure.
38
+ */
39
+ load(settings: DeviceManagerSettings): Promise<boolean>;
40
+ /** What the seed consults: loaded with a device predicate, or not loaded. */
41
+ view(): ClaimsIndexView;
42
+ /** Every entry, optionally those whose owner starts with `ownerPrefix`; or why there is no answer. */
43
+ list(ownerPrefix?: string): ClaimsListing;
44
+ /** The entry for a key, when the index is loaded and has one. */
45
+ entry(key: ClaimKey): FieldClaim | undefined;
46
+ /** Add or replace one entry — written before the row it announces. Throws when the index cannot be loaded or written. */
47
+ upsert(claim: FieldClaim, settings: DeviceManagerSettings): Promise<void>;
48
+ /** Remove one entry — written after the row no longer carries the claim. No write when absent. */
49
+ remove(key: ClaimKey, settings: DeviceManagerSettings): Promise<void>;
50
+ /**
51
+ * Drop every entry of a removed device (condition i). Best effort: an index
52
+ * that cannot be loaded or written keeps a stale entry, which costs the next
53
+ * seed of that number one row read and is removed by the next release that
54
+ * answers `not-claimed` — so it is logged, never thrown, and the removal
55
+ * proceeds.
56
+ */
57
+ pruneDevice(deviceId: number, settings: DeviceManagerSettings): Promise<void>;
58
+ /**
59
+ * Read-modify-write of the whole blob under the addon-store lock, from the
60
+ * LOADED entries: the index is never written from a partial picture. `next`
61
+ * returning null means nothing to write.
62
+ */
63
+ private mutate;
64
+ private install;
65
+ /** The three-way read (D651): a failed read is a REASON, never an empty index. */
66
+ private readBlob;
67
+ /** Not loaded, by name: the index stays unknown and says why. */
68
+ private refuseMalformed;
69
+ }
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ import { FieldClaimMode } from '@camstack/types';
3
+ import { RuntimeStatePolicyLookup } from './runtime-state-persist-gate.js';
4
+ /** The row key under which claims travel. Not a cap name: `$` is not legal in one. */
5
+ export declare const OWNED_FIELDS_KEY = "$owned";
6
+ /**
7
+ * One claimed cap. `fields` is the claimed set, `values` what has been written
8
+ * so far — a claimed field with no value is HELD (ruling S5), never answered
9
+ * from the native slice.
10
+ */
11
+ export interface OwnedCap {
12
+ readonly owner: string;
13
+ readonly mode: FieldClaimMode;
14
+ readonly fields: readonly string[];
15
+ readonly values: Readonly<Record<string, unknown>>;
16
+ }
17
+ export type OwnedCaps = ReadonlyMap<string, OwnedCap>;
18
+ export interface SplitBlob {
19
+ readonly native: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
20
+ readonly owned: OwnedCaps;
21
+ /** `$owned` entries that failed `OwnedCapSchema`, by cap name — logged once per device, never applied. */
22
+ readonly dropped: readonly string[];
23
+ }
24
+ export type MergedSlice = {
25
+ readonly kind: 'slice';
26
+ readonly slice: Readonly<Record<string, unknown>>;
27
+ } | {
28
+ readonly kind: 'held';
29
+ readonly reason: string;
30
+ };
31
+ export declare function isPlainRecord(value: unknown): value is Readonly<Record<string, unknown>>;
32
+ /**
33
+ * Split a runtime-state row into its native slices and its claims. A row with
34
+ * no `$owned` splits to its slices and an empty map — the shape every device
35
+ * without a claim has always had. Non-object entries are dropped, as the
36
+ * mirror seed and `DeviceRuntimeState.fromInitial` have always dropped them.
37
+ */
38
+ export declare function splitRuntimeBlob(blob: Readonly<Record<string, unknown>>): SplitBlob;
39
+ /**
40
+ * Join native slices and claims into one row. `values` are kept only for caps
41
+ * whose policy is `durability: 'restored'`; a session-only cap keeps its CLAIM
42
+ * (so a restart still knows who owns what) but not its values, and the claimed
43
+ * fields are held after boot until the owner writes again. A device with no
44
+ * claims gets no `$owned` key at all — its row is byte-identical to before.
45
+ */
46
+ export declare function joinRuntimeBlob(native: SplitBlob['native'], owned: OwnedCaps, policyFor: RuntimeStatePolicyLookup): Record<string, Record<string, unknown>>;
47
+ /**
48
+ * The one merge. Held when a claimed field has no value yet (S5: never
49
+ * answered from the native), when a `replace` has no native slice to overlay
50
+ * (a partial slice is never emitted), or when the merged slice fails the cap's
51
+ * `runtimeState` schema (the native value never leaks through a bad overlay).
52
+ */
53
+ export declare function mergeOwned(native: Readonly<Record<string, unknown>> | null, owned: OwnedCap, schema: z.ZodType | null): MergedSlice;
@@ -28,6 +28,13 @@ export interface MigrateDeviceResult {
28
28
  readonly sourceStillLive: readonly CameraSwitchId[];
29
29
  readonly swapped: boolean;
30
30
  }
31
+ /**
32
+ * A camera's switch states as read before the disable phase: every switch the
33
+ * camera OFFERS, with its state. A switch it does not offer is absent.
34
+ */
35
+ export type SwitchStates = ReadonlyMap<CameraSwitchId, boolean>;
36
+ /** Severity of a per-device migration line. */
37
+ export type MigrationLogLevel = 'warn' | 'error';
31
38
  export interface MigrateDeviceDeps {
32
39
  /** `pipelineOrchestrator.setCameraSwitch`, through `ctx.api`. */
33
40
  readonly setSwitch: (deviceId: number, switchId: CameraSwitchId, enabled: boolean) => Promise<void>;
@@ -46,6 +53,15 @@ export interface MigrateDeviceDeps {
46
53
  * reported as a success.
47
54
  */
48
55
  readonly swapHardwareState: (sourceId: number, targetId: number) => Promise<void>;
56
+ /**
57
+ * Refuse a migration over a device under customization
58
+ * (`migrate-hardware-state.ts::assertNoFieldClaims`, D663): a field claim
59
+ * names the NUMBER while its owner names the STABLE ID the swap moves, so no
60
+ * order of operations keeps both right. THROWS to refuse. Asked FIRST —
61
+ * before any switch is flipped, so a refused migration leaves both cameras
62
+ * exactly as they were.
63
+ */
64
+ readonly assertNoFieldClaims: (sourceId: number, targetId: number) => Promise<void>;
49
65
  /**
50
66
  * Tell the stream broker the hardware behind this number is gone
51
67
  * (`streamBroker.forgetDeviceHardware`): every DERIVED stream definition is
@@ -67,6 +83,17 @@ export interface MigrateDeviceDeps {
67
83
  */
68
84
  readonly forgetStreamState: (deviceId: number) => Promise<void>;
69
85
  readonly log: (message: string, meta: Record<string, unknown>) => void;
86
+ /**
87
+ * The state of every switch the camera offers, read BEFORE the disable
88
+ * phase, so a migration refused before any row moved can put each switch
89
+ * back exactly as it was (D663 5b ruling I-1) — never blanket-on, which
90
+ * would undo a function the operator had switched off (D62). Bounded like a
91
+ * switch write; a read that fails is reported and the rollback falls back to
92
+ * switching that camera on.
93
+ */
94
+ readonly readSwitches: (deviceId: number) => Promise<SwitchStates>;
95
+ /** One line about ONE device, tagged with its id (D391). */
96
+ readonly logDevice: (level: MigrationLogLevel, deviceId: number, message: string, meta: Record<string, unknown>) => void;
70
97
  /**
71
98
  * Override for {@link SWITCH_WRITE_TIMEOUT_MS}, for the specs. A write that
72
99
  * outlives the bound is reported `unreachable` — never `off`/`on` — and may
@@ -26,6 +26,13 @@ export interface MigrationGuard {
26
26
  begin(sourceId: number, targetId: number): void;
27
27
  /** Release both ids. Always paired with {@link begin} via try/finally. */
28
28
  end(sourceId: number, targetId: number): void;
29
+ /**
30
+ * Is a migration involving this device running right now? A per-field claim,
31
+ * patch or release asks before touching the device's row (D663): the row is
32
+ * about to be swapped with another device's, and a claim written into that
33
+ * window would travel with the wrong hardware.
34
+ */
35
+ isInFlight(deviceId: number): boolean;
29
36
  /** Record a completed swap's fingerprint for replay matching. */
30
37
  recordCompleted(record: CompletedMigration): void;
31
38
  /**