signalk-backup 0.1.0

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 (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +71 -0
  3. package/assets/icon.png +0 -0
  4. package/assets/icon.svg +46 -0
  5. package/package.json +91 -0
  6. package/plugin/backup-client.d.ts +60 -0
  7. package/plugin/backup-client.d.ts.map +1 -0
  8. package/plugin/backup-client.js +87 -0
  9. package/plugin/backup-client.js.map +1 -0
  10. package/plugin/config/schema.d.ts +32 -0
  11. package/plugin/config/schema.d.ts.map +1 -0
  12. package/plugin/config/schema.js +83 -0
  13. package/plugin/config/schema.js.map +1 -0
  14. package/plugin/database-export/index.d.ts +33 -0
  15. package/plugin/database-export/index.d.ts.map +1 -0
  16. package/plugin/database-export/index.js +55 -0
  17. package/plugin/database-export/index.js.map +1 -0
  18. package/plugin/database-export/questdb.d.ts +41 -0
  19. package/plugin/database-export/questdb.d.ts.map +1 -0
  20. package/plugin/database-export/questdb.js +143 -0
  21. package/plugin/database-export/questdb.js.map +1 -0
  22. package/plugin/database-export/types.d.ts +30 -0
  23. package/plugin/database-export/types.d.ts.map +1 -0
  24. package/plugin/database-export/types.js +10 -0
  25. package/plugin/database-export/types.js.map +1 -0
  26. package/plugin/index.d.ts +4 -0
  27. package/plugin/index.d.ts.map +1 -0
  28. package/plugin/index.js +477 -0
  29. package/plugin/index.js.map +1 -0
  30. package/plugin/proxy.d.ts +13 -0
  31. package/plugin/proxy.d.ts.map +1 -0
  32. package/plugin/proxy.js +100 -0
  33. package/plugin/proxy.js.map +1 -0
  34. package/plugin/types.d.ts +121 -0
  35. package/plugin/types.d.ts.map +1 -0
  36. package/plugin/types.js +2 -0
  37. package/plugin/types.js.map +1 -0
  38. package/public/assets/index-B7P78qlq.js +2 -0
  39. package/public/assets/index-B7P78qlq.js.map +1 -0
  40. package/public/assets/main-DQRPJ7oa.js +11 -0
  41. package/public/assets/main-DQRPJ7oa.js.map +1 -0
  42. package/public/icon.svg +46 -0
  43. package/public/index.html +27 -0
@@ -0,0 +1,83 @@
1
+ import { Type } from '@sinclair/typebox';
2
+ /**
3
+ * Schema for the SignalK Admin UI plugin config form.
4
+ *
5
+ * Most user-facing settings (schedule, retention, cloud sync, exclusions)
6
+ * live inside the backup-server container's own UI — open it via the
7
+ * "Open Backup Console" link from /plugins/signalk-backup/. The fields
8
+ * below are only the per-deployment knobs the SignalK admin needs.
9
+ */
10
+ export const ConfigSchema = Type.Object({
11
+ managedContainer: Type.Boolean({
12
+ default: true,
13
+ title: 'Manage backup container via signalk-container',
14
+ description: 'When enabled (default), the plugin pulls and runs ghcr.io/dirkwa/signalk-backup-server. ' +
15
+ 'Disable to point at an external backup-server instance via "External URL".'
16
+ }),
17
+ imageTag: Type.String({
18
+ default: 'latest',
19
+ title: 'Container image tag',
20
+ description: 'Pin to a specific version (e.g. "0.1.0") or use a floating tag (e.g. "latest").'
21
+ }),
22
+ externalUrl: Type.String({
23
+ default: '',
24
+ title: 'External backup-server URL',
25
+ description: 'Used only when managedContainer is disabled. e.g. http://192.168.1.50:3010. ' +
26
+ 'Leave blank when managing the container.'
27
+ }),
28
+ logLevel: Type.Union([
29
+ Type.Literal('trace'),
30
+ Type.Literal('debug'),
31
+ Type.Literal('info'),
32
+ Type.Literal('warn'),
33
+ Type.Literal('error'),
34
+ Type.Literal('fatal')
35
+ ], {
36
+ default: 'info',
37
+ title: 'Log level',
38
+ description: 'Forwarded to the backup-server container as LOG_LEVEL.'
39
+ }),
40
+ databaseExport: Type.Object({
41
+ questdb: Type.Boolean({
42
+ default: false,
43
+ title: 'Export QuestDB to backup',
44
+ description: 'When enabled, the plugin periodically writes QuestDB tables to Parquet files ' +
45
+ 'inside the backup data dir. The next snapshot then captures them as part of ' +
46
+ 'the regular backup. Filesystem-level QuestDB files are still excluded — only ' +
47
+ 'the safe COPY-out exports travel.'
48
+ }),
49
+ intervalMinutes: Type.Number({
50
+ default: 60,
51
+ minimum: 5,
52
+ maximum: 1440,
53
+ title: 'Export interval (minutes)',
54
+ description: 'How often the plugin runs database exports. The freshness of DB data inside ' +
55
+ 'a backup is bounded by max(this interval, the backup-server snapshot interval). ' +
56
+ 'Default 60.'
57
+ })
58
+ }, {
59
+ default: { questdb: false, intervalMinutes: 60 },
60
+ title: 'Database export'
61
+ })
62
+ });
63
+ /**
64
+ * Materialised defaults — Signal K only uses the schema's `default` fields
65
+ * to seed the Admin UI form, NOT to inject defaults into the runtime config
66
+ * object passed to `plugin.start()`. When the plugin is auto-enabled
67
+ * (signalk-plugin-enabled-by-default) or enabled without saving the form,
68
+ * start() receives `{}`. Spread SCHEMA_DEFAULTS in start() so every field
69
+ * is present at runtime.
70
+ *
71
+ * See AGENTS.md §"Plugin-specific gotchas".
72
+ */
73
+ export const SCHEMA_DEFAULTS = {
74
+ managedContainer: true,
75
+ imageTag: 'latest',
76
+ externalUrl: '',
77
+ logLevel: 'info',
78
+ databaseExport: {
79
+ questdb: false,
80
+ intervalMinutes: 60
81
+ }
82
+ };
83
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAU,MAAM,mBAAmB,CAAA;AAEhD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,IAAI,CAAC,MAAM,CAAC;IACtC,gBAAgB,EAAE,IAAI,CAAC,OAAO,CAAC;QAC7B,OAAO,EAAE,IAAI;QACb,KAAK,EAAE,+CAA+C;QACtD,WAAW,EACT,0FAA0F;YAC1F,4EAA4E;KAC/E,CAAC;IACF,QAAQ,EAAE,IAAI,CAAC,MAAM,CAAC;QACpB,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,qBAAqB;QAC5B,WAAW,EAAE,iFAAiF;KAC/F,CAAC;IACF,WAAW,EAAE,IAAI,CAAC,MAAM,CAAC;QACvB,OAAO,EAAE,EAAE;QACX,KAAK,EAAE,4BAA4B;QACnC,WAAW,EACT,8EAA8E;YAC9E,0CAA0C;KAC7C,CAAC;IACF,QAAQ,EAAE,IAAI,CAAC,KAAK,CAClB;QACE,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC;QACrB,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC;QACrB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC;QACpB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC;QACpB,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC;QACrB,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC;KACtB,EACD;QACE,OAAO,EAAE,MAAM;QACf,KAAK,EAAE,WAAW;QAClB,WAAW,EAAE,wDAAwD;KACtE,CACF;IACD,cAAc,EAAE,IAAI,CAAC,MAAM,CACzB;QACE,OAAO,EAAE,IAAI,CAAC,OAAO,CAAC;YACpB,OAAO,EAAE,KAAK;YACd,KAAK,EAAE,0BAA0B;YACjC,WAAW,EACT,+EAA+E;gBAC/E,8EAA8E;gBAC9E,+EAA+E;gBAC/E,mCAAmC;SACtC,CAAC;QACF,eAAe,EAAE,IAAI,CAAC,MAAM,CAAC;YAC3B,OAAO,EAAE,EAAE;YACX,OAAO,EAAE,CAAC;YACV,OAAO,EAAE,IAAI;YACb,KAAK,EAAE,2BAA2B;YAClC,WAAW,EACT,8EAA8E;gBAC9E,kFAAkF;gBAClF,aAAa;SAChB,CAAC;KACH,EACD;QACE,OAAO,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,EAAE,EAAE;QAChD,KAAK,EAAE,iBAAiB;KACzB,CACF;CACF,CAAC,CAAA;AAIF;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,eAAe,GAAW;IACrC,gBAAgB,EAAE,IAAI;IACtB,QAAQ,EAAE,QAAQ;IAClB,WAAW,EAAE,EAAE;IACf,QAAQ,EAAE,MAAM;IAChB,cAAc,EAAE;QACd,OAAO,EAAE,KAAK;QACd,eAAe,EAAE,EAAE;KACpB;CACF,CAAA"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Database export orchestrator.
3
+ *
4
+ * Plugin-side counterpart to docs/v0.2-database-backup-design.md. The
5
+ * exporter pulls data via the source plugin's HTTP route on the SignalK
6
+ * server itself — no container exec or shared filesystems involved.
7
+ *
8
+ * Currently supports QuestDB only. InfluxDB is intentionally out of
9
+ * scope (see design doc).
10
+ */
11
+ import type { ExportResult } from './types.js';
12
+ export interface ExportOrchestratorOptions {
13
+ /** Host-visible path to the SignalK config root. */
14
+ signalkConfigRoot: string;
15
+ /** SignalK server base URL (loopback) — used to talk to source plugins. */
16
+ signalkBaseUrl: string;
17
+ /** Optional debug logger. */
18
+ log?: (msg: string) => void;
19
+ }
20
+ /**
21
+ * Run every supported exporter whose `detect()` returns true. Each
22
+ * exporter writes its parquet files under
23
+ * <configRoot>/plugin-config-data/signalk-backup/database-exports/<pluginId>/
24
+ * which is the staging area kopia will pick up on the next snapshot.
25
+ *
26
+ * Errors in one exporter are logged but don't abort the rest — partial
27
+ * coverage is preferable to none. The returned array contains one
28
+ * ExportResult per exporter that ran (regardless of success).
29
+ */
30
+ export declare function runAllExports(opts: ExportOrchestratorOptions): Promise<ExportResult[]>;
31
+ export type { DatabaseExporter, ExportResult, TableExport } from './types.js';
32
+ export { QuestDBExporter } from './questdb.js';
33
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/database-export/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,KAAK,EAAoB,YAAY,EAAE,MAAM,YAAY,CAAA;AAKhE,MAAM,WAAW,yBAAyB;IACxC,oDAAoD;IACpD,iBAAiB,EAAE,MAAM,CAAA;IACzB,2EAA2E;IAC3E,cAAc,EAAE,MAAM,CAAA;IACtB,6BAA6B;IAC7B,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5B;AAED;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE,yBAAyB,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAgC5F;AAED,YAAY,EAAE,gBAAgB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAC7E,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA"}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Database export orchestrator.
3
+ *
4
+ * Plugin-side counterpart to docs/v0.2-database-backup-design.md. The
5
+ * exporter pulls data via the source plugin's HTTP route on the SignalK
6
+ * server itself — no container exec or shared filesystems involved.
7
+ *
8
+ * Currently supports QuestDB only. InfluxDB is intentionally out of
9
+ * scope (see design doc).
10
+ */
11
+ import { mkdir } from 'node:fs/promises';
12
+ import { join } from 'node:path';
13
+ import { QuestDBExporter } from './questdb.js';
14
+ const PLUGIN_ID = 'signalk-backup';
15
+ const STAGING_SUBDIR = 'database-exports';
16
+ /**
17
+ * Run every supported exporter whose `detect()` returns true. Each
18
+ * exporter writes its parquet files under
19
+ * <configRoot>/plugin-config-data/signalk-backup/database-exports/<pluginId>/
20
+ * which is the staging area kopia will pick up on the next snapshot.
21
+ *
22
+ * Errors in one exporter are logged but don't abort the rest — partial
23
+ * coverage is preferable to none. The returned array contains one
24
+ * ExportResult per exporter that ran (regardless of success).
25
+ */
26
+ export async function runAllExports(opts) {
27
+ const stagingRoot = join(opts.signalkConfigRoot, 'plugin-config-data', PLUGIN_ID, STAGING_SUBDIR);
28
+ await mkdir(stagingRoot, { recursive: true });
29
+ const exporters = [
30
+ new QuestDBExporter({
31
+ signalkBaseUrl: opts.signalkBaseUrl,
32
+ log: opts.log
33
+ })
34
+ ];
35
+ const results = [];
36
+ for (const exporter of exporters) {
37
+ if (!(await exporter.detect())) {
38
+ opts.log?.(`[db-export] skipping ${exporter.pluginId} (detect failed)`);
39
+ continue;
40
+ }
41
+ const stagingDir = join(stagingRoot, exporter.pluginId);
42
+ try {
43
+ const r = await exporter.exportAll(stagingDir);
44
+ results.push(r);
45
+ opts.log?.(`[db-export] ${exporter.pluginId}: ${r.tables.length} tables, ` +
46
+ `${r.totalBytes} bytes, ${r.durationMs}ms`);
47
+ }
48
+ catch (err) {
49
+ opts.log?.(`[db-export] ${exporter.pluginId} failed: ${err instanceof Error ? err.message : String(err)}`);
50
+ }
51
+ }
52
+ return results;
53
+ }
54
+ export { QuestDBExporter } from './questdb.js';
55
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/database-export/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAA;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AAChC,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAG9C,MAAM,SAAS,GAAG,gBAAgB,CAAA;AAClC,MAAM,cAAc,GAAG,kBAAkB,CAAA;AAWzC;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAA+B;IACjE,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,iBAAiB,EAAE,oBAAoB,EAAE,SAAS,EAAE,cAAc,CAAC,CAAA;IACjG,MAAM,KAAK,CAAC,WAAW,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAE7C,MAAM,SAAS,GAAuB;QACpC,IAAI,eAAe,CAAC;YAClB,cAAc,EAAE,IAAI,CAAC,cAAc;YACnC,GAAG,EAAE,IAAI,CAAC,GAAG;SACd,CAAC;KACH,CAAA;IAED,MAAM,OAAO,GAAmB,EAAE,CAAA;IAClC,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,IAAI,CAAC,CAAC,MAAM,QAAQ,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC;YAC/B,IAAI,CAAC,GAAG,EAAE,CAAC,wBAAwB,QAAQ,CAAC,QAAQ,kBAAkB,CAAC,CAAA;YACvE,SAAQ;QACV,CAAC;QACD,MAAM,UAAU,GAAG,IAAI,CAAC,WAAW,EAAE,QAAQ,CAAC,QAAQ,CAAC,CAAA;QACvD,IAAI,CAAC;YACH,MAAM,CAAC,GAAG,MAAM,QAAQ,CAAC,SAAS,CAAC,UAAU,CAAC,CAAA;YAC9C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACf,IAAI,CAAC,GAAG,EAAE,CACR,eAAe,QAAQ,CAAC,QAAQ,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,WAAW;gBAC7D,GAAG,CAAC,CAAC,UAAU,WAAW,CAAC,CAAC,UAAU,IAAI,CAC7C,CAAA;QACH,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,IAAI,CAAC,GAAG,EAAE,CACR,eAAe,QAAQ,CAAC,QAAQ,YAAY,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE,CAC/F,CAAA;QACH,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAGD,OAAO,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * QuestDB exporter — pulls data via signalk-questdb's HTTP route.
3
+ *
4
+ * The signalk-questdb plugin exposes:
5
+ * GET /plugins/signalk-questdb/api/full-export/tables → { tables: [...] }
6
+ * GET /plugins/signalk-questdb/api/full-export/<table> → parquet stream
7
+ *
8
+ * Both are served by SignalK in-process, so we reach them via plain HTTP
9
+ * over loopback. No container exec, no shared filesystem ownership
10
+ * issues, no copy-completion polling.
11
+ *
12
+ * Streams the response body directly to a temp file in the staging dir,
13
+ * then atomically renames into place — so a snapshot mid-export never
14
+ * sees a half-written parquet.
15
+ */
16
+ import type { DatabaseExporter, ExportResult } from './types.js';
17
+ export interface QuestDBExporterOptions {
18
+ /** SignalK server base URL — typically http://127.0.0.1:3000 */
19
+ signalkBaseUrl?: string;
20
+ /** Optional debug logger. */
21
+ log?: (msg: string) => void;
22
+ /** Override fetch (tests). */
23
+ fetch?: typeof fetch;
24
+ }
25
+ export declare class QuestDBExporter implements DatabaseExporter {
26
+ readonly pluginId = "signalk-questdb";
27
+ private readonly baseUrl;
28
+ private readonly fetchImpl;
29
+ constructor(opts?: QuestDBExporterOptions);
30
+ private readonly log;
31
+ /**
32
+ * Detect: hit the tables endpoint. Returns true on HTTP 200 with at
33
+ * least one table; false on any error or 503/404 (plugin disabled or
34
+ * not loaded).
35
+ */
36
+ detect(): Promise<boolean>;
37
+ exportAll(stagingDir: string): Promise<ExportResult>;
38
+ private listTables;
39
+ private exportTable;
40
+ }
41
+ //# sourceMappingURL=questdb.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"questdb.d.ts","sourceRoot":"","sources":["../../src/database-export/questdb.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAOH,OAAO,KAAK,EAAE,gBAAgB,EAAE,YAAY,EAAe,MAAM,YAAY,CAAA;AAW7E,MAAM,WAAW,sBAAsB;IACrC,gEAAgE;IAChE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,6BAA6B;IAC7B,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAC3B,8BAA8B;IAC9B,KAAK,CAAC,EAAE,OAAO,KAAK,CAAA;CACrB;AAED,qBAAa,eAAgB,YAAW,gBAAgB;IACtD,QAAQ,CAAC,QAAQ,qBAAoB;IAErC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAQ;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAc;gBAE5B,IAAI,GAAE,sBAA2B;IAM7C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAuB;IAE3C;;;;OAIG;IACG,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAU1B,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;YAiC5C,UAAU;YAmBV,WAAW;CAuC1B"}
@@ -0,0 +1,143 @@
1
+ /**
2
+ * QuestDB exporter — pulls data via signalk-questdb's HTTP route.
3
+ *
4
+ * The signalk-questdb plugin exposes:
5
+ * GET /plugins/signalk-questdb/api/full-export/tables → { tables: [...] }
6
+ * GET /plugins/signalk-questdb/api/full-export/<table> → parquet stream
7
+ *
8
+ * Both are served by SignalK in-process, so we reach them via plain HTTP
9
+ * over loopback. No container exec, no shared filesystem ownership
10
+ * issues, no copy-completion polling.
11
+ *
12
+ * Streams the response body directly to a temp file in the staging dir,
13
+ * then atomically renames into place — so a snapshot mid-export never
14
+ * sees a half-written parquet.
15
+ */
16
+ import { mkdir, rename, unlink } from 'node:fs/promises';
17
+ import { createWriteStream } from 'node:fs';
18
+ import { pipeline } from 'node:stream/promises';
19
+ import { Readable } from 'node:stream';
20
+ import { join } from 'node:path';
21
+ const QUESTDB_PLUGIN_ID = 'signalk-questdb';
22
+ /** Default base URL — overridable for tests. SignalK normally listens here. */
23
+ const DEFAULT_SIGNALK_BASE = 'http://127.0.0.1:3000';
24
+ /** Per-request timeout. A full-table export of ~500k rows runs in <1s on
25
+ * a Pi over the loopback HTTP path, but pipe between pi-host-network +
26
+ * pasta containers can stall — bound at 10 minutes. */
27
+ const FETCH_TIMEOUT_MS = 600_000;
28
+ const SAFE_TABLE_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
29
+ export class QuestDBExporter {
30
+ pluginId = QUESTDB_PLUGIN_ID;
31
+ baseUrl;
32
+ fetchImpl;
33
+ constructor(opts = {}) {
34
+ this.baseUrl = (opts.signalkBaseUrl ?? DEFAULT_SIGNALK_BASE).replace(/\/$/, '');
35
+ this.fetchImpl = opts.fetch ?? fetch;
36
+ this.log = (msg) => opts.log?.(`[questdb-export] ${msg}`);
37
+ }
38
+ log;
39
+ /**
40
+ * Detect: hit the tables endpoint. Returns true on HTTP 200 with at
41
+ * least one table; false on any error or 503/404 (plugin disabled or
42
+ * not loaded).
43
+ */
44
+ async detect() {
45
+ try {
46
+ const tables = await this.listTables();
47
+ return tables.length > 0;
48
+ }
49
+ catch (err) {
50
+ this.log(`detect failed: ${errMsg(err)}`);
51
+ return false;
52
+ }
53
+ }
54
+ async exportAll(stagingDir) {
55
+ const startedAt = Date.now();
56
+ await mkdir(stagingDir, { recursive: true });
57
+ const tables = await this.listTables();
58
+ const exports = [];
59
+ for (const table of tables) {
60
+ // Defence in depth — the route should reject these too, but a
61
+ // malformed name in the request URL is worth catching here.
62
+ if (!SAFE_TABLE_NAME.test(table)) {
63
+ this.log(`refusing unsafe table identifier: ${table}`);
64
+ continue;
65
+ }
66
+ try {
67
+ exports.push(await this.exportTable(table, stagingDir));
68
+ }
69
+ catch (err) {
70
+ // Partial coverage > none. Log and keep going.
71
+ this.log(`export failed for ${table}: ${errMsg(err)}`);
72
+ }
73
+ }
74
+ return {
75
+ pluginId: this.pluginId,
76
+ tables: exports,
77
+ totalBytes: exports.reduce((acc, t) => acc + t.bytes, 0),
78
+ durationMs: Date.now() - startedAt
79
+ };
80
+ }
81
+ // ---------------------------------------------------------------------
82
+ // Internal
83
+ // ---------------------------------------------------------------------
84
+ async listTables() {
85
+ const url = `${this.baseUrl}/plugins/${this.pluginId}/api/full-export/tables`;
86
+ const res = await this.fetchImpl(url, {
87
+ signal: AbortSignal.timeout(10_000)
88
+ });
89
+ if (!res.ok) {
90
+ throw new Error(`tables HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
91
+ }
92
+ const body = (await res.json());
93
+ if (!Array.isArray(body.tables)) {
94
+ throw new Error(`tables response missing 'tables' array`);
95
+ }
96
+ const out = [];
97
+ for (const t of body.tables) {
98
+ if (typeof t === 'string')
99
+ out.push(t);
100
+ }
101
+ return out;
102
+ }
103
+ async exportTable(table, stagingDir) {
104
+ const url = `${this.baseUrl}/plugins/${this.pluginId}/api/full-export/${table}`;
105
+ const finalPath = join(stagingDir, `${table}.parquet`);
106
+ const tempPath = `${finalPath}.partial`;
107
+ this.log(`exporting ${table}`);
108
+ const res = await this.fetchImpl(url, {
109
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
110
+ });
111
+ if (!res.ok || !res.body) {
112
+ throw new Error(`HTTP ${res.status}: ${(await res.text().catch(() => '')).slice(0, 200)}`);
113
+ }
114
+ // Stream the response body to a temp file, then atomic-rename. This
115
+ // means a kopia snapshot that races us mid-export sees either the
116
+ // previous .parquet or no entry — never a torn write.
117
+ const out = createWriteStream(tempPath);
118
+ let bytes = 0;
119
+ out.on('drain', () => undefined);
120
+ // Count bytes via a tap — pipeline doesn't expose them otherwise.
121
+ const reader = Readable.fromWeb(res.body);
122
+ reader.on('data', (chunk) => {
123
+ bytes += chunk.length;
124
+ });
125
+ try {
126
+ await pipeline(reader, out);
127
+ }
128
+ catch (err) {
129
+ // Best-effort cleanup of partial file.
130
+ await unlink(tempPath).catch(() => undefined);
131
+ throw err;
132
+ }
133
+ await rename(tempPath, finalPath);
134
+ // rowCount is unknown without a separate query; leaving 0 keeps the
135
+ // shape stable. Bytes carries the meaningful "what got captured".
136
+ this.log(`exported ${table}: ${bytes} bytes`);
137
+ return { table, parquetPath: finalPath, rowCount: 0, bytes };
138
+ }
139
+ }
140
+ function errMsg(err) {
141
+ return err instanceof Error ? err.message : String(err);
142
+ }
143
+ //# sourceMappingURL=questdb.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"questdb.js","sourceRoot":"","sources":["../../src/database-export/questdb.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Common types for database exporters.
3
+ *
4
+ * Each exporter targets ONE database plugin (signalk-questdb, etc.) and
5
+ * produces one parquet file per table. Files land in a staging directory
6
+ * inside the SignalK config root so kopia picks them up automatically as
7
+ * part of the next snapshot — there's no separate "upload" step.
8
+ */
9
+ export interface TableExport {
10
+ table: string;
11
+ /** Host-visible absolute path to the exported parquet file. */
12
+ parquetPath: string;
13
+ rowCount: number;
14
+ bytes: number;
15
+ }
16
+ export interface ExportResult {
17
+ /** The signalk plugin id whose data this represents (e.g. 'signalk-questdb'). */
18
+ pluginId: string;
19
+ tables: TableExport[];
20
+ totalBytes: number;
21
+ durationMs: number;
22
+ }
23
+ export interface DatabaseExporter {
24
+ readonly pluginId: string;
25
+ /** Probe — returns true if this exporter can run against the live system. */
26
+ detect(): Promise<boolean>;
27
+ /** Run a full export of every user table. Caller has already created stagingDir. */
28
+ exportAll(stagingDir: string): Promise<ExportResult>;
29
+ }
30
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/database-export/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,+DAA+D;IAC/D,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,YAAY;IAC3B,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,WAAW,EAAE,CAAA;IACrB,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;IACzB,6EAA6E;IAC7E,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;IAC1B,oFAAoF;IACpF,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,CAAA;CACrD"}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Common types for database exporters.
3
+ *
4
+ * Each exporter targets ONE database plugin (signalk-questdb, etc.) and
5
+ * produces one parquet file per table. Files land in a staging directory
6
+ * inside the SignalK config root so kopia picks them up automatically as
7
+ * part of the next snapshot — there's no separate "upload" step.
8
+ */
9
+ export {};
10
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/database-export/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
@@ -0,0 +1,4 @@
1
+ import { Plugin } from '@signalk/server-api';
2
+ import { BackupServerAPI } from './types.js';
3
+ export default function (app: BackupServerAPI): Plugin;
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AAI5C,OAAO,EACL,eAAe,EAIhB,MAAM,YAAY,CAAA;AAqGnB,MAAM,CAAC,OAAO,WAAW,GAAG,EAAE,eAAe,GAAG,MAAM,CAqZrD"}