@camstack/server 0.1.6 → 0.1.8
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.
- package/package.json +3 -3
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +211 -9
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +147 -0
- package/src/api/trpc/generated-cap-mounts.ts +299 -8
- package/src/api/trpc/generated-cap-routers.ts +2384 -302
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +84 -3
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +453 -107
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +408 -36
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +137 -12
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- package/src/core/storage/sql-schema.ts +0 -3
package/src/main.ts
CHANGED
|
@@ -46,12 +46,15 @@ import { registerHealthRoutes } from "./api/health/health.routes";
|
|
|
46
46
|
import { registerOauth2Routes } from "./api/oauth2/oauth2-routes.js";
|
|
47
47
|
import {
|
|
48
48
|
AddonRouteRegistry,
|
|
49
|
+
DataPlaneRegistry,
|
|
50
|
+
proxyToUpstream,
|
|
49
51
|
} from "@camstack/core";
|
|
50
52
|
import type { FastifyRequest, FastifyReply } from "fastify";
|
|
51
53
|
import { loadBootstrapConfig, setupInfra } from "./boot/boot-config";
|
|
52
54
|
import { bootManual } from "./manual-boot";
|
|
53
55
|
|
|
54
56
|
import { PostBootService } from "./boot/post-boot.service";
|
|
57
|
+
import { runIntegrationIdBackfill } from "./boot/integration-id-backfill";
|
|
55
58
|
|
|
56
59
|
// ---- Process-level error handlers ----
|
|
57
60
|
|
|
@@ -201,6 +204,7 @@ async function bootstrap() {
|
|
|
201
204
|
const uploadAddonBridge = app.get(AddonBridgeService);
|
|
202
205
|
const uploadMoleculer = app.get(MoleculerService);
|
|
203
206
|
const uploadAddonRegistry = app.get(AddonRegistryService);
|
|
207
|
+
const uploadAddonPackage = app.get(AddonPackageService);
|
|
204
208
|
const uploadLogger = app.get(LoggingService).createLogger("addon-upload");
|
|
205
209
|
await registerAddonUploadRoute(
|
|
206
210
|
fastify,
|
|
@@ -208,6 +212,7 @@ async function bootstrap() {
|
|
|
208
212
|
uploadAuthService,
|
|
209
213
|
uploadMoleculer,
|
|
210
214
|
uploadAddonRegistry,
|
|
215
|
+
uploadAddonPackage,
|
|
211
216
|
uploadLogger,
|
|
212
217
|
);
|
|
213
218
|
console.log(
|
|
@@ -234,6 +239,7 @@ async function bootstrap() {
|
|
|
234
239
|
// Instantiate new core services
|
|
235
240
|
const loggingService = app.get(LoggingService);
|
|
236
241
|
const addonRouteRegistry = new AddonRouteRegistry();
|
|
242
|
+
const dataPlaneRegistry = new DataPlaneRegistry();
|
|
237
243
|
|
|
238
244
|
// Use Fastify-managed notification/toast wrappers (globally provided by NotificationModule)
|
|
239
245
|
const { NotificationServiceWrapper } =
|
|
@@ -249,6 +255,7 @@ async function bootstrap() {
|
|
|
249
255
|
// Wire AddonRouteRegistry and NotificationService
|
|
250
256
|
const addonRegistry = app.get(AddonRegistryService);
|
|
251
257
|
addonRegistry.setAddonRouteRegistry(addonRouteRegistry);
|
|
258
|
+
addonRegistry.setDataPlaneRegistry(dataPlaneRegistry);
|
|
252
259
|
|
|
253
260
|
// ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
|
|
254
261
|
const capabilityRegistry = addonRegistry.getCapabilityRegistry();
|
|
@@ -701,6 +708,59 @@ async function bootstrap() {
|
|
|
701
708
|
else if (Array.isArray(v)) headers[k] = v.join(",");
|
|
702
709
|
}
|
|
703
710
|
|
|
711
|
+
// ── HTTP data-plane: reverse-proxy to the addon's own listener ───────
|
|
712
|
+
// Checked BEFORE control routes. A hit means the addon serves this prefix
|
|
713
|
+
// via `ctx.dataPlane` (it streams the bytes with real req/res); the hub
|
|
714
|
+
// authenticates here, then pipes. Same origin/cert as the admin-ui.
|
|
715
|
+
const dpMatch = dataPlaneRegistry.match(addonId, subPath);
|
|
716
|
+
if (dpMatch) {
|
|
717
|
+
const access = dpMatch.endpoint.access;
|
|
718
|
+
if (access !== "public") {
|
|
719
|
+
const authHeader = request.headers.authorization;
|
|
720
|
+
const cookieToken = request.cookies?.[SESSION_COOKIE];
|
|
721
|
+
if (!authHeader && !cookieToken) {
|
|
722
|
+
return reply.status(401).send({ error: "Unauthorized" });
|
|
723
|
+
}
|
|
724
|
+
const token = authHeader ? authHeader.replace("Bearer ", "") : cookieToken!;
|
|
725
|
+
if (token.startsWith("cst_")) {
|
|
726
|
+
const userMgmt = capabilityRegistry?.getSingleton("user-management");
|
|
727
|
+
if (!userMgmt) return reply.status(503).send({ error: "User management not available" });
|
|
728
|
+
const scopedToken = await userMgmt.validateScopedToken({ token });
|
|
729
|
+
const scopeOk = scopedToken?.scopes.some(
|
|
730
|
+
(scope: { type: string; target?: string }) => scope.type === "addon" && scope.target === addonId,
|
|
731
|
+
);
|
|
732
|
+
if (!scopedToken || !scopeOk) {
|
|
733
|
+
return reply.status(403).send({ error: "Token scope mismatch" });
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
try {
|
|
737
|
+
const payload = authService.verifyToken(token);
|
|
738
|
+
if (access === "admin" && !payload.isAdmin) {
|
|
739
|
+
return reply.status(403).send({ error: "Admin required" });
|
|
740
|
+
}
|
|
741
|
+
} catch {
|
|
742
|
+
return reply.status(401).send({ error: "Invalid token" });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const qIdx = request.url.indexOf("?");
|
|
747
|
+
const query = qIdx >= 0 ? request.url.slice(qIdx) : "";
|
|
748
|
+
// Forward the FULL sub-path INCLUDING the prefix — the addon's facility
|
|
749
|
+
// multiplexes by prefix, so it strips the prefix itself. (`dpMatch.rest`
|
|
750
|
+
// is only used to pick the endpoint, not to rewrite the path.)
|
|
751
|
+
const upstreamPath = `/${subPath}${query}`;
|
|
752
|
+
// Take over the socket — `proxyToUpstream` drives the raw response.
|
|
753
|
+
reply.hijack();
|
|
754
|
+
proxyToUpstream({
|
|
755
|
+
baseUrl: dpMatch.endpoint.baseUrl,
|
|
756
|
+
secret: dpMatch.endpoint.secret,
|
|
757
|
+
upstreamPath,
|
|
758
|
+
clientReq: request.raw,
|
|
759
|
+
clientRes: reply.raw,
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
|
|
704
764
|
const match = addonRouteRegistry.matchRoute(method, fullPath);
|
|
705
765
|
if (!match) {
|
|
706
766
|
if (method === "GET" && spaIndexHtml) {
|
|
@@ -907,11 +967,16 @@ async function bootstrap() {
|
|
|
907
967
|
const indexPath = path.join(staticDir, "index.html");
|
|
908
968
|
if (fs.existsSync(staticDir) && fs.existsSync(indexPath)) {
|
|
909
969
|
spaIndexHtml = indexPath;
|
|
970
|
+
// `serve: false` registers no route — it only decorates
|
|
971
|
+
// `reply.sendFile`, so the single SPA `/*` handler below owns all
|
|
972
|
+
// routing and serves each asset LIVE from the current `staticDir`.
|
|
973
|
+
// The old `wildcard: false` registered one route per file enumerated
|
|
974
|
+
// AT BOOT, so a redeployed admin-ui's new content-hashed assets had no
|
|
975
|
+
// route and 404'd until a hub restart. Live `sendFile` removes that.
|
|
910
976
|
await fastify.register(fastifyStatic, {
|
|
911
977
|
root: staticDir,
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
decorateReply: false,
|
|
978
|
+
serve: false,
|
|
979
|
+
decorateReply: true,
|
|
915
980
|
});
|
|
916
981
|
// Dev diagnostic: serve webrtc-test.html from dataPath if it exists.
|
|
917
982
|
const webrtcTestPath = path.join(dataPath, "webrtc-test.html");
|
|
@@ -921,8 +986,9 @@ async function bootstrap() {
|
|
|
921
986
|
});
|
|
922
987
|
}
|
|
923
988
|
|
|
924
|
-
// SPA fallback
|
|
925
|
-
//
|
|
989
|
+
// SPA fallback + live static serving: this single catch-all owns every
|
|
990
|
+
// GET. Core API prefixes fall through to their own routers via
|
|
991
|
+
// `callNotFound`. Uses a wildcard route instead of setNotFoundHandler.
|
|
926
992
|
fastify.get("/*", async (request, reply) => {
|
|
927
993
|
const url = request.url;
|
|
928
994
|
if (
|
|
@@ -933,17 +999,42 @@ async function bootstrap() {
|
|
|
933
999
|
) {
|
|
934
1000
|
return reply.callNotFound();
|
|
935
1001
|
}
|
|
936
|
-
// A request whose last path segment has a file extension is a
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
// pin `text/html
|
|
942
|
-
// module MIME check long after the file is actually available.
|
|
1002
|
+
// A request whose last path segment has a file extension is a static
|
|
1003
|
+
// asset: serve it LIVE from the current dist so a redeployed
|
|
1004
|
+
// admin-ui's new content-hashed files are picked up without a hub
|
|
1005
|
+
// restart. When the file is missing, 404 — never the SPA
|
|
1006
|
+
// `index.html`: serving HTML under a `.js`/`.css` URL makes upstream
|
|
1007
|
+
// caches (Cloudflare, the browser) pin `text/html`, which then fails
|
|
1008
|
+
// the module MIME check long after the file is actually available.
|
|
943
1009
|
const pathOnly = url.split("?")[0] ?? url;
|
|
944
1010
|
if (/\.[a-zA-Z0-9]+$/.test(pathOnly)) {
|
|
1011
|
+
const rel = pathOnly.replace(/^\/+/, "");
|
|
1012
|
+
const abs = path.join(staticDir, rel);
|
|
1013
|
+
if (
|
|
1014
|
+
(abs === staticDir || abs.startsWith(staticDir + path.sep)) &&
|
|
1015
|
+
fs.existsSync(abs)
|
|
1016
|
+
) {
|
|
1017
|
+
// Cache policy that lets PWA updates actually propagate (the
|
|
1018
|
+
// stale-bundle bug): the service worker + registration + manifest
|
|
1019
|
+
// MUST be revalidated every load or a redeploy never reaches the
|
|
1020
|
+
// client (the SW keeps serving the old precache). Content-hashed
|
|
1021
|
+
// build assets (assets/index-<hash>.js) are immutable. Everything
|
|
1022
|
+
// else gets a short cache.
|
|
1023
|
+
const base = rel.split("/").pop() ?? rel;
|
|
1024
|
+
if (/^(sw\.js|registerSW\.js|workbox-.*\.js|manifest\.webmanifest)$/.test(base)) {
|
|
1025
|
+
reply.header("cache-control", "no-cache, must-revalidate");
|
|
1026
|
+
} else if (rel.startsWith("assets/") && /-[A-Za-z0-9_-]{8,}\./.test(base)) {
|
|
1027
|
+
reply.header("cache-control", "public, max-age=31536000, immutable");
|
|
1028
|
+
} else {
|
|
1029
|
+
reply.header("cache-control", "no-cache");
|
|
1030
|
+
}
|
|
1031
|
+
return reply.sendFile(rel);
|
|
1032
|
+
}
|
|
945
1033
|
return reply.callNotFound();
|
|
946
1034
|
}
|
|
1035
|
+
// index.html (the SPA shell) must never be cached — it references the
|
|
1036
|
+
// content-hashed bundles, so a stale copy pins the old app forever.
|
|
1037
|
+
reply.header("cache-control", "no-cache, must-revalidate");
|
|
947
1038
|
return reply.type("text/html").send(fs.createReadStream(spaIndexHtml!));
|
|
948
1039
|
});
|
|
949
1040
|
const { version } = await adminUI.getVersion();
|
|
@@ -994,6 +1085,40 @@ async function bootstrap() {
|
|
|
994
1085
|
// Post-boot: fork workers, register device streams, emit system.boot
|
|
995
1086
|
const postBoot = app.get(PostBootService);
|
|
996
1087
|
await postBoot.run({ port, host, dataPath, trpcRegistered });
|
|
1088
|
+
|
|
1089
|
+
// One-time backfill: stamp integrationId on devices created before the
|
|
1090
|
+
// device-manager forwarder started stamping it (legacy camera providers),
|
|
1091
|
+
// so deleting their integration cascades them. Idempotent — only touches
|
|
1092
|
+
// untagged top-level devices of single-instance addons.
|
|
1093
|
+
try {
|
|
1094
|
+
const dmForBackfill = capabilityRegistry.getSingleton('device-manager') as {
|
|
1095
|
+
listAll?: (input: { addonId?: string }) => Promise<readonly {
|
|
1096
|
+
id: number; addonId: string; parentDeviceId: number | null; integrationId?: string
|
|
1097
|
+
}[]>
|
|
1098
|
+
setIntegrationId?: (input: { deviceId: number; integrationId: string }) => Promise<void>
|
|
1099
|
+
} | null
|
|
1100
|
+
const integrationRegistry = addonRegistry.getIntegrationRegistry()
|
|
1101
|
+
if (dmForBackfill?.listAll && dmForBackfill?.setIntegrationId && integrationRegistry) {
|
|
1102
|
+
const listAll = dmForBackfill.listAll
|
|
1103
|
+
const setIntegrationId = dmForBackfill.setIntegrationId
|
|
1104
|
+
const backfillLogger = loggingService.createLogger('integration-backfill')
|
|
1105
|
+
await runIntegrationIdBackfill({
|
|
1106
|
+
listIntegrations: async () =>
|
|
1107
|
+
(await integrationRegistry.listIntegrations()).map((i) => ({ id: i.id, addonId: i.addonId })),
|
|
1108
|
+
listDevices: async () =>
|
|
1109
|
+
(await listAll({})).map((d) => ({
|
|
1110
|
+
id: d.id, addonId: d.addonId, parentDeviceId: d.parentDeviceId, integrationId: d.integrationId,
|
|
1111
|
+
})),
|
|
1112
|
+
setIntegrationId: (deviceId, integrationId) => setIntegrationId({ deviceId, integrationId }),
|
|
1113
|
+
logger: {
|
|
1114
|
+
info: (message, meta) => backfillLogger.info(message, { meta }),
|
|
1115
|
+
warn: (message, meta) => backfillLogger.warn(message, { meta }),
|
|
1116
|
+
},
|
|
1117
|
+
})
|
|
1118
|
+
}
|
|
1119
|
+
} catch (err) {
|
|
1120
|
+
console.warn('[bootstrap] integrationId backfill skipped:', err instanceof Error ? err.message : err)
|
|
1121
|
+
}
|
|
997
1122
|
}
|
|
998
1123
|
|
|
999
1124
|
/**
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
// server/backend/src/core/storage/settings-store.spec.ts
|
|
3
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
4
|
-
import { SettingsStore } from './settings-store'
|
|
5
|
-
import { RUNTIME_DEFAULTS } from '../config/config.schema'
|
|
6
|
-
|
|
7
|
-
// Use an in-memory SQLite DB for tests so no temp files are created.
|
|
8
|
-
function makeStore(): SettingsStore {
|
|
9
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call --
|
|
10
|
-
return new (SettingsStore as any)(':memory:') as SettingsStore
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Re-open the private constructor with ':memory:' by temporarily patching the
|
|
14
|
-
// constructor argument. SettingsStore accepts a dbPath string; ':memory:' is
|
|
15
|
-
// the special better-sqlite3 in-memory sentinel.
|
|
16
|
-
function createStore(): SettingsStore {
|
|
17
|
-
return new SettingsStore(':memory:')
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('SettingsStore — system settings', () => {
|
|
21
|
-
let store: SettingsStore
|
|
22
|
-
|
|
23
|
-
beforeEach(() => { store = createStore() })
|
|
24
|
-
afterEach(() => { store.close() })
|
|
25
|
-
|
|
26
|
-
it('returns undefined for a key that does not exist', () => {
|
|
27
|
-
expect(store.getSystem('nonexistent')).toBeUndefined()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
it('stores and retrieves a string value', () => {
|
|
31
|
-
store.setSystem('logging.level', 'debug')
|
|
32
|
-
expect(store.getSystem('logging.level')).toBe('debug')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('stores and retrieves a number value', () => {
|
|
36
|
-
store.setSystem('retention.days', 30)
|
|
37
|
-
expect(store.getSystem('retention.days')).toBe(30)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('stores and retrieves a boolean value', () => {
|
|
41
|
-
store.setSystem('features.streaming', true)
|
|
42
|
-
expect(store.getSystem('features.streaming')).toBe(true)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('stores and retrieves a nested object (JSON serialization)', () => {
|
|
46
|
-
const obj = { a: 1, b: [1, 2, 3], c: { nested: true } }
|
|
47
|
-
store.setSystem('complex.key', obj)
|
|
48
|
-
expect(store.getSystem('complex.key')).toEqual(obj)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('overwrites an existing key', () => {
|
|
52
|
-
store.setSystem('logging.level', 'info')
|
|
53
|
-
store.setSystem('logging.level', 'warn')
|
|
54
|
-
expect(store.getSystem('logging.level')).toBe('warn')
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('getAllSystem returns all stored keys', () => {
|
|
58
|
-
store.setSystem('key.a', 1)
|
|
59
|
-
store.setSystem('key.b', 2)
|
|
60
|
-
const all = store.getAllSystem()
|
|
61
|
-
expect(all['key.a']).toBe(1)
|
|
62
|
-
expect(all['key.b']).toBe(2)
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('getAllSystem returns empty object when no keys are set', () => {
|
|
66
|
-
expect(store.getAllSystem()).toEqual({})
|
|
67
|
-
})
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
describe('SettingsStore — addon settings', () => {
|
|
71
|
-
let store: SettingsStore
|
|
72
|
-
|
|
73
|
-
beforeEach(() => { store = createStore() })
|
|
74
|
-
afterEach(() => { store.close() })
|
|
75
|
-
|
|
76
|
-
it('returns undefined for a missing addon key', () => {
|
|
77
|
-
expect(store.getAddon('sqlite-storage', 'dbPath')).toBeUndefined()
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('stores and retrieves a value scoped by addonId', () => {
|
|
81
|
-
store.setAddon('sqlite-storage', 'dbPath', '/data/camstack.db')
|
|
82
|
-
expect(store.getAddon('sqlite-storage', 'dbPath')).toBe('/data/camstack.db')
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('scopes values: different addons do not share keys', () => {
|
|
86
|
-
store.setAddon('addon-a', 'sharedKey', 'value-a')
|
|
87
|
-
store.setAddon('addon-b', 'sharedKey', 'value-b')
|
|
88
|
-
expect(store.getAddon('addon-a', 'sharedKey')).toBe('value-a')
|
|
89
|
-
expect(store.getAddon('addon-b', 'sharedKey')).toBe('value-b')
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
it('getAllAddon returns only keys for the requested addon', () => {
|
|
93
|
-
store.setAddon('addon-a', 'key1', 10)
|
|
94
|
-
store.setAddon('addon-a', 'key2', 20)
|
|
95
|
-
store.setAddon('addon-b', 'key1', 99)
|
|
96
|
-
const all = store.getAllAddon('addon-a')
|
|
97
|
-
expect(all).toEqual({ key1: 10, key2: 20 })
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('setAllAddon replaces all keys atomically', () => {
|
|
101
|
-
store.setAddon('my-addon', 'old', 'gone')
|
|
102
|
-
store.setAllAddon('my-addon', { newKey: 'newVal', another: 42 })
|
|
103
|
-
const all = store.getAllAddon('my-addon')
|
|
104
|
-
expect(all).toEqual({ newKey: 'newVal', another: 42 })
|
|
105
|
-
expect(store.getAddon('my-addon', 'old')).toBeUndefined()
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('setAllAddon does not affect other addons', () => {
|
|
109
|
-
store.setAddon('other-addon', 'keep', 'me')
|
|
110
|
-
store.setAllAddon('my-addon', { x: 1 })
|
|
111
|
-
expect(store.getAddon('other-addon', 'keep')).toBe('me')
|
|
112
|
-
})
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
describe('SettingsStore — provider settings', () => {
|
|
116
|
-
let store: SettingsStore
|
|
117
|
-
|
|
118
|
-
beforeEach(() => { store = createStore() })
|
|
119
|
-
afterEach(() => { store.close() })
|
|
120
|
-
|
|
121
|
-
it('returns undefined for a missing provider key', () => {
|
|
122
|
-
expect(store.getProvider('prov-1', 'url')).toBeUndefined()
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('stores and retrieves a provider value', () => {
|
|
126
|
-
store.setProvider('prov-1', 'url', 'http://localhost:8554')
|
|
127
|
-
expect(store.getProvider('prov-1', 'url')).toBe('http://localhost:8554')
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('scopes values between providers', () => {
|
|
131
|
-
store.setProvider('prov-1', 'url', 'http://host-a')
|
|
132
|
-
store.setProvider('prov-2', 'url', 'http://host-b')
|
|
133
|
-
expect(store.getProvider('prov-1', 'url')).toBe('http://host-a')
|
|
134
|
-
expect(store.getProvider('prov-2', 'url')).toBe('http://host-b')
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
it('getAllProvider returns all keys for a provider', () => {
|
|
138
|
-
store.setProvider('prov-1', 'url', 'http://x')
|
|
139
|
-
store.setProvider('prov-1', 'port', 8554)
|
|
140
|
-
expect(store.getAllProvider('prov-1')).toEqual({ url: 'http://x', port: 8554 })
|
|
141
|
-
})
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
describe('SettingsStore — device settings', () => {
|
|
145
|
-
let store: SettingsStore
|
|
146
|
-
|
|
147
|
-
beforeEach(() => { store = createStore() })
|
|
148
|
-
afterEach(() => { store.close() })
|
|
149
|
-
|
|
150
|
-
it('returns undefined for a missing device key', () => {
|
|
151
|
-
expect(store.getDevice('cam-1', 'label')).toBeUndefined()
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
it('stores and retrieves a device value', () => {
|
|
155
|
-
store.setDevice('cam-1', 'label', 'Front Door')
|
|
156
|
-
expect(store.getDevice('cam-1', 'label')).toBe('Front Door')
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
it('scopes values between devices', () => {
|
|
160
|
-
store.setDevice('cam-1', 'label', 'A')
|
|
161
|
-
store.setDevice('cam-2', 'label', 'B')
|
|
162
|
-
expect(store.getDevice('cam-1', 'label')).toBe('A')
|
|
163
|
-
expect(store.getDevice('cam-2', 'label')).toBe('B')
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
it('getAllDevice returns all keys for a device', () => {
|
|
167
|
-
store.setDevice('cam-1', 'label', 'Cam 1')
|
|
168
|
-
store.setDevice('cam-1', 'enabled', true)
|
|
169
|
-
expect(store.getAllDevice('cam-1')).toEqual({ label: 'Cam 1', enabled: true })
|
|
170
|
-
})
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
describe('SettingsStore — first boot seeding', () => {
|
|
174
|
-
let store: SettingsStore
|
|
175
|
-
|
|
176
|
-
beforeEach(() => { store = createStore() })
|
|
177
|
-
afterEach(() => { store.close() })
|
|
178
|
-
|
|
179
|
-
it('isSystemSettingsEmpty returns true on a fresh DB', () => {
|
|
180
|
-
expect(store.isSystemSettingsEmpty()).toBe(true)
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
it('seedDefaults populates system_settings with RUNTIME_DEFAULTS', () => {
|
|
184
|
-
store.seedDefaults()
|
|
185
|
-
expect(store.isSystemSettingsEmpty()).toBe(false)
|
|
186
|
-
for (const [key, expected] of Object.entries(RUNTIME_DEFAULTS)) {
|
|
187
|
-
expect(store.getSystem(key)).toEqual(expected)
|
|
188
|
-
}
|
|
189
|
-
})
|
|
190
|
-
|
|
191
|
-
it('seedDefaults does not overwrite existing values (INSERT OR IGNORE)', () => {
|
|
192
|
-
store.setSystem('logging.level', 'debug')
|
|
193
|
-
store.seedDefaults()
|
|
194
|
-
// The manually set value should survive — seedDefaults uses INSERT OR IGNORE
|
|
195
|
-
expect(store.getSystem('logging.level')).toBe('debug')
|
|
196
|
-
})
|
|
197
|
-
|
|
198
|
-
it('second boot: seedDefaults is skipped when table is not empty', () => {
|
|
199
|
-
store.seedDefaults()
|
|
200
|
-
// Simulate a manual override then "re-boot"
|
|
201
|
-
store.setSystem('logging.level', 'warn')
|
|
202
|
-
|
|
203
|
-
// On second boot we would call seedDefaults only when isEmpty — simulate that guard
|
|
204
|
-
if (!store.isSystemSettingsEmpty()) {
|
|
205
|
-
// No-op: skip seeding
|
|
206
|
-
} else {
|
|
207
|
-
store.seedDefaults()
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Value from first seed should remain (not reverted to RUNTIME_DEFAULTS)
|
|
211
|
-
expect(store.getSystem('logging.level')).toBe('warn')
|
|
212
|
-
})
|
|
213
|
-
})
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
// server/backend/src/core/storage/sql-schema.spec.ts
|
|
2
|
-
import { describe, it, expect } from 'vitest'
|
|
3
|
-
import { addonTableToDdl, CORE_TABLE_DDL, type AddonTableSchema } from './sql-schema'
|
|
4
|
-
|
|
5
|
-
describe('CORE_TABLE_DDL', () => {
|
|
6
|
-
it('is a non-empty readonly array of strings', () => {
|
|
7
|
-
expect(CORE_TABLE_DDL.length).toBeGreaterThan(0)
|
|
8
|
-
for (const stmt of CORE_TABLE_DDL) {
|
|
9
|
-
expect(typeof stmt).toBe('string')
|
|
10
|
-
expect(stmt.length).toBeGreaterThan(0)
|
|
11
|
-
}
|
|
12
|
-
})
|
|
13
|
-
|
|
14
|
-
it('contains CREATE TABLE IF NOT EXISTS for core tables', () => {
|
|
15
|
-
// `detection_events`, `audio_levels`, `track_trails` were removed
|
|
16
|
-
// in the P12b sweep — they're now owned by `addon-pipeline-analytics`
|
|
17
|
-
// via `declareCollection` (pipeline-analytics:motion-events,
|
|
18
|
-
// :object-events, :audio-events, :tracks, :media). The hub-core
|
|
19
|
-
// DDL only carries the foundational settings + device registry
|
|
20
|
-
// tables.
|
|
21
|
-
const tables = ['system_settings', 'addon_settings', 'provider_settings', 'device_settings', 'devices']
|
|
22
|
-
for (const table of tables) {
|
|
23
|
-
const found = CORE_TABLE_DDL.some(stmt => stmt.includes(`CREATE TABLE IF NOT EXISTS ${table}`))
|
|
24
|
-
expect(found, `Expected DDL for table "${table}"`).toBe(true)
|
|
25
|
-
}
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
it('contains indexes for the devices table', () => {
|
|
29
|
-
const found = CORE_TABLE_DDL.some(stmt => stmt.includes('idx_devices_addon'))
|
|
30
|
-
expect(found).toBe(true)
|
|
31
|
-
})
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
describe('addonTableToDdl', () => {
|
|
35
|
-
it('generates a CREATE TABLE statement with single primary key', () => {
|
|
36
|
-
const schema: AddonTableSchema = {
|
|
37
|
-
name: 'my_addon_events',
|
|
38
|
-
columns: [
|
|
39
|
-
{ name: 'id', type: 'TEXT', primaryKey: true },
|
|
40
|
-
{ name: 'timestamp', type: 'INTEGER', notNull: true },
|
|
41
|
-
{ name: 'payload', type: 'JSON' },
|
|
42
|
-
],
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const stmts = addonTableToDdl(schema)
|
|
46
|
-
expect(stmts).toHaveLength(1)
|
|
47
|
-
const ddl = stmts[0]
|
|
48
|
-
expect(ddl).toContain('CREATE TABLE IF NOT EXISTS my_addon_events')
|
|
49
|
-
expect(ddl).toContain('id TEXT')
|
|
50
|
-
expect(ddl).toContain('timestamp INTEGER NOT NULL')
|
|
51
|
-
expect(ddl).toContain('payload JSON')
|
|
52
|
-
expect(ddl).toContain('PRIMARY KEY (id)')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('generates a CREATE TABLE with composite primary key', () => {
|
|
56
|
-
const schema: AddonTableSchema = {
|
|
57
|
-
name: 'composite_pk_table',
|
|
58
|
-
columns: [
|
|
59
|
-
{ name: 'addon_id', type: 'TEXT', primaryKey: true, notNull: true },
|
|
60
|
-
{ name: 'key', type: 'TEXT', primaryKey: true, notNull: true },
|
|
61
|
-
{ name: 'value', type: 'JSON', notNull: true },
|
|
62
|
-
],
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const stmts = addonTableToDdl(schema)
|
|
66
|
-
expect(stmts[0]).toContain('PRIMARY KEY (addon_id, key)')
|
|
67
|
-
expect(stmts[0]).toContain('addon_id TEXT NOT NULL')
|
|
68
|
-
expect(stmts[0]).toContain('key TEXT NOT NULL')
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('generates indexes when provided', () => {
|
|
72
|
-
const schema: AddonTableSchema = {
|
|
73
|
-
name: 'indexed_table',
|
|
74
|
-
columns: [
|
|
75
|
-
{ name: 'id', type: 'TEXT', primaryKey: true },
|
|
76
|
-
{ name: 'device_id', type: 'TEXT', notNull: true },
|
|
77
|
-
{ name: 'ts', type: 'INTEGER', notNull: true },
|
|
78
|
-
],
|
|
79
|
-
indexes: [
|
|
80
|
-
{ name: 'idx_indexed_device_ts', columns: ['device_id', 'ts'] },
|
|
81
|
-
{ name: 'idx_unique_id', columns: ['id'], unique: true },
|
|
82
|
-
],
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const stmts = addonTableToDdl(schema)
|
|
86
|
-
expect(stmts).toHaveLength(3)
|
|
87
|
-
expect(stmts[1]).toContain('CREATE INDEX IF NOT EXISTS idx_indexed_device_ts ON indexed_table(device_id, ts)')
|
|
88
|
-
expect(stmts[2]).toContain('CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_id ON indexed_table(id)')
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('generates no PRIMARY KEY clause when no column is marked primaryKey', () => {
|
|
92
|
-
const schema: AddonTableSchema = {
|
|
93
|
-
name: 'no_pk_table',
|
|
94
|
-
columns: [
|
|
95
|
-
{ name: 'col1', type: 'TEXT' },
|
|
96
|
-
{ name: 'col2', type: 'INTEGER' },
|
|
97
|
-
],
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const stmts = addonTableToDdl(schema)
|
|
101
|
-
expect(stmts[0]).not.toContain('PRIMARY KEY')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('produces valid SQL string (no syntax placeholders)', () => {
|
|
105
|
-
const schema: AddonTableSchema = {
|
|
106
|
-
name: 'valid_sql_table',
|
|
107
|
-
columns: [
|
|
108
|
-
{ name: 'id', type: 'TEXT', primaryKey: true },
|
|
109
|
-
{ name: 'data', type: 'JSON' },
|
|
110
|
-
],
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const stmts = addonTableToDdl(schema)
|
|
114
|
-
for (const stmt of stmts) {
|
|
115
|
-
// Should not contain template placeholders or undefined
|
|
116
|
-
expect(stmt).not.toContain('undefined')
|
|
117
|
-
expect(stmt).not.toContain('${')
|
|
118
|
-
expect(stmt.trim().length).toBeGreaterThan(0)
|
|
119
|
-
}
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
it('handles all column types correctly', () => {
|
|
123
|
-
const schema: AddonTableSchema = {
|
|
124
|
-
name: 'all_types',
|
|
125
|
-
columns: [
|
|
126
|
-
{ name: 'text_col', type: 'TEXT' },
|
|
127
|
-
{ name: 'int_col', type: 'INTEGER' },
|
|
128
|
-
{ name: 'real_col', type: 'REAL' },
|
|
129
|
-
{ name: 'json_col', type: 'JSON' },
|
|
130
|
-
],
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const stmts = addonTableToDdl(schema)
|
|
134
|
-
const ddl = stmts[0]
|
|
135
|
-
expect(ddl).toContain('text_col TEXT')
|
|
136
|
-
expect(ddl).toContain('int_col INTEGER')
|
|
137
|
-
expect(ddl).toContain('real_col REAL')
|
|
138
|
-
expect(ddl).toContain('json_col JSON')
|
|
139
|
-
})
|
|
140
|
-
})
|