feat(device-core): add device registry schema
This commit is contained in:
+208
@@ -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",
|
||||
"002_device_project_access.sql",
|
||||
"003_device_management_commands.sql",
|
||||
"004_device_registry_foundation.sql",
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user