@lde/distribution-monitor 0.1.1

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/README.md ADDED
@@ -0,0 +1,180 @@
1
+ # Distribution Monitor
2
+
3
+ Monitor DCAT distributions (SPARQL endpoints and data dumps) with periodic probes, storing observations in PostgreSQL. Uses [`@lde/distribution-probe`](../distribution-probe) for the actual health check.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @lde/distribution-monitor
9
+ ```
10
+
11
+ ## CLI Usage
12
+
13
+ The easiest way to run the monitor is via the CLI with a configuration file.
14
+
15
+ ### Quick Start
16
+
17
+ 1. Create a configuration file (TypeScript, JavaScript, JSON, or YAML)
18
+ 2. Run the monitor
19
+
20
+ ```bash
21
+ # Start continuous monitoring
22
+ npx distribution-monitor start
23
+
24
+ # Run a one-off check for all monitors
25
+ npx distribution-monitor check
26
+
27
+ # Check a specific monitor
28
+ npx distribution-monitor check dbpedia
29
+
30
+ # Use a custom config path
31
+ npx distribution-monitor start --config ./configs/production.config.ts
32
+ ```
33
+
34
+ ### TypeScript Config (`distribution-monitor.config.ts`)
35
+
36
+ ```typescript
37
+ import { defineConfig } from '@lde/distribution-monitor';
38
+
39
+ export default defineConfig({
40
+ databaseUrl: process.env.DATABASE_URL,
41
+ intervalSeconds: 300,
42
+ timeoutMs: 30_000,
43
+ monitors: [
44
+ {
45
+ identifier: 'dbpedia',
46
+ distribution: {
47
+ accessUrl: 'https://dbpedia.org/sparql',
48
+ conformsTo: 'https://www.w3.org/TR/sparql11-protocol/',
49
+ },
50
+ sparqlQuery: 'ASK { ?s ?p ?o }',
51
+ },
52
+ {
53
+ identifier: 'wikidata',
54
+ distribution: {
55
+ accessUrl: 'https://query.wikidata.org/sparql',
56
+ conformsTo: 'https://www.w3.org/TR/sparql11-protocol/',
57
+ },
58
+ sparqlQuery: 'SELECT * WHERE { ?s ?p ?o } LIMIT 1',
59
+ },
60
+ {
61
+ identifier: 'my-dump',
62
+ distribution: {
63
+ accessUrl: 'https://example.org/data.nt',
64
+ mediaType: 'application/n-triples',
65
+ },
66
+ },
67
+ ],
68
+ });
69
+ ```
70
+
71
+ ### YAML Config (`distribution-monitor.config.yaml`)
72
+
73
+ ```yaml
74
+ databaseUrl: ${DATABASE_URL}
75
+ intervalSeconds: 300
76
+ monitors:
77
+ - identifier: dbpedia
78
+ distribution:
79
+ accessUrl: https://dbpedia.org/sparql
80
+ conformsTo: https://www.w3.org/TR/sparql11-protocol/
81
+ sparqlQuery: ASK { ?s ?p ?o }
82
+ - identifier: my-dump
83
+ distribution:
84
+ accessUrl: https://example.org/data.nt
85
+ mediaType: application/n-triples
86
+ ```
87
+
88
+ ### Environment Variables
89
+
90
+ Create a `.env` file for sensitive configuration:
91
+
92
+ ```
93
+ DATABASE_URL=postgres://user:pass@localhost:5432/monitoring
94
+ ```
95
+
96
+ The CLI automatically loads `.env` files.
97
+
98
+ ### Config Auto-Discovery
99
+
100
+ The CLI searches for configuration in this order:
101
+
102
+ 1. `distribution-monitor.config.{ts,mts,js,mjs,json,yaml,yml}`
103
+ 2. `.distribution-monitorrc`
104
+ 3. `package.json` → `"distribution-monitor"` key
105
+
106
+ ## Programmatic Usage
107
+
108
+ ```typescript
109
+ import { Distribution } from '@lde/dataset';
110
+ import {
111
+ MonitorService,
112
+ PostgresObservationStore,
113
+ type MonitorConfig,
114
+ } from '@lde/distribution-monitor';
115
+
116
+ const monitors: MonitorConfig[] = [
117
+ {
118
+ identifier: 'dbpedia',
119
+ distribution: Distribution.sparql(new URL('https://dbpedia.org/sparql')),
120
+ sparqlQuery: 'ASK { ?s ?p ?o }',
121
+ },
122
+ {
123
+ identifier: 'my-dump',
124
+ distribution: new Distribution(
125
+ new URL('https://example.org/data.nt'),
126
+ 'application/n-triples',
127
+ ),
128
+ },
129
+ ];
130
+
131
+ const store = await PostgresObservationStore.create(
132
+ 'postgres://user:pass@localhost:5432/db',
133
+ );
134
+
135
+ const service = new MonitorService({
136
+ store,
137
+ monitors,
138
+ intervalSeconds: 300,
139
+ timeoutMs: 30_000,
140
+ headers: new Headers({ 'User-Agent': 'my-monitor/1.0' }),
141
+ });
142
+
143
+ service.start();
144
+ // …or run immediate checks
145
+ await service.checkAll();
146
+ await service.checkNow('dbpedia');
147
+
148
+ const observations = await store.getLatest();
149
+ for (const [identifier, observation] of observations) {
150
+ console.log(
151
+ `${identifier}: ${observation.success ? 'OK' : 'FAIL'} (${
152
+ observation.responseTimeMs
153
+ }ms)`,
154
+ );
155
+ }
156
+
157
+ service.stop();
158
+ await store.close();
159
+ ```
160
+
161
+ ## Distribution shape
162
+
163
+ Each monitor targets a DCAT `Distribution`. Supply:
164
+
165
+ - `accessUrl` — required. The URL to probe.
166
+ - `mediaType` (optional) — plain content-type (e.g. `application/n-triples`) or DCAT-AP 3.0 IANA URI. Omit for SPARQL endpoints that only serve the protocol.
167
+ - `conformsTo` (optional) — use `https://www.w3.org/TR/sparql11-protocol/` to mark a distribution as a SPARQL endpoint. Required when `accessUrl` doesn’t already imply SPARQL via `mediaType`.
168
+ - `sparqlQuery` (optional) — for SPARQL endpoints. Query type (ASK / SELECT / CONSTRUCT / DESCRIBE) is autodetected. Defaults to a minimal `SELECT` availability probe.
169
+
170
+ Distributions with embedded credentials (`https://user:pass@host/path`) are supported: the credentials are stripped from the URL and forwarded as an `Authorization: Basic` header.
171
+
172
+ ## Database Initialisation
173
+
174
+ `PostgresObservationStore.create()` automatically initializes the database schema:
175
+
176
+ - `observations` table for storing check results
177
+ - `latest_observations` materialized view for efficient queries
178
+ - Required indexes
179
+
180
+ This is idempotent and safe to call on every startup.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":""}
package/dist/cli.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { loadConfig } from 'c12';
4
+ import { createRequire } from 'node:module';
5
+ import { MonitorService } from './service.js';
6
+ import { PostgresObservationStore } from './store.js';
7
+ import { normalizeConfig } from './config.js';
8
+ const require = createRequire(import.meta.url);
9
+ const { version } = require('../package.json');
10
+ async function loadMonitorContext(configFile) {
11
+ const { config: rawConfig } = await loadConfig({
12
+ name: 'distribution-monitor',
13
+ configFile,
14
+ dotenv: true,
15
+ });
16
+ if (!rawConfig) {
17
+ console.error('Error: No configuration found.');
18
+ console.error('Create a distribution-monitor.config.ts file or specify --config.');
19
+ process.exit(1);
20
+ }
21
+ const config = normalizeConfig(rawConfig);
22
+ const databaseUrl = config.databaseUrl ?? process.env.DATABASE_URL;
23
+ if (!databaseUrl) {
24
+ console.error('Error: databaseUrl required (set in config or DATABASE_URL env).');
25
+ process.exit(1);
26
+ }
27
+ if (config.monitors.length === 0) {
28
+ console.error('Error: No monitors configured.');
29
+ process.exit(1);
30
+ }
31
+ const store = await PostgresObservationStore.create(databaseUrl);
32
+ const service = new MonitorService({
33
+ store,
34
+ monitors: config.monitors,
35
+ intervalSeconds: config.intervalSeconds,
36
+ timeoutMs: config.timeoutMs,
37
+ });
38
+ return {
39
+ config: { ...config, databaseUrl },
40
+ store,
41
+ service,
42
+ };
43
+ }
44
+ const program = new Command();
45
+ program
46
+ .name('distribution-monitor')
47
+ .description('Monitor DCAT distributions (SPARQL endpoints and data dumps)')
48
+ .version(version);
49
+ program
50
+ .command('start')
51
+ .description('Start monitoring all configured distributions')
52
+ .option('-c, --config <path>', 'Config file path')
53
+ .action(async (options) => {
54
+ const { config, store, service } = await loadMonitorContext(options.config);
55
+ await service.checkAll();
56
+ service.start();
57
+ console.log(`Monitoring ${config.monitors.length} distribution(s)...`);
58
+ console.log(`Interval: ${config.intervalSeconds ?? 300} seconds`);
59
+ const shutdown = async () => {
60
+ console.log('\nShutting down...');
61
+ service.stop();
62
+ await store.close();
63
+ process.exit(0);
64
+ };
65
+ process.on('SIGINT', shutdown);
66
+ process.on('SIGTERM', shutdown);
67
+ });
68
+ program
69
+ .command('check [identifier]')
70
+ .description('Run immediate check (all monitors or specific one)')
71
+ .option('-c, --config <path>', 'Config file path')
72
+ .action(async (identifier, options) => {
73
+ const { config, store, service } = await loadMonitorContext(options.config);
74
+ try {
75
+ if (identifier) {
76
+ const monitor = config.monitors.find((m) => m.identifier === identifier);
77
+ if (!monitor) {
78
+ console.error(`Error: Monitor '${identifier}' not found.`);
79
+ console.error('Available monitors:', config.monitors.map((m) => m.identifier).join(', '));
80
+ process.exit(1);
81
+ }
82
+ console.log(`Checking ${identifier}...`);
83
+ await service.checkNow(identifier);
84
+ console.log(`Check completed for ${identifier}.`);
85
+ }
86
+ else {
87
+ console.log(`Checking ${config.monitors.length} distribution(s)...`);
88
+ await service.checkAll();
89
+ console.log('All checks completed.');
90
+ }
91
+ }
92
+ finally {
93
+ await store.close();
94
+ }
95
+ });
96
+ program.parse();
@@ -0,0 +1,86 @@
1
+ import type { MonitorConfig } from './types.js';
2
+ /**
3
+ * Shape of a single monitor entry in a configuration file. URLs may be
4
+ * supplied as strings for YAML/JSON ergonomics; they are converted to
5
+ * {@link URL} objects by {@link normalizeConfig}.
6
+ */
7
+ export interface RawMonitorConfig {
8
+ /** Unique identifier for this monitor. */
9
+ identifier: string;
10
+ /** The distribution to probe. */
11
+ distribution: {
12
+ /** Distribution access URL. */
13
+ accessUrl: string | URL;
14
+ /**
15
+ * Plain content-type (e.g. `application/n-triples`) or DCAT-AP 3.0
16
+ * IANA media type URI.
17
+ */
18
+ mediaType?: string;
19
+ /**
20
+ * Specification the distribution conforms to, e.g.
21
+ * `https://www.w3.org/TR/sparql11-protocol/` for SPARQL endpoints.
22
+ */
23
+ conformsTo?: string | URL;
24
+ };
25
+ /**
26
+ * SPARQL query to run against SPARQL-endpoint distributions. Ignored for
27
+ * data-dump distributions.
28
+ */
29
+ sparqlQuery?: string;
30
+ }
31
+ /**
32
+ * Configuration for the distribution monitor.
33
+ */
34
+ export interface DistributionMonitorConfig {
35
+ /** PostgreSQL connection string. */
36
+ databaseUrl?: string;
37
+ /** Polling interval in seconds (default: 300). */
38
+ intervalSeconds?: number;
39
+ /** Request timeout in milliseconds (default: 30 000). */
40
+ timeoutMs?: number;
41
+ /** Monitor definitions. */
42
+ monitors: RawMonitorConfig[];
43
+ }
44
+ /**
45
+ * Type helper for TypeScript config files.
46
+ *
47
+ * @example
48
+ * ```ts
49
+ * // distribution-monitor.config.ts
50
+ * import { defineConfig } from '@lde/distribution-monitor';
51
+ *
52
+ * export default defineConfig({
53
+ * databaseUrl: process.env.DATABASE_URL,
54
+ * intervalSeconds: 300,
55
+ * monitors: [
56
+ * {
57
+ * identifier: 'dbpedia',
58
+ * distribution: {
59
+ * accessUrl: 'https://dbpedia.org/sparql',
60
+ * conformsTo: 'https://www.w3.org/TR/sparql11-protocol/',
61
+ * },
62
+ * sparqlQuery: 'ASK { ?s ?p ?o }',
63
+ * },
64
+ * {
65
+ * identifier: 'my-dump',
66
+ * distribution: {
67
+ * accessUrl: 'https://example.org/data.nt',
68
+ * mediaType: 'application/n-triples',
69
+ * },
70
+ * },
71
+ * ],
72
+ * });
73
+ * ```
74
+ */
75
+ export declare function defineConfig(config: DistributionMonitorConfig): DistributionMonitorConfig;
76
+ /**
77
+ * Normalize config: convert string URLs to URL objects and construct
78
+ * {@link Distribution} instances for each monitor.
79
+ */
80
+ export declare function normalizeConfig(raw: DistributionMonitorConfig): {
81
+ databaseUrl?: string;
82
+ intervalSeconds?: number;
83
+ timeoutMs?: number;
84
+ monitors: MonitorConfig[];
85
+ };
86
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD;;;;GAIG;AACH,MAAM,WAAW,gBAAgB;IAC/B,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,iCAAiC;IACjC,YAAY,EAAE;QACZ,+BAA+B;QAC/B,SAAS,EAAE,MAAM,GAAG,GAAG,CAAC;QACxB;;;WAGG;QACH,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB;;;WAGG;QACH,UAAU,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC;KAC3B,CAAC;IACF;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,yBAAyB;IACxC,oCAAoC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kDAAkD;IAClD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,yDAAyD;IACzD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,2BAA2B;IAC3B,QAAQ,EAAE,gBAAgB,EAAE,CAAC;CAC9B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,yBAAyB,GAChC,yBAAyB,CAE3B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,yBAAyB,GAAG;IAC/D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B,CAWA"}
package/dist/config.js ADDED
@@ -0,0 +1,60 @@
1
+ import { Distribution } from '@lde/dataset';
2
+ /**
3
+ * Type helper for TypeScript config files.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * // distribution-monitor.config.ts
8
+ * import { defineConfig } from '@lde/distribution-monitor';
9
+ *
10
+ * export default defineConfig({
11
+ * databaseUrl: process.env.DATABASE_URL,
12
+ * intervalSeconds: 300,
13
+ * monitors: [
14
+ * {
15
+ * identifier: 'dbpedia',
16
+ * distribution: {
17
+ * accessUrl: 'https://dbpedia.org/sparql',
18
+ * conformsTo: 'https://www.w3.org/TR/sparql11-protocol/',
19
+ * },
20
+ * sparqlQuery: 'ASK { ?s ?p ?o }',
21
+ * },
22
+ * {
23
+ * identifier: 'my-dump',
24
+ * distribution: {
25
+ * accessUrl: 'https://example.org/data.nt',
26
+ * mediaType: 'application/n-triples',
27
+ * },
28
+ * },
29
+ * ],
30
+ * });
31
+ * ```
32
+ */
33
+ export function defineConfig(config) {
34
+ return config;
35
+ }
36
+ /**
37
+ * Normalize config: convert string URLs to URL objects and construct
38
+ * {@link Distribution} instances for each monitor.
39
+ */
40
+ export function normalizeConfig(raw) {
41
+ return {
42
+ databaseUrl: raw.databaseUrl,
43
+ intervalSeconds: raw.intervalSeconds,
44
+ timeoutMs: raw.timeoutMs,
45
+ monitors: raw.monitors.map((m) => ({
46
+ identifier: m.identifier,
47
+ distribution: toDistribution(m.distribution),
48
+ sparqlQuery: m.sparqlQuery,
49
+ })),
50
+ };
51
+ }
52
+ function toDistribution(raw) {
53
+ const accessUrl = typeof raw.accessUrl === 'string' ? new URL(raw.accessUrl) : raw.accessUrl;
54
+ const conformsTo = raw.conformsTo === undefined
55
+ ? undefined
56
+ : typeof raw.conformsTo === 'string'
57
+ ? new URL(raw.conformsTo)
58
+ : raw.conformsTo;
59
+ return new Distribution(accessUrl, raw.mediaType, conformsTo);
60
+ }
@@ -0,0 +1,5 @@
1
+ export type { MonitorConfig, CheckResult, Observation, ObservationStore, } from './types.js';
2
+ export { PostgresObservationStore } from './store.js';
3
+ export { MonitorService, mapProbeResult, type MonitorServiceOptions, type Probe, } from './service.js';
4
+ export { defineConfig, normalizeConfig, type DistributionMonitorConfig, type RawMonitorConfig, } from './config.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,aAAa,EACb,WAAW,EACX,WAAW,EACX,gBAAgB,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AACtD,OAAO,EACL,cAAc,EACd,cAAc,EACd,KAAK,qBAAqB,EAC1B,KAAK,KAAK,GACX,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,YAAY,EACZ,eAAe,EACf,KAAK,yBAAyB,EAC9B,KAAK,gBAAgB,GACtB,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { PostgresObservationStore } from './store.js';
2
+ export { MonitorService, mapProbeResult, } from './service.js';
3
+ export { defineConfig, normalizeConfig, } from './config.js';
@@ -0,0 +1,22 @@
1
+ import type { CheckResult } from './types.js';
2
+ export interface SparqlMonitorOptions {
3
+ /** Timeout in milliseconds for the SPARQL request. */
4
+ timeoutMs?: number;
5
+ /** HTTP headers to include in requests (e.g., User-Agent). */
6
+ headers?: Headers;
7
+ }
8
+ /**
9
+ * Executes SPARQL queries against an endpoint and measures response time.
10
+ */
11
+ export declare class SparqlMonitor {
12
+ private readonly fetcher;
13
+ private readonly options?;
14
+ constructor(options?: SparqlMonitorOptions);
15
+ /**
16
+ * Execute a SPARQL query against an endpoint and return the result.
17
+ */
18
+ check(endpointUrl: URL, query: string): Promise<CheckResult>;
19
+ private prepareFetcherForUrl;
20
+ private consumeStream;
21
+ }
22
+ //# sourceMappingURL=monitor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"monitor.d.ts","sourceRoot":"","sources":["../src/monitor.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AA8B9C,MAAM,WAAW,oBAAoB;IACnC,sDAAsD;IACtD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8DAA8D;IAC9D,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;GAEG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAwB;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAuB;gBAEpC,OAAO,CAAC,EAAE,oBAAoB;IAQ1C;;OAEG;IACG,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAyClE,OAAO,CAAC,oBAAoB;YAuBd,aAAa;CAW5B"}
@@ -0,0 +1,92 @@
1
+ import { SparqlEndpointFetcher } from 'fetch-sparql-endpoint';
2
+ /**
3
+ * Extract credentials from a URL and convert them to a Basic auth header.
4
+ * Returns a tuple of [URL without credentials, Headers with Authorization].
5
+ */
6
+ function extractUrlCredentials(url, baseHeaders) {
7
+ const headers = new Headers(baseHeaders);
8
+ if (url.username || url.password) {
9
+ const credentials = `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}`;
10
+ headers.set('Authorization', `Basic ${Buffer.from(credentials).toString('base64')}`);
11
+ const cleanUrl = new URL(url.toString());
12
+ cleanUrl.username = '';
13
+ cleanUrl.password = '';
14
+ return [cleanUrl, headers];
15
+ }
16
+ return [url, headers];
17
+ }
18
+ /**
19
+ * Executes SPARQL queries against an endpoint and measures response time.
20
+ */
21
+ export class SparqlMonitor {
22
+ fetcher;
23
+ options;
24
+ constructor(options) {
25
+ this.options = options;
26
+ this.fetcher = new SparqlEndpointFetcher({
27
+ timeout: options?.timeoutMs ?? 30000,
28
+ defaultHeaders: options?.headers,
29
+ });
30
+ }
31
+ /**
32
+ * Execute a SPARQL query against an endpoint and return the result.
33
+ */
34
+ async check(endpointUrl, query) {
35
+ const observedAt = new Date(); // UTC
36
+ const startTime = performance.now();
37
+ const [url, fetcher] = this.prepareFetcherForUrl(endpointUrl);
38
+ try {
39
+ const queryType = fetcher.getQueryType(query);
40
+ switch (queryType) {
41
+ case 'ASK':
42
+ await fetcher.fetchAsk(url, query);
43
+ break;
44
+ case 'SELECT':
45
+ await this.consumeStream(await fetcher.fetchBindings(url, query));
46
+ break;
47
+ case 'CONSTRUCT':
48
+ await this.consumeStream(await fetcher.fetchTriples(url, query));
49
+ break;
50
+ }
51
+ const responseTimeMs = Math.round(performance.now() - startTime);
52
+ return {
53
+ success: true,
54
+ responseTimeMs,
55
+ errorMessage: null,
56
+ observedAt,
57
+ };
58
+ }
59
+ catch (error) {
60
+ const responseTimeMs = Math.round(performance.now() - startTime);
61
+ const errorMessage = error instanceof Error ? error.message : String(error);
62
+ return {
63
+ success: false,
64
+ responseTimeMs,
65
+ errorMessage,
66
+ observedAt,
67
+ };
68
+ }
69
+ }
70
+ prepareFetcherForUrl(endpointUrl) {
71
+ const [url, headers] = extractUrlCredentials(endpointUrl, this.options?.headers);
72
+ const hasCredentials = headers.has('Authorization') &&
73
+ !this.options?.headers?.has('Authorization');
74
+ if (!hasCredentials) {
75
+ return [url.toString(), this.fetcher];
76
+ }
77
+ const fetcher = new SparqlEndpointFetcher({
78
+ timeout: this.options?.timeoutMs ?? 30000,
79
+ defaultHeaders: headers,
80
+ });
81
+ return [url.toString(), fetcher];
82
+ }
83
+ async consumeStream(stream) {
84
+ return new Promise((resolve, reject) => {
85
+ stream.on('data', () => {
86
+ // Just consume the data
87
+ });
88
+ stream.on('end', () => resolve());
89
+ stream.on('error', reject);
90
+ });
91
+ }
92
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Observations table — maps to SOSA Observation concept.
3
+ */
4
+ export declare const observations: import("drizzle-orm/pg-core").PgTableWithColumns<{
5
+ name: "observations";
6
+ schema: undefined;
7
+ columns: {
8
+ id: import("drizzle-orm/pg-core").PgBuildColumn<"observations", import("drizzle-orm/pg-core").SetHasDefault<import("drizzle-orm/pg-core").SetIsPrimaryKey<import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgUUIDBuilder>>>, {
9
+ name: string;
10
+ tableName: "observations";
11
+ dataType: "string uuid";
12
+ data: string;
13
+ driverParam: string;
14
+ notNull: true;
15
+ hasDefault: true;
16
+ isPrimaryKey: false;
17
+ isAutoincrement: false;
18
+ hasRuntimeDefault: false;
19
+ enumValues: undefined;
20
+ identity: undefined;
21
+ generated: undefined;
22
+ }>;
23
+ observedAt: import("drizzle-orm/pg-core").PgBuildColumn<"observations", import("drizzle-orm/pg-core").SetHasDefault<import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTimestampBuilder>>, {
24
+ name: string;
25
+ tableName: "observations";
26
+ dataType: "object date";
27
+ data: Date;
28
+ driverParam: string;
29
+ notNull: true;
30
+ hasDefault: true;
31
+ isPrimaryKey: false;
32
+ isAutoincrement: false;
33
+ hasRuntimeDefault: false;
34
+ enumValues: undefined;
35
+ identity: undefined;
36
+ generated: undefined;
37
+ }>;
38
+ monitor: import("drizzle-orm/pg-core").PgBuildColumn<"observations", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>>, {
39
+ name: string;
40
+ tableName: "observations";
41
+ dataType: "string";
42
+ data: string;
43
+ driverParam: string;
44
+ notNull: true;
45
+ hasDefault: false;
46
+ isPrimaryKey: false;
47
+ isAutoincrement: false;
48
+ hasRuntimeDefault: false;
49
+ enumValues: undefined;
50
+ identity: undefined;
51
+ generated: undefined;
52
+ }>;
53
+ success: import("drizzle-orm/pg-core").PgBuildColumn<"observations", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgBooleanBuilder>, {
54
+ name: string;
55
+ tableName: "observations";
56
+ dataType: "boolean";
57
+ data: boolean;
58
+ driverParam: boolean;
59
+ notNull: true;
60
+ hasDefault: false;
61
+ isPrimaryKey: false;
62
+ isAutoincrement: false;
63
+ hasRuntimeDefault: false;
64
+ enumValues: undefined;
65
+ identity: undefined;
66
+ generated: undefined;
67
+ }>;
68
+ responseTimeMs: import("drizzle-orm/pg-core").PgBuildColumn<"observations", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgIntegerBuilder>, {
69
+ name: string;
70
+ tableName: "observations";
71
+ dataType: "number int32";
72
+ data: number;
73
+ driverParam: string | number;
74
+ notNull: true;
75
+ hasDefault: false;
76
+ isPrimaryKey: false;
77
+ isAutoincrement: false;
78
+ hasRuntimeDefault: false;
79
+ enumValues: undefined;
80
+ identity: undefined;
81
+ generated: undefined;
82
+ }>;
83
+ errorMessage: import("drizzle-orm/pg-core").PgBuildColumn<"observations", import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>, {
84
+ name: string;
85
+ tableName: "observations";
86
+ dataType: "string";
87
+ data: string;
88
+ driverParam: string;
89
+ notNull: false;
90
+ hasDefault: false;
91
+ isPrimaryKey: false;
92
+ isAutoincrement: false;
93
+ hasRuntimeDefault: false;
94
+ enumValues: undefined;
95
+ identity: undefined;
96
+ generated: undefined;
97
+ }>;
98
+ };
99
+ dialect: "pg";
100
+ }>;
101
+ /**
102
+ * SQL for refreshing the materialized view.
103
+ */
104
+ export declare const refreshLatestObservationsViewSql: import("drizzle-orm").SQL<unknown>;
105
+ /**
106
+ * Materialized view for the latest observation per monitor.
107
+ */
108
+ export declare const latestObservations: import("drizzle-orm/pg-core").PgMaterializedViewWithSelection<"latest_observations", false, {
109
+ id: import("drizzle-orm/pg-core").PgBuildColumn<"latest_observations", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgUUIDBuilder>, {
110
+ name: string;
111
+ tableName: "latest_observations";
112
+ dataType: "string uuid";
113
+ data: string;
114
+ driverParam: string;
115
+ notNull: true;
116
+ hasDefault: false;
117
+ isPrimaryKey: false;
118
+ isAutoincrement: false;
119
+ hasRuntimeDefault: false;
120
+ enumValues: undefined;
121
+ identity: undefined;
122
+ generated: undefined;
123
+ }>;
124
+ monitor: import("drizzle-orm/pg-core").PgBuildColumn<"latest_observations", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>>, {
125
+ name: string;
126
+ tableName: "latest_observations";
127
+ dataType: "string";
128
+ data: string;
129
+ driverParam: string;
130
+ notNull: true;
131
+ hasDefault: false;
132
+ isPrimaryKey: false;
133
+ isAutoincrement: false;
134
+ hasRuntimeDefault: false;
135
+ enumValues: undefined;
136
+ identity: undefined;
137
+ generated: undefined;
138
+ }>;
139
+ observedAt: import("drizzle-orm/pg-core").PgBuildColumn<"latest_observations", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgTimestampBuilder>, {
140
+ name: string;
141
+ tableName: "latest_observations";
142
+ dataType: "object date";
143
+ data: Date;
144
+ driverParam: string;
145
+ notNull: true;
146
+ hasDefault: false;
147
+ isPrimaryKey: false;
148
+ isAutoincrement: false;
149
+ hasRuntimeDefault: false;
150
+ enumValues: undefined;
151
+ identity: undefined;
152
+ generated: undefined;
153
+ }>;
154
+ success: import("drizzle-orm/pg-core").PgBuildColumn<"latest_observations", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgBooleanBuilder>, {
155
+ name: string;
156
+ tableName: "latest_observations";
157
+ dataType: "boolean";
158
+ data: boolean;
159
+ driverParam: boolean;
160
+ notNull: true;
161
+ hasDefault: false;
162
+ isPrimaryKey: false;
163
+ isAutoincrement: false;
164
+ hasRuntimeDefault: false;
165
+ enumValues: undefined;
166
+ identity: undefined;
167
+ generated: undefined;
168
+ }>;
169
+ responseTimeMs: import("drizzle-orm/pg-core").PgBuildColumn<"latest_observations", import("drizzle-orm/pg-core").SetNotNull<import("drizzle-orm/pg-core").PgIntegerBuilder>, {
170
+ name: string;
171
+ tableName: "latest_observations";
172
+ dataType: "number int32";
173
+ data: number;
174
+ driverParam: string | number;
175
+ notNull: true;
176
+ hasDefault: false;
177
+ isPrimaryKey: false;
178
+ isAutoincrement: false;
179
+ hasRuntimeDefault: false;
180
+ enumValues: undefined;
181
+ identity: undefined;
182
+ generated: undefined;
183
+ }>;
184
+ errorMessage: import("drizzle-orm/pg-core").PgBuildColumn<"latest_observations", import("drizzle-orm/pg-core").PgTextBuilder<[string, ...string[]]>, {
185
+ name: string;
186
+ tableName: "latest_observations";
187
+ dataType: "string";
188
+ data: string;
189
+ driverParam: string;
190
+ notNull: false;
191
+ hasDefault: false;
192
+ isPrimaryKey: false;
193
+ isAutoincrement: false;
194
+ hasRuntimeDefault: false;
195
+ enumValues: undefined;
196
+ identity: undefined;
197
+ generated: undefined;
198
+ }>;
199
+ }>;
200
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAqBA;;GAEG;AACH,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAexB,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,gCAAgC,oCAE5C,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAO7B,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,36 @@
1
+ import { boolean, index, integer, pgMaterializedView, pgTable, text, timestamp, uuid, } from 'drizzle-orm/pg-core';
2
+ import { sql } from 'drizzle-orm';
3
+ const columns = {
4
+ id: uuid('id').notNull(),
5
+ monitor: text('monitor').notNull(),
6
+ observedAt: timestamp('observed_at', { mode: 'date' }).notNull(),
7
+ success: boolean('success').notNull(),
8
+ responseTimeMs: integer('response_time_ms').notNull(),
9
+ errorMessage: text('error_message'),
10
+ };
11
+ /**
12
+ * Observations table — maps to SOSA Observation concept.
13
+ */
14
+ export const observations = pgTable('observations', {
15
+ ...columns,
16
+ id: columns.id.primaryKey().defaultRandom(),
17
+ observedAt: columns.observedAt.defaultNow(),
18
+ }, (table) => [
19
+ index('observations_monitor_idx').on(table.monitor),
20
+ index('observations_observed_at_idx').on(table.observedAt),
21
+ index('observations_monitor_observed_at_idx').on(table.monitor, sql `${table.observedAt} DESC`),
22
+ ]);
23
+ /**
24
+ * SQL for refreshing the materialized view.
25
+ */
26
+ export const refreshLatestObservationsViewSql = sql `
27
+ REFRESH MATERIALIZED VIEW CONCURRENTLY latest_observations
28
+ `;
29
+ /**
30
+ * Materialized view for the latest observation per monitor.
31
+ */
32
+ export const latestObservations = pgMaterializedView('latest_observations', columns).as(sql `
33
+ SELECT DISTINCT ON (monitor) *
34
+ FROM ${observations}
35
+ ORDER BY monitor, observed_at DESC
36
+ `);
@@ -0,0 +1,72 @@
1
+ import { probe, type ProbeResultType } from '@lde/distribution-probe';
2
+ import type { CheckResult, MonitorConfig, ObservationStore } from './types.js';
3
+ /**
4
+ * Function signature for a probe. Matches `probe()` from
5
+ * `@lde/distribution-probe`; injectable so tests can stub it.
6
+ */
7
+ export type Probe = typeof probe;
8
+ export interface MonitorServiceOptions {
9
+ /** Store for persisting observations. */
10
+ store: ObservationStore;
11
+ /** Monitor configurations. */
12
+ monitors: MonitorConfig[];
13
+ /** Polling interval in seconds (default: 300). */
14
+ intervalSeconds?: number;
15
+ /** Request timeout in milliseconds passed to the probe (default: 30 000). */
16
+ timeoutMs?: number;
17
+ /** HTTP headers forwarded to every probe request (e.g. User-Agent). */
18
+ headers?: Headers;
19
+ /**
20
+ * Override the probe function. Mostly useful for tests; defaults to
21
+ * {@link probe} from `@lde/distribution-probe`.
22
+ */
23
+ probe?: Probe;
24
+ }
25
+ /**
26
+ * Orchestrates monitoring of multiple DCAT distributions.
27
+ */
28
+ export declare class MonitorService {
29
+ private readonly store;
30
+ private readonly probe;
31
+ private readonly configs;
32
+ private readonly intervalSeconds;
33
+ private readonly timeoutMs;
34
+ private readonly headers?;
35
+ private job;
36
+ constructor(options: MonitorServiceOptions);
37
+ /**
38
+ * Perform an immediate check for a monitor.
39
+ */
40
+ checkNow(identifier: string): Promise<void>;
41
+ /**
42
+ * Perform an immediate check for all monitors.
43
+ */
44
+ checkAll(): Promise<void>;
45
+ /**
46
+ * Start monitoring all configured distributions.
47
+ */
48
+ start(): void;
49
+ /**
50
+ * Stop monitoring.
51
+ */
52
+ stop(): void;
53
+ /**
54
+ * Check whether monitoring is running.
55
+ */
56
+ isRunning(): boolean;
57
+ /**
58
+ * Convert seconds to a cron expression.
59
+ */
60
+ private secondsToCron;
61
+ private performCheck;
62
+ private refreshView;
63
+ }
64
+ /**
65
+ * Collapse a {@link ProbeResultType} into a {@link CheckResult}. Network
66
+ * errors become `success: false` with the network error message; HTTP or
67
+ * body-validation failures become `success: false` with the probe's
68
+ * failureReason (falling back to joined warnings or the HTTP status) as the
69
+ * error message; everything else is `success: true`.
70
+ */
71
+ export declare function mapProbeResult(result: ProbeResultType, observedAt: Date): CheckResult;
72
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,EAEL,KAAK,eAAe,EAErB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAE/E;;;GAGG;AACH,MAAM,MAAM,KAAK,GAAG,OAAO,KAAK,CAAC;AAEjC,MAAM,WAAW,qBAAqB;IACpC,yCAAyC;IACzC,KAAK,EAAE,gBAAgB,CAAC;IACxB,8BAA8B;IAC9B,QAAQ,EAAE,aAAa,EAAE,CAAC;IAC1B,kDAAkD;IAClD,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6EAA6E;IAC7E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,KAAK,CAAC,EAAE,KAAK,CAAC;CACf;AAID;;GAEG;AACH,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAmB;IACzC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAQ;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAkB;IAC1C,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAU;IACnC,OAAO,CAAC,GAAG,CAAwB;gBAEvB,OAAO,EAAE,qBAAqB;IAS1C;;OAEG;IACG,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASjD;;OAEG;IACG,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAK/B;;OAEG;IACH,KAAK,IAAI,IAAI;IAQb;;OAEG;IACH,IAAI,IAAI,IAAI;IAOZ;;OAEG;IACH,SAAS,IAAI,OAAO;IAIpB;;OAEG;IACH,OAAO,CAAC,aAAa;YAYP,YAAY;YAWZ,WAAW;CAO1B;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,eAAe,EACvB,UAAU,EAAE,IAAI,GACf,WAAW,CA+Bb"}
@@ -0,0 +1,134 @@
1
+ import { CronJob } from 'cron';
2
+ import { probe, NetworkError, } from '@lde/distribution-probe';
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+ /**
5
+ * Orchestrates monitoring of multiple DCAT distributions.
6
+ */
7
+ export class MonitorService {
8
+ store;
9
+ probe;
10
+ configs;
11
+ intervalSeconds;
12
+ timeoutMs;
13
+ headers;
14
+ job = null;
15
+ constructor(options) {
16
+ this.store = options.store;
17
+ this.probe = options.probe ?? probe;
18
+ this.configs = options.monitors;
19
+ this.intervalSeconds = options.intervalSeconds ?? 300;
20
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
21
+ this.headers = options.headers;
22
+ }
23
+ /**
24
+ * Perform an immediate check for a monitor.
25
+ */
26
+ async checkNow(identifier) {
27
+ const config = this.configs.find((c) => c.identifier === identifier);
28
+ if (!config) {
29
+ throw new Error(`Monitor not found: ${identifier}`);
30
+ }
31
+ await this.performCheck(config);
32
+ await this.refreshView();
33
+ }
34
+ /**
35
+ * Perform an immediate check for all monitors.
36
+ */
37
+ async checkAll() {
38
+ await Promise.all(this.configs.map((config) => this.performCheck(config)));
39
+ await this.refreshView();
40
+ }
41
+ /**
42
+ * Start monitoring all configured distributions.
43
+ */
44
+ start() {
45
+ if (!this.job) {
46
+ const cronExpression = this.secondsToCron(this.intervalSeconds);
47
+ this.job = new CronJob(cronExpression, () => this.checkAll());
48
+ this.job.start();
49
+ }
50
+ }
51
+ /**
52
+ * Stop monitoring.
53
+ */
54
+ stop() {
55
+ if (this.job) {
56
+ this.job.stop();
57
+ this.job = null;
58
+ }
59
+ }
60
+ /**
61
+ * Check whether monitoring is running.
62
+ */
63
+ isRunning() {
64
+ return this.job !== null;
65
+ }
66
+ /**
67
+ * Convert seconds to a cron expression.
68
+ */
69
+ secondsToCron(seconds) {
70
+ if (seconds < 60) {
71
+ return `*/${seconds} * * * * *`;
72
+ }
73
+ const minutes = Math.floor(seconds / 60);
74
+ if (minutes < 60) {
75
+ return `0 */${minutes} * * * *`;
76
+ }
77
+ const hours = Math.floor(minutes / 60);
78
+ return `0 0 */${hours} * * *`;
79
+ }
80
+ async performCheck(config) {
81
+ const observedAt = new Date();
82
+ const options = { timeoutMs: this.timeoutMs };
83
+ if (this.headers)
84
+ options.headers = this.headers;
85
+ if (config.sparqlQuery)
86
+ options.sparqlQuery = config.sparqlQuery;
87
+ const result = await this.probe(config.distribution, options);
88
+ const checkResult = mapProbeResult(result, observedAt);
89
+ await this.store.store({ monitor: config.identifier, ...checkResult });
90
+ }
91
+ async refreshView() {
92
+ try {
93
+ await this.store.refreshLatestObservationsView();
94
+ }
95
+ catch {
96
+ // View refresh failure is not critical
97
+ }
98
+ }
99
+ }
100
+ /**
101
+ * Collapse a {@link ProbeResultType} into a {@link CheckResult}. Network
102
+ * errors become `success: false` with the network error message; HTTP or
103
+ * body-validation failures become `success: false` with the probe's
104
+ * failureReason (falling back to joined warnings or the HTTP status) as the
105
+ * error message; everything else is `success: true`.
106
+ */
107
+ export function mapProbeResult(result, observedAt) {
108
+ if (result instanceof NetworkError) {
109
+ return {
110
+ success: false,
111
+ responseTimeMs: result.responseTimeMs,
112
+ errorMessage: result.message,
113
+ observedAt,
114
+ };
115
+ }
116
+ if (result.isSuccess()) {
117
+ return {
118
+ success: true,
119
+ responseTimeMs: result.responseTimeMs,
120
+ errorMessage: null,
121
+ observedAt,
122
+ };
123
+ }
124
+ const errorMessage = result.failureReason ??
125
+ (result.warnings.length > 0
126
+ ? result.warnings.join('; ')
127
+ : `HTTP ${result.statusCode} ${result.statusText}`);
128
+ return {
129
+ success: false,
130
+ responseTimeMs: result.responseTimeMs,
131
+ errorMessage,
132
+ observedAt,
133
+ };
134
+ }
@@ -0,0 +1,23 @@
1
+ import type { ObservationStore, Observation } from './types.js';
2
+ /**
3
+ * PostgreSQL implementation of the ObservationStore interface.
4
+ */
5
+ export declare class PostgresObservationStore implements ObservationStore {
6
+ private db;
7
+ private constructor();
8
+ /**
9
+ * Create a new store and initialize the database schema.
10
+ *
11
+ * Uses drizzle-kit's generateMigration to create schema from code.
12
+ * This approach works around a bug in pushSchema where the execute()
13
+ * return format doesn't match what drizzle-kit expects.
14
+ * See: https://github.com/drizzle-team/drizzle-orm/issues/5293
15
+ */
16
+ static create(connectionString: string): Promise<PostgresObservationStore>;
17
+ close(): Promise<void>;
18
+ getLatest(): Promise<Map<string, Observation>>;
19
+ get(id: string): Promise<Observation | null>;
20
+ store(observation: Omit<Observation, 'id'>): Promise<Observation>;
21
+ refreshLatestObservationsView(): Promise<void>;
22
+ }
23
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAKhE;;GAEG;AACH,qBAAa,wBAAyB,YAAW,gBAAgB;IAC/D,OAAO,CAAC,EAAE,CAAqB;IAE/B,OAAO;IAIP;;;;;;;OAOG;WACU,MAAM,CACjB,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,wBAAwB,CAAC;IAwC9B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAKtB,SAAS,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAK9C,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC;IAS5C,KAAK,CAAC,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC;IAQjE,6BAA6B,IAAI,OAAO,CAAC,IAAI,CAAC;CAGrD"}
package/dist/store.js ADDED
@@ -0,0 +1,84 @@
1
+ import { drizzle } from 'drizzle-orm/postgres-js';
2
+ import { eq, sql } from 'drizzle-orm';
3
+ import * as schema from './schema.js';
4
+ const { observations, latestObservations, refreshLatestObservationsViewSql } = schema;
5
+ /**
6
+ * PostgreSQL implementation of the ObservationStore interface.
7
+ */
8
+ export class PostgresObservationStore {
9
+ db;
10
+ constructor(connectionString) {
11
+ this.db = drizzle(connectionString);
12
+ }
13
+ /**
14
+ * Create a new store and initialize the database schema.
15
+ *
16
+ * Uses drizzle-kit's generateMigration to create schema from code.
17
+ * This approach works around a bug in pushSchema where the execute()
18
+ * return format doesn't match what drizzle-kit expects.
19
+ * See: https://github.com/drizzle-team/drizzle-orm/issues/5293
20
+ */
21
+ static async create(connectionString) {
22
+ const store = new PostgresObservationStore(connectionString);
23
+ const { generateDrizzleJson, generateMigration } = await import('drizzle-kit/api-postgres');
24
+ // Generate migration from empty state to our schema
25
+ const empty = await generateDrizzleJson({});
26
+ const target = await generateDrizzleJson(schema, empty.id);
27
+ const migration = await generateMigration(empty, target);
28
+ // Execute each statement, ignoring "already exists" errors for idempotency
29
+ for (const statement of migration) {
30
+ try {
31
+ await store.db.execute(sql.raw(statement));
32
+ }
33
+ catch (error) {
34
+ // Check both direct error and cause for "already exists"
35
+ const isAlreadyExists = (e) => {
36
+ if (!(e instanceof Error))
37
+ return false;
38
+ if (e.message.includes('already exists'))
39
+ return true;
40
+ if ('cause' in e)
41
+ return isAlreadyExists(e.cause);
42
+ return false;
43
+ };
44
+ if (!isAlreadyExists(error)) {
45
+ throw error;
46
+ }
47
+ }
48
+ }
49
+ // Create unique index on materialized view for CONCURRENTLY refresh
50
+ try {
51
+ await store.db.execute(sql `CREATE UNIQUE INDEX latest_observations_monitor_idx ON latest_observations (monitor)`);
52
+ }
53
+ catch {
54
+ // Index may already exist
55
+ }
56
+ return store;
57
+ }
58
+ async close() {
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ await this.db.$client.end();
61
+ }
62
+ async getLatest() {
63
+ const rows = await this.db.select().from(latestObservations);
64
+ return new Map(rows.map((row) => [row.monitor, row]));
65
+ }
66
+ async get(id) {
67
+ const rows = await this.db
68
+ .select()
69
+ .from(observations)
70
+ .where(eq(observations.id, id))
71
+ .limit(1);
72
+ return rows[0] ?? null;
73
+ }
74
+ async store(observation) {
75
+ const rows = await this.db
76
+ .insert(observations)
77
+ .values(observation)
78
+ .returning();
79
+ return rows[0];
80
+ }
81
+ async refreshLatestObservationsView() {
82
+ await this.db.execute(refreshLatestObservationsViewSql);
83
+ }
84
+ }
@@ -0,0 +1,71 @@
1
+ import { Distribution } from '@lde/dataset';
2
+ /**
3
+ * Configuration for a single monitor.
4
+ *
5
+ * Monitors target any DCAT {@link Distribution}: a SPARQL endpoint (in which
6
+ * case `sparqlQuery` is used for the probe) or a data dump (in which case
7
+ * `sparqlQuery` is ignored and the distribution is fetched with HEAD/GET).
8
+ */
9
+ export interface MonitorConfig {
10
+ /** Unique identifier for this monitor. */
11
+ identifier: string;
12
+ /** The DCAT distribution to probe. */
13
+ distribution: Distribution;
14
+ /**
15
+ * SPARQL query to run against the endpoint. Only meaningful when the
16
+ * distribution is a SPARQL endpoint. Defaults to a minimal availability
17
+ * probe (`SELECT * { ?s ?p ?o } LIMIT 1`).
18
+ */
19
+ sparqlQuery?: string;
20
+ }
21
+ /**
22
+ * Result of a single check against a distribution.
23
+ */
24
+ export interface CheckResult {
25
+ /** Whether the distribution responded successfully. */
26
+ success: boolean;
27
+ /** Response time in milliseconds. */
28
+ responseTimeMs: number;
29
+ /** Error message if the check failed. */
30
+ errorMessage: string | null;
31
+ /** Timestamp when the response was received (UTC). */
32
+ observedAt: Date;
33
+ }
34
+ /**
35
+ * Observation record from the database.
36
+ */
37
+ export interface Observation {
38
+ id: string;
39
+ monitor: string;
40
+ observedAt: Date;
41
+ success: boolean;
42
+ responseTimeMs: number;
43
+ errorMessage: string | null;
44
+ }
45
+ /**
46
+ * Store interface for persisting observations.
47
+ */
48
+ export interface ObservationStore {
49
+ /**
50
+ * Get the latest observation for each identifier.
51
+ * Returns a map keyed by identifier.
52
+ */
53
+ getLatest(): Promise<Map<string, Observation>>;
54
+ /**
55
+ * Get a specific observation by ID.
56
+ */
57
+ get(id: string): Promise<Observation | null>;
58
+ /**
59
+ * Save a new observation.
60
+ */
61
+ store(observation: Omit<Observation, 'id'>): Promise<Observation>;
62
+ /**
63
+ * Refresh the latest_observations materialized view.
64
+ */
65
+ refreshLatestObservationsView(): Promise<void>;
66
+ /**
67
+ * Close the database connection.
68
+ */
69
+ close(): Promise<void>;
70
+ }
71
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C;;;;;;GAMG;AACH,MAAM,WAAW,aAAa;IAC5B,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAC;IACnB,sCAAsC;IACtC,YAAY,EAAE,YAAY,CAAC;IAC3B;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,uDAAuD;IACvD,OAAO,EAAE,OAAO,CAAC;IACjB,qCAAqC;IACrC,cAAc,EAAE,MAAM,CAAC;IACvB,yCAAyC;IACzC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,sDAAsD;IACtD,UAAU,EAAE,IAAI,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,IAAI,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;IACjB,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,SAAS,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC,CAAC;IAE/C;;OAEG;IACH,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;IAE7C;;OAEG;IACH,KAAK,CAAC,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG,OAAO,CAAC,WAAW,CAAC,CAAC;IAElE;;OAEG;IACH,6BAA6B,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/C;;OAEG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@lde/distribution-monitor",
3
+ "version": "0.1.1",
4
+ "description": "Monitor DCAT distributions (SPARQL endpoints and data dumps) with periodic probes",
5
+ "repository": {
6
+ "url": "git+https://github.com/ldelements/lde.git",
7
+ "directory": "packages/distribution-monitor"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "exports": {
12
+ "./package.json": "./package.json",
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "development": "./src/index.ts",
17
+ "default": "./dist/index.js"
18
+ }
19
+ },
20
+ "main": "./dist/index.js",
21
+ "module": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "bin": {
24
+ "distribution-monitor": "dist/cli.js"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "!**/*.tsbuildinfo"
29
+ ],
30
+ "dependencies": {
31
+ "@lde/dataset": "0.7.3",
32
+ "@lde/distribution-probe": "0.1.2",
33
+ "c12": "^3.3.4",
34
+ "commander": "^14.0.3",
35
+ "cron": "^4.1.0",
36
+ "drizzle-kit": "1.0.0-beta.20",
37
+ "drizzle-orm": "1.0.0-beta.22-41a7d21",
38
+ "postgres": "^3.4.9",
39
+ "tslib": "^2.3.0"
40
+ },
41
+ "devDependencies": {
42
+ "@testcontainers/postgresql": "^11.14.0"
43
+ }
44
+ }