feat(device-core): add device registry schema

This commit is contained in:
Codex
2026-08-10 17:34:50 +03:00
parent 70bafdd028
commit 9fdaed1c95
3 changed files with 276 additions and 0 deletions
@@ -0,0 +1,208 @@
begin;
create table if not exists device_adapter_packages (
id uuid primary key,
package_key text not null
check (package_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
publisher_ref text not null
check (length(btrim(publisher_ref)) between 3 and 256),
lifecycle_state text not null default 'active'
check (lifecycle_state in ('active', 'retired')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (package_key)
);
create table if not exists device_adapter_versions (
id uuid primary key,
adapter_package_id uuid not null references device_adapter_packages(id),
version text not null
check (version ~ '^[0-9]+\.[0-9]+\.[0-9]+([+-][A-Za-z0-9.-]+)?$'),
runtime_package_ref text not null
check (length(btrim(runtime_package_ref)) between 3 and 256),
content_digest text not null
check (content_digest ~ '^sha256:[a-f0-9]{64}$'),
contract_version text not null
check (contract_version ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$'),
capabilities text[] not null default '{}'
check (
cardinality(capabilities) <= 64
and array_position(capabilities, null) is null
),
lifecycle_state text not null default 'draft'
check (lifecycle_state in ('draft', 'active', 'retired')),
registered_by_ref text not null
check (length(btrim(registered_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (adapter_package_id, version),
unique (runtime_package_ref, content_digest),
unique (id, adapter_package_id)
);
alter table device_model_profiles
add column if not exists adapter_version_id uuid
references device_adapter_versions(id),
add column if not exists schema_artifact_ref text
check (
schema_artifact_ref is null
or length(btrim(schema_artifact_ref)) between 3 and 256
),
add column if not exists profile_digest text
check (
profile_digest is null
or profile_digest ~ '^sha256:[a-f0-9]{64}$'
),
add column if not exists capabilities text[] not null default '{}'
check (
cardinality(capabilities) <= 64
and array_position(capabilities, null) is null
),
add column if not exists lifecycle_state text not null default 'active'
check (lifecycle_state in ('draft', 'active', 'retired'));
create index if not exists device_model_profiles_adapter_version_idx
on device_model_profiles (adapter_version_id, lifecycle_state, updated_at desc);
create table if not exists device_edges (
id uuid primary key,
edge_key text not null
check (edge_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
deployment_ref text
check (
deployment_ref is null
or length(btrim(deployment_ref)) between 3 and 256
),
lifecycle_state text not null default 'provisioning'
check (lifecycle_state in ('provisioning', 'active', 'suspended', 'retired')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (edge_key)
);
create table if not exists device_routes (
id uuid primary key,
project_id uuid not null references device_projects(id),
route_key text not null
check (route_key ~ '^[a-z][a-z0-9-]{1,62}$'),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
edge_id uuid not null references device_edges(id),
model_profile_ref text not null references device_model_profiles(profile_ref),
listener_ref text not null
check (length(btrim(listener_ref)) between 3 and 256),
protocol text not null
check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'),
direction text not null default 'telemetry'
check (direction in ('telemetry', 'bidirectional')),
lifecycle_state text not null default 'draft'
check (lifecycle_state in ('draft', 'active', 'suspended', 'retired')),
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, route_key),
unique (id, project_id),
unique (id, edge_id, project_id),
unique (id, project_id, model_profile_ref)
);
create index if not exists device_routes_edge_state_idx
on device_routes (edge_id, lifecycle_state, updated_at desc);
create index if not exists device_routes_project_state_idx
on device_routes (project_id, lifecycle_state, updated_at desc);
create table if not exists device_sessions (
id uuid primary key,
session_ref text not null
check (length(btrim(session_ref)) between 3 and 256),
edge_id uuid not null,
project_id uuid not null,
route_id uuid not null,
device_id uuid,
protocol text not null
check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'),
lifecycle_state text not null default 'connecting'
check (lifecycle_state in ('connecting', 'online', 'closing', 'closed', 'rejected')),
connected_at timestamptz not null,
last_seen_at timestamptz not null,
disconnected_at timestamptz,
close_reason_code text
check (
close_reason_code is null
or close_reason_code ~ '^[a-z][a-z0-9._-]{1,63}$'
),
frame_count bigint not null default 0 check (frame_count >= 0),
byte_count bigint not null default 0 check (byte_count >= 0),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (edge_id, session_ref),
foreign key (route_id, edge_id, project_id)
references device_routes(id, edge_id, project_id),
foreign key (device_id, project_id)
references device_instances(id, project_id),
check (last_seen_at >= connected_at),
check (
(lifecycle_state in ('connecting', 'online') and disconnected_at is null)
or
(lifecycle_state in ('closing', 'closed', 'rejected'))
)
);
create index if not exists device_sessions_route_state_seen_idx
on device_sessions (route_id, lifecycle_state, last_seen_at desc);
create index if not exists device_sessions_device_seen_idx
on device_sessions (device_id, last_seen_at desc)
where device_id is not null;
create table if not exists device_enrollment_intents (
id uuid primary key,
project_id uuid not null references device_projects(id),
enrollment_key text not null
check (enrollment_key ~ '^[a-z][a-z0-9-]{1,62}$'),
route_id uuid not null,
model_profile_ref text not null references device_model_profiles(profile_ref),
display_name text not null
check (length(btrim(display_name)) between 1 and 160),
expected_identifier_kind text not null
check (expected_identifier_kind ~ '^[a-z][a-z0-9._-]{1,31}$'),
expected_identifier_digest text not null
check (expected_identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'),
expected_identifier_masked text not null
check (length(btrim(expected_identifier_masked)) between 4 and 64),
lifecycle_state text not null default 'pending'
check (lifecycle_state in ('pending', 'observed', 'claimed', 'cancelled', 'expired')),
expires_at timestamptz,
claimed_device_id uuid,
created_by_ref text not null
check (length(btrim(created_by_ref)) between 3 and 256),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (project_id, enrollment_key),
unique (
project_id,
expected_identifier_kind,
expected_identifier_digest,
model_profile_ref
),
foreign key (route_id, project_id, model_profile_ref)
references device_routes(id, project_id, model_profile_ref),
foreign key (claimed_device_id, project_id)
references device_instances(id, project_id),
check (expires_at is null or expires_at > created_at)
);
create index if not exists device_enrollment_intents_project_state_idx
on device_enrollment_intents (project_id, lifecycle_state, updated_at desc);
commit;
@@ -19,6 +19,7 @@ const migrationFiles = [
"001_device_plane_foundation.sql", "001_device_plane_foundation.sql",
"002_device_project_access.sql", "002_device_project_access.sql",
"003_device_management_commands.sql", "003_device_management_commands.sql",
"004_device_registry_foundation.sql",
]; ];
export class PostgresDeviceRepository { export class PostgresDeviceRepository {
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const migrationUrl = new URL(
"../migrations/004_device_registry_foundation.sql",
import.meta.url,
);
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
test("registry migration defines generic catalog, edge and runtime boundaries", async () => {
const sql = await readFile(migrationUrl, "utf8");
for (const table of [
"device_adapter_packages",
"device_adapter_versions",
"device_edges",
"device_routes",
"device_sessions",
"device_enrollment_intents",
]) {
assert.match(sql, new RegExp(`create table if not exists ${table}`));
}
assert.match(sql, /add column if not exists adapter_version_id uuid/);
assert.match(sql, /content_digest ~ '\^sha256:\[a-f0-9\]\{64\}\$'/);
assert.match(sql, /expected_identifier_digest ~ '\^hmac-sha256:\[a-f0-9\]\{64\}\$'/);
});
test("registry migration enforces project, route, edge and device isolation", async () => {
const sql = await readFile(migrationUrl, "utf8");
assert.match(
sql,
/foreign key \(route_id, edge_id, project_id\)\s+references device_routes\(id, edge_id, project_id\)/,
);
assert.match(
sql,
/foreign key \(route_id, project_id, model_profile_ref\)\s+references device_routes\(id, project_id, model_profile_ref\)/,
);
assert.match(
sql,
/foreign key \(device_id, project_id\)\s+references device_instances\(id, project_id\)/,
);
assert.match(
sql,
/foreign key \(claimed_device_id, project_id\)\s+references device_instances\(id, project_id\)/,
);
});
test("registry migration stores no device, tenant, network or credential seed", async () => {
const sql = await readFile(migrationUrl, "utf8");
assert.doesNotMatch(sql, /insert\s+into/i);
assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i);
assert.doesNotMatch(sql, /155\.212\.|device\.nodedc\.ru|synology/i);
assert.doesNotMatch(sql, /password|secret|private_key|credential_ref/i);
});
test("repository applies registry migration after management receipts", async () => {
const source = await readFile(repositoryUrl, "utf8");
const managementIndex = source.indexOf("003_device_management_commands.sql");
const registryIndex = source.indexOf("004_device_registry_foundation.sql");
assert.notEqual(managementIndex, -1);
assert.notEqual(registryIndex, -1);
assert.ok(managementIndex < registryIndex);
});