@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.
Files changed (60) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
  6. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
  7. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  8. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  9. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
  10. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  11. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  12. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
  13. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  14. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  15. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  16. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  17. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  18. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
  19. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  20. package/src/__tests__/native-cap-route.spec.ts +404 -0
  21. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  22. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  23. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  24. package/src/api/addon-upload.ts +27 -1
  25. package/src/api/capabilities.router.ts +1 -1
  26. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  27. package/src/api/core/bulk-update-coordinator.ts +302 -0
  28. package/src/api/core/cap-providers.ts +211 -9
  29. package/src/api/core/capabilities.router.ts +26 -3
  30. package/src/api/core/logs.router.ts +4 -0
  31. package/src/api/oauth2/oauth2-routes.ts +5 -1
  32. package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
  33. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
  34. package/src/api/trpc/cap-mount-helpers.ts +12 -1
  35. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  36. package/src/api/trpc/client-ip.ts +147 -0
  37. package/src/api/trpc/generated-cap-mounts.ts +299 -8
  38. package/src/api/trpc/generated-cap-routers.ts +2384 -302
  39. package/src/api/trpc/trpc.middleware.ts +5 -1
  40. package/src/api/trpc/trpc.router.ts +84 -3
  41. package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
  42. package/src/boot/integration-id-backfill.ts +109 -0
  43. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  44. package/src/core/addon/addon-call-gateway.ts +157 -0
  45. package/src/core/addon/addon-package.service.ts +9 -0
  46. package/src/core/addon/addon-registry.service.ts +453 -107
  47. package/src/core/addon/addon-row-manifest.ts +29 -0
  48. package/src/core/addon/addon-settings-provider.ts +40 -116
  49. package/src/core/capability/capability.service.ts +9 -0
  50. package/src/core/logging/logging.service.ts +7 -2
  51. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  52. package/src/core/moleculer/cap-call-fn.ts +103 -0
  53. package/src/core/moleculer/cap-route-authority.ts +182 -0
  54. package/src/core/moleculer/moleculer.service.ts +408 -36
  55. package/src/core/network/network-quality.service.spec.ts +2 -1
  56. package/src/main.ts +137 -12
  57. package/src/core/storage/settings-store.spec.ts +0 -213
  58. package/src/core/storage/settings-store.ts +0 -2
  59. package/src/core/storage/sql-schema.spec.ts +0 -140
  60. 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
- prefix: "/",
913
- wildcard: false,
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: catch-all route that serves index.html for non-API paths.
925
- // Uses a wildcard route instead of setNotFoundHandler.
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
- // static asset, not a SPA navigation route. If it reached here,
938
- // `fastify-static` has no route for it the file is missing.
939
- // Return 404 instead of the SPA `index.html`: serving HTML under
940
- // an asset URL makes upstream caches (Cloudflare, the browser)
941
- // pin `text/html` for a `.js`/`.css` URL, which then fails the
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,2 +0,0 @@
1
- // Re-export from @camstack/core (sqlite-storage builtin)
2
- export { SettingsStore } from '@camstack/core'
@@ -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
- })
@@ -1,3 +0,0 @@
1
- // Re-export from @camstack/core (sqlite-storage builtin)
2
- export { CORE_TABLE_DDL, addonTableToDdl } from '@camstack/core'
3
- export type { AddonTableSchema } from '@camstack/core'