@camstack/server 0.1.7 → 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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
  3. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
  4. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
  5. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
  6. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +209 -3
  7. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  8. package/src/api/core/cap-providers.ts +152 -3
  9. package/src/api/core/logs.router.ts +4 -0
  10. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  11. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
  12. package/src/api/trpc/cap-mount-helpers.ts +12 -1
  13. package/src/api/trpc/client-ip.ts +17 -0
  14. package/src/api/trpc/generated-cap-mounts.ts +281 -8
  15. package/src/api/trpc/generated-cap-routers.ts +2087 -184
  16. package/src/api/trpc/trpc.router.ts +43 -7
  17. package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
  18. package/src/boot/integration-id-backfill.ts +109 -0
  19. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  20. package/src/core/addon/addon-registry.service.ts +89 -2
  21. package/src/core/addon/addon-row-manifest.ts +29 -0
  22. package/src/core/logging/logging.service.ts +7 -2
  23. package/src/core/moleculer/moleculer.service.ts +28 -0
  24. package/src/core/network/network-quality.service.spec.ts +2 -1
  25. package/src/main.ts +92 -0
  26. package/src/core/storage/settings-store.spec.ts +0 -213
  27. package/src/core/storage/settings-store.ts +0 -2
  28. package/src/core/storage/sql-schema.spec.ts +0 -140
  29. 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
 
@@ -236,6 +239,7 @@ async function bootstrap() {
236
239
  // Instantiate new core services
237
240
  const loggingService = app.get(LoggingService);
238
241
  const addonRouteRegistry = new AddonRouteRegistry();
242
+ const dataPlaneRegistry = new DataPlaneRegistry();
239
243
 
240
244
  // Use Fastify-managed notification/toast wrappers (globally provided by NotificationModule)
241
245
  const { NotificationServiceWrapper } =
@@ -251,6 +255,7 @@ async function bootstrap() {
251
255
  // Wire AddonRouteRegistry and NotificationService
252
256
  const addonRegistry = app.get(AddonRegistryService);
253
257
  addonRegistry.setAddonRouteRegistry(addonRouteRegistry);
258
+ addonRegistry.setDataPlaneRegistry(dataPlaneRegistry);
254
259
 
255
260
  // ── Configure the CapabilityRegistry (created in AddonRegistryService constructor) ──
256
261
  const capabilityRegistry = addonRegistry.getCapabilityRegistry();
@@ -703,6 +708,59 @@ async function bootstrap() {
703
708
  else if (Array.isArray(v)) headers[k] = v.join(",");
704
709
  }
705
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
+
706
764
  const match = addonRouteRegistry.matchRoute(method, fullPath);
707
765
  if (!match) {
708
766
  if (method === "GET" && spaIndexHtml) {
@@ -1027,6 +1085,40 @@ async function bootstrap() {
1027
1085
  // Post-boot: fork workers, register device streams, emit system.boot
1028
1086
  const postBoot = app.get(PostBootService);
1029
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
+ }
1030
1122
  }
1031
1123
 
1032
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'