@joystyk/ops 0.0.0 → 0.2.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.
- package/CHANGELOG.md +16 -0
- package/README.md +32 -2
- package/dist/backup.d.ts +36 -0
- package/dist/backup.js +100 -0
- package/dist/backup.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +5 -0
- package/dist/metrics.d.ts +21 -0
- package/dist/metrics.js +96 -0
- package/dist/metrics.js.map +1 -0
- package/dist/network.d.ts +2 -0
- package/dist/network.js +27 -0
- package/dist/network.js.map +1 -0
- package/dist/schedule.d.ts +68 -0
- package/dist/schedule.js +126 -0
- package/dist/schedule.js.map +1 -0
- package/package.json +40 -11
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @joystyk/ops
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bb095c7: Share canonical IPv4 and IPv6 /64 rate-limit keys across Node services. Keep room
|
|
8
|
+
initialization diagnostics on the server behind a client-safe reference, and expire old
|
|
9
|
+
message-rate strikes. Extend hidden-information conformance checks with transition-aware
|
|
10
|
+
perturbations so private events remain testable when recipients gain cards.
|
|
11
|
+
|
|
12
|
+
## 0.1.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- Prepare the first compiled library release: ESM and API declarations for every public entry point, runtime assets, and checks against installed npm tarballs. Games and applications remain private.
|
package/README.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @joystyk/ops
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node operations helpers: Prometheus counters and gauges, database backup rotation, and validated runtime configuration. Run metrics on a private listener and use fixed label vocabularies.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
`rateLimitKey(address)` gives Node services one canonical network limit key: an IPv4 address,
|
|
6
|
+
or the first 64 bits of an IPv6 address. IPv4-mapped IPv6 forms share the IPv4 key. Apply it
|
|
7
|
+
after validating the socket peer and trusted forwarding headers; it does not establish proxy
|
|
8
|
+
trust. Keep the key in memory or hash it before storage, and never log it.
|
|
9
|
+
|
|
10
|
+
## npm package
|
|
11
|
+
|
|
12
|
+
The first `0.1.0` release is being prepared; it has not been published. Once available:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install @joystyk/ops
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires Node 24.15 or later. This package runs on the server.
|
|
19
|
+
|
|
20
|
+
| Import | Format |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `@joystyk/ops` | ES module and TypeScript declarations |
|
|
23
|
+
|
|
24
|
+
Only the entries listed above are public API. Installed packages contain compiled ESM,
|
|
25
|
+
type declarations and runtime assets. Repository-relative links describe the Joystyk
|
|
26
|
+
workspace and are not needed to use the npm package.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { createMetrics } from '@joystyk/ops';
|
|
30
|
+
|
|
31
|
+
const metrics = createMetrics();
|
|
32
|
+
const requests = metrics.counter('requests_total', 'Requests served');
|
|
33
|
+
requests.inc({ status: 'ok' });
|
|
34
|
+
console.log(metrics.render());
|
|
35
|
+
```
|
package/dist/backup.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface Keep {
|
|
2
|
+
/** The newest copies to keep, whatever their age. At least 1. */
|
|
3
|
+
readonly newest: number;
|
|
4
|
+
/** Also keep the newest copy of each of this many most recent UTC days. */
|
|
5
|
+
readonly daily: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_KEEP: Keep;
|
|
8
|
+
/** 2026-10-08T03:15:00.123Z → 20261008T031500Z: sortable, and safe in a file name. */
|
|
9
|
+
export declare const stampOf: (date: Date) => string;
|
|
10
|
+
/** The prefix a database's backups share: `rooms` for /data/rooms.sqlite. */
|
|
11
|
+
export declare const backupPrefix: (database: string) => string;
|
|
12
|
+
export declare const backupName: (database: string, date: Date) => string;
|
|
13
|
+
/**
|
|
14
|
+
* The backups to delete among `names` (file names in the backup folder). Only names this
|
|
15
|
+
* module writes for `prefix` count; anything else in the folder is left alone.
|
|
16
|
+
*/
|
|
17
|
+
export declare function backupsToPrune(names: readonly string[], prefix: string, keep: Keep): string[];
|
|
18
|
+
export type BackupResult = {
|
|
19
|
+
readonly event: 'backup_written';
|
|
20
|
+
readonly file: string;
|
|
21
|
+
readonly bytes: number;
|
|
22
|
+
readonly pruned: number;
|
|
23
|
+
readonly ms: number;
|
|
24
|
+
} | {
|
|
25
|
+
readonly event: 'backup_skipped';
|
|
26
|
+
readonly reason: 'no database';
|
|
27
|
+
readonly database: string;
|
|
28
|
+
};
|
|
29
|
+
export interface BackupOptions {
|
|
30
|
+
readonly keep?: Keep;
|
|
31
|
+
/** A missing database is skipped instead of failing (the content service's, before its first run). */
|
|
32
|
+
readonly ifExists?: boolean;
|
|
33
|
+
readonly now?: () => Date;
|
|
34
|
+
}
|
|
35
|
+
/** Copies `source` into `dir`, checks the copy and prunes old ones. Throws when the backup fails. */
|
|
36
|
+
export declare function backupDatabase(source: string, dir: string, options?: BackupOptions): Promise<BackupResult>;
|
package/dist/backup.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
3
|
+
import { DatabaseSync, backup } from "node:sqlite";
|
|
4
|
+
//#region src/backup.ts
|
|
5
|
+
var DEFAULT_KEEP = {
|
|
6
|
+
newest: 14,
|
|
7
|
+
daily: 14
|
|
8
|
+
};
|
|
9
|
+
var STAMP = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
|
|
10
|
+
var PARTIAL = ".partial";
|
|
11
|
+
/** A `.partial` file older than this belongs to a backup that was cut short. */
|
|
12
|
+
var STALE_PARTIAL_MS = 36e5;
|
|
13
|
+
/** 2026-10-08T03:15:00.123Z → 20261008T031500Z: sortable, and safe in a file name. */
|
|
14
|
+
var stampOf = (date) => date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[-:]/g, "");
|
|
15
|
+
/** The prefix a database's backups share: `rooms` for /data/rooms.sqlite. */
|
|
16
|
+
var backupPrefix = (database) => basename(database, extname(database));
|
|
17
|
+
var backupName = (database, date) => `${backupPrefix(database)}-${stampOf(date)}.sqlite`;
|
|
18
|
+
/**
|
|
19
|
+
* The backups to delete among `names` (file names in the backup folder). Only names this
|
|
20
|
+
* module writes for `prefix` count; anything else in the folder is left alone.
|
|
21
|
+
*/
|
|
22
|
+
function backupsToPrune(names, prefix, keep) {
|
|
23
|
+
const ours = names.filter((name) => name.startsWith(`${prefix}-`) && name.endsWith(".sqlite")).map((name) => ({
|
|
24
|
+
name,
|
|
25
|
+
stamp: name.slice(prefix.length + 1, -7)
|
|
26
|
+
})).filter((b) => STAMP.test(b.stamp)).sort((a, b) => a.stamp < b.stamp ? 1 : a.stamp > b.stamp ? -1 : 0);
|
|
27
|
+
const kept = new Set(ours.slice(0, Math.max(0, keep.newest)).map((b) => b.name));
|
|
28
|
+
const days = /* @__PURE__ */ new Set();
|
|
29
|
+
for (const b of ours) {
|
|
30
|
+
const day = b.stamp.slice(0, 8);
|
|
31
|
+
if (days.has(day)) continue;
|
|
32
|
+
if (days.size >= Math.max(0, keep.daily)) break;
|
|
33
|
+
days.add(day);
|
|
34
|
+
kept.add(b.name);
|
|
35
|
+
}
|
|
36
|
+
return ours.filter((b) => !kept.has(b.name)).map((b) => b.name);
|
|
37
|
+
}
|
|
38
|
+
/** Copies `source` into `dir`, checks the copy and prunes old ones. Throws when the backup fails. */
|
|
39
|
+
async function backupDatabase(source, dir, options = {}) {
|
|
40
|
+
const keep = options.keep ?? DEFAULT_KEEP;
|
|
41
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
42
|
+
if (!Number.isInteger(keep.newest) || !Number.isInteger(keep.daily) || keep.newest < 1 || keep.daily < 0) throw new Error("keep.newest must be an integer of at least 1, and keep.daily at least 0");
|
|
43
|
+
if (!existsSync(source)) {
|
|
44
|
+
if (options.ifExists === true) return {
|
|
45
|
+
event: "backup_skipped",
|
|
46
|
+
reason: "no database",
|
|
47
|
+
database: resolve(source)
|
|
48
|
+
};
|
|
49
|
+
throw new Error(`the database ${source} does not exist`);
|
|
50
|
+
}
|
|
51
|
+
const started = performance.now();
|
|
52
|
+
const prefix = backupPrefix(source);
|
|
53
|
+
mkdirSync(dir, { recursive: true });
|
|
54
|
+
removeStalePartials(dir, prefix, now().getTime());
|
|
55
|
+
const target = join(dir, backupName(source, now()));
|
|
56
|
+
const partial = `${target}${PARTIAL}`;
|
|
57
|
+
if (existsSync(target)) throw new Error(`${target} exists; backups are never overwritten`);
|
|
58
|
+
try {
|
|
59
|
+
const db = new DatabaseSync(source, { readOnly: true });
|
|
60
|
+
try {
|
|
61
|
+
await backup(db, partial);
|
|
62
|
+
} finally {
|
|
63
|
+
db.close();
|
|
64
|
+
}
|
|
65
|
+
const copy = new DatabaseSync(partial);
|
|
66
|
+
try {
|
|
67
|
+
copy.exec("PRAGMA journal_mode = DELETE");
|
|
68
|
+
const row = copy.prepare("PRAGMA integrity_check").get();
|
|
69
|
+
if (row?.integrity_check !== "ok") throw new Error(`the backup failed its integrity check: ${JSON.stringify(row)}`);
|
|
70
|
+
} finally {
|
|
71
|
+
copy.close();
|
|
72
|
+
}
|
|
73
|
+
linkSync(partial, target);
|
|
74
|
+
} finally {
|
|
75
|
+
rmSync(partial, { force: true });
|
|
76
|
+
}
|
|
77
|
+
const pruned = backupsToPrune(readdirSync(dir), prefix, keep);
|
|
78
|
+
for (const name of pruned) rmSync(join(dir, name), { force: true });
|
|
79
|
+
return {
|
|
80
|
+
event: "backup_written",
|
|
81
|
+
file: resolve(target),
|
|
82
|
+
bytes: statSync(target).size,
|
|
83
|
+
pruned: pruned.length,
|
|
84
|
+
ms: Math.round(performance.now() - started)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Deletes `.partial` files this module left behind when a process stopped mid-backup. */
|
|
88
|
+
function removeStalePartials(dir, prefix, now) {
|
|
89
|
+
for (const name of readdirSync(dir)) {
|
|
90
|
+
if (!name.startsWith(`${prefix}-`) || !name.endsWith(`.sqlite${PARTIAL}`)) continue;
|
|
91
|
+
const path = join(dir, name);
|
|
92
|
+
try {
|
|
93
|
+
if (now - statSync(path).mtimeMs > STALE_PARTIAL_MS) rmSync(path, { force: true });
|
|
94
|
+
} catch {}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
//#endregion
|
|
98
|
+
export { DEFAULT_KEEP, backupDatabase, backupName, backupPrefix, backupsToPrune, stampOf };
|
|
99
|
+
|
|
100
|
+
//# sourceMappingURL=backup.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backup.js","names":[],"sources":["../src/backup.ts"],"sourcesContent":["// Online backup of a live SQLite database (room-server.md section 6.6; Greed's\n// scripts/backup.ts). Safe while the service writes: node:sqlite's backup() copies a consistent\n// snapshot page by page. The copy is written under a `.partial` name, checked with\n// PRAGMA integrity_check, then linked into place, so a backup cut short (a container stopping)\n// never leaves a file that looks complete, and an existing file is never overwritten. Old copies\n// are pruned: the newest `keep.newest`, plus the newest copy of each of the last `keep.daily`\n// UTC days.\n//\n// This file imports only Node built-ins: every image copies it next to\n// scripts/sqlite-backup.ts, the command deploy/backup.sh runs, without the rest of the package.\nimport { existsSync, linkSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';\nimport { basename, extname, join, resolve } from 'node:path';\nimport { backup, DatabaseSync } from 'node:sqlite';\n\nexport interface Keep {\n /** The newest copies to keep, whatever their age. At least 1. */\n readonly newest: number;\n /** Also keep the newest copy of each of this many most recent UTC days. */\n readonly daily: number;\n}\n\nexport const DEFAULT_KEEP: Keep = { newest: 14, daily: 14 };\n\nconst STAMP = /^(\\d{4})(\\d{2})(\\d{2})T(\\d{2})(\\d{2})(\\d{2})Z$/;\nconst PARTIAL = '.partial';\n/** A `.partial` file older than this belongs to a backup that was cut short. */\nconst STALE_PARTIAL_MS = 60 * 60_000;\n\n/** 2026-10-08T03:15:00.123Z → 20261008T031500Z: sortable, and safe in a file name. */\nexport const stampOf = (date: Date): string =>\n date\n .toISOString()\n .replace(/\\.\\d{3}Z$/, 'Z')\n .replace(/[-:]/g, '');\n\n/** The prefix a database's backups share: `rooms` for /data/rooms.sqlite. */\nexport const backupPrefix = (database: string): string => basename(database, extname(database));\n\nexport const backupName = (database: string, date: Date): string => `${backupPrefix(database)}-${stampOf(date)}.sqlite`;\n\n/**\n * The backups to delete among `names` (file names in the backup folder). Only names this\n * module writes for `prefix` count; anything else in the folder is left alone.\n */\nexport function backupsToPrune(names: readonly string[], prefix: string, keep: Keep): string[] {\n const ours = names\n .filter((name) => name.startsWith(`${prefix}-`) && name.endsWith('.sqlite'))\n .map((name) => ({ name, stamp: name.slice(prefix.length + 1, -'.sqlite'.length) }))\n .filter((b) => STAMP.test(b.stamp))\n .sort((a, b) => (a.stamp < b.stamp ? 1 : a.stamp > b.stamp ? -1 : 0));\n const kept = new Set(ours.slice(0, Math.max(0, keep.newest)).map((b) => b.name));\n const days = new Set<string>();\n for (const b of ours) {\n const day = b.stamp.slice(0, 8);\n if (days.has(day)) continue;\n if (days.size >= Math.max(0, keep.daily)) break;\n days.add(day);\n kept.add(b.name);\n }\n return ours.filter((b) => !kept.has(b.name)).map((b) => b.name);\n}\n\nexport type BackupResult =\n | {\n readonly event: 'backup_written';\n readonly file: string;\n readonly bytes: number;\n readonly pruned: number;\n readonly ms: number;\n }\n | { readonly event: 'backup_skipped'; readonly reason: 'no database'; readonly database: string };\n\nexport interface BackupOptions {\n readonly keep?: Keep;\n /** A missing database is skipped instead of failing (the content service's, before its first run). */\n readonly ifExists?: boolean;\n readonly now?: () => Date;\n}\n\n/** Copies `source` into `dir`, checks the copy and prunes old ones. Throws when the backup fails. */\nexport async function backupDatabase(source: string, dir: string, options: BackupOptions = {}): Promise<BackupResult> {\n const keep = options.keep ?? DEFAULT_KEEP;\n const now = options.now ?? (() => new Date());\n if (!Number.isInteger(keep.newest) || !Number.isInteger(keep.daily) || keep.newest < 1 || keep.daily < 0) {\n throw new Error('keep.newest must be an integer of at least 1, and keep.daily at least 0');\n }\n if (!existsSync(source)) {\n if (options.ifExists === true) return { event: 'backup_skipped', reason: 'no database', database: resolve(source) };\n throw new Error(`the database ${source} does not exist`);\n }\n const started = performance.now();\n const prefix = backupPrefix(source);\n mkdirSync(dir, { recursive: true });\n removeStalePartials(dir, prefix, now().getTime());\n const target = join(dir, backupName(source, now()));\n const partial = `${target}${PARTIAL}`;\n if (existsSync(target)) throw new Error(`${target} exists; backups are never overwritten`);\n\n try {\n const db = new DatabaseSync(source, { readOnly: true });\n try {\n await backup(db, partial);\n } finally {\n db.close();\n }\n // The copy inherits WAL mode; switch it to a rollback journal so it stays one self-contained file.\n const copy = new DatabaseSync(partial);\n try {\n copy.exec('PRAGMA journal_mode = DELETE');\n const row = copy.prepare('PRAGMA integrity_check').get() as { integrity_check?: unknown } | undefined;\n if (row?.integrity_check !== 'ok')\n throw new Error(`the backup failed its integrity check: ${JSON.stringify(row)}`);\n } finally {\n copy.close();\n }\n // A hard link fails if the target appeared meanwhile, where a rename would replace it.\n linkSync(partial, target);\n } finally {\n rmSync(partial, { force: true });\n }\n\n const pruned = backupsToPrune(readdirSync(dir), prefix, keep);\n for (const name of pruned) rmSync(join(dir, name), { force: true });\n return {\n event: 'backup_written',\n file: resolve(target),\n bytes: statSync(target).size,\n pruned: pruned.length,\n ms: Math.round(performance.now() - started),\n };\n}\n\n/** Deletes `.partial` files this module left behind when a process stopped mid-backup. */\nfunction removeStalePartials(dir: string, prefix: string, now: number): void {\n for (const name of readdirSync(dir)) {\n if (!name.startsWith(`${prefix}-`) || !name.endsWith(`.sqlite${PARTIAL}`)) continue;\n const path = join(dir, name);\n try {\n if (now - statSync(path).mtimeMs > STALE_PARTIAL_MS) rmSync(path, { force: true });\n } catch {\n // Gone already.\n }\n }\n}\n"],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { backupDatabase, backupName, backupPrefix, backupsToPrune, DEFAULT_KEEP, stampOf, type BackupOptions, type BackupResult, type Keep, } from './backup.js';
|
|
2
|
+
export { closeServer, createMetrics, METRICS_CONTENT_TYPE, startMetricsServer, type Counter, type Labels, type Metrics, } from './metrics.js';
|
|
3
|
+
export { rateLimitKey } from './network.js';
|
|
4
|
+
export { describeSchedule, nextBackupAt, parseOpsEnv, scheduleBackups, startSchedule, systemTimers, type BackupReport, type BackupSchedule, type BackupSettings, type MetricsSettings, type OpsSettings, type ScheduleBackupsOptions, type ScheduledDatabase, type Scheduler, type Timers, } from './schedule.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { DEFAULT_KEEP, backupDatabase, backupName, backupPrefix, backupsToPrune, stampOf } from "./backup.js";
|
|
2
|
+
import { METRICS_CONTENT_TYPE, closeServer, createMetrics, startMetricsServer } from "./metrics.js";
|
|
3
|
+
import { rateLimitKey } from "./network.js";
|
|
4
|
+
import { describeSchedule, nextBackupAt, parseOpsEnv, scheduleBackups, startSchedule, systemTimers } from "./schedule.js";
|
|
5
|
+
export { DEFAULT_KEEP, METRICS_CONTENT_TYPE, backupDatabase, backupName, backupPrefix, backupsToPrune, closeServer, createMetrics, describeSchedule, nextBackupAt, parseOpsEnv, rateLimitKey, scheduleBackups, stampOf, startMetricsServer, startSchedule, systemTimers };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Server } from 'node:http';
|
|
2
|
+
export type Labels = Readonly<Record<string, string>>;
|
|
3
|
+
export interface Counter {
|
|
4
|
+
inc(labels?: Labels, by?: number): void;
|
|
5
|
+
/** The current value for these labels (tests, and gauges derived from counters). */
|
|
6
|
+
value(labels?: Labels): number;
|
|
7
|
+
}
|
|
8
|
+
export interface Metrics {
|
|
9
|
+
/** A counter; `seed` lists label sets reported as 0 before their first increment. */
|
|
10
|
+
counter(name: string, help: string, seed?: readonly Labels[]): Counter;
|
|
11
|
+
/** A gauge read at scrape time. */
|
|
12
|
+
gauge(name: string, help: string, read: () => number): void;
|
|
13
|
+
/** Every metric, in registration order, in Prometheus text format. */
|
|
14
|
+
render(): string;
|
|
15
|
+
}
|
|
16
|
+
export declare const createMetrics: () => Metrics;
|
|
17
|
+
export declare const METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
|
|
18
|
+
/** Serves `GET /metrics` on its own port. Anything else is 404. Resolves once listening. */
|
|
19
|
+
export declare const startMetricsServer: (host: string, port: number, render: () => string) => Promise<Server>;
|
|
20
|
+
/** Closes a listener and resolves when it has closed. */
|
|
21
|
+
export declare const closeServer: (server: Server) => Promise<void>;
|
package/dist/metrics.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
//#region src/metrics.ts
|
|
3
|
+
var NAME = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
|
|
4
|
+
var LABEL = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
5
|
+
var escapeLabel = (value) => value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, "\\\"");
|
|
6
|
+
var escapeHelp = (value) => value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
|
|
7
|
+
var labelKey = (labels) => {
|
|
8
|
+
const names = Object.keys(labels).sort();
|
|
9
|
+
for (const name of names) if (!LABEL.test(name)) throw new Error(`invalid label name ${name}`);
|
|
10
|
+
return names.length === 0 ? "" : `{${names.map((name) => `${name}="${escapeLabel(labels[name] ?? "")}"`).join(",")}}`;
|
|
11
|
+
};
|
|
12
|
+
var formatValue = (value) => Number.isFinite(value) ? String(value) : Number.isNaN(value) ? "NaN" : value > 0 ? "+Inf" : "-Inf";
|
|
13
|
+
var createMetrics = () => {
|
|
14
|
+
const sections = [];
|
|
15
|
+
const names = /* @__PURE__ */ new Set();
|
|
16
|
+
const register = (name) => {
|
|
17
|
+
if (!NAME.test(name)) throw new Error(`invalid metric name ${name}`);
|
|
18
|
+
if (names.has(name)) throw new Error(`${name} is registered twice`);
|
|
19
|
+
names.add(name);
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
counter: (name, help, seed = []) => {
|
|
23
|
+
register(name);
|
|
24
|
+
const values = /* @__PURE__ */ new Map();
|
|
25
|
+
for (const labels of seed) values.set(labelKey(labels), 0);
|
|
26
|
+
if (seed.length === 0) values.set("", 0);
|
|
27
|
+
sections.push(() => [
|
|
28
|
+
`# HELP ${name} ${escapeHelp(help)}`,
|
|
29
|
+
`# TYPE ${name} counter`,
|
|
30
|
+
...[...values].map(([key, value]) => `${name}${key} ${formatValue(value)}`)
|
|
31
|
+
].join("\n"));
|
|
32
|
+
return {
|
|
33
|
+
inc: (labels = {}, by = 1) => {
|
|
34
|
+
if (!(by >= 0)) throw new Error("a counter only goes up");
|
|
35
|
+
const key = labelKey(labels);
|
|
36
|
+
values.set(key, (values.get(key) ?? 0) + by);
|
|
37
|
+
if (key !== "" && seed.length === 0 && values.get("") === 0) values.delete("");
|
|
38
|
+
},
|
|
39
|
+
value: (labels = {}) => values.get(labelKey(labels)) ?? 0
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
gauge: (name, help, read) => {
|
|
43
|
+
register(name);
|
|
44
|
+
sections.push(() => {
|
|
45
|
+
let value;
|
|
46
|
+
try {
|
|
47
|
+
value = read();
|
|
48
|
+
} catch {
|
|
49
|
+
value = NaN;
|
|
50
|
+
}
|
|
51
|
+
return [
|
|
52
|
+
`# HELP ${name} ${escapeHelp(help)}`,
|
|
53
|
+
`# TYPE ${name} gauge`,
|
|
54
|
+
`${name} ${formatValue(value)}`
|
|
55
|
+
].join("\n");
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
render: () => `${sections.map((section) => section()).join("\n")}\n`
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
var METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
|
|
62
|
+
/** Serves `GET /metrics` on its own port. Anything else is 404. Resolves once listening. */
|
|
63
|
+
var startMetricsServer = (host, port, render) => new Promise((resolve, reject) => {
|
|
64
|
+
const server = createServer((request, response) => {
|
|
65
|
+
if (new URL(request.url ?? "/", "http://metrics.invalid").pathname !== "/metrics") {
|
|
66
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }).end("Not found\n");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
70
|
+
response.writeHead(405, {
|
|
71
|
+
allow: "GET, HEAD",
|
|
72
|
+
"content-type": "text/plain; charset=utf-8"
|
|
73
|
+
}).end("Method not allowed\n");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const body = render();
|
|
77
|
+
response.writeHead(200, {
|
|
78
|
+
"content-type": METRICS_CONTENT_TYPE,
|
|
79
|
+
"cache-control": "no-store"
|
|
80
|
+
}).end(request.method === "HEAD" ? void 0 : body);
|
|
81
|
+
});
|
|
82
|
+
server.once("error", reject);
|
|
83
|
+
server.listen(port, host, () => {
|
|
84
|
+
server.off("error", reject);
|
|
85
|
+
resolve(server);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
/** Closes a listener and resolves when it has closed. */
|
|
89
|
+
var closeServer = (server) => new Promise((resolve) => {
|
|
90
|
+
server.close(() => resolve());
|
|
91
|
+
server.closeAllConnections();
|
|
92
|
+
});
|
|
93
|
+
//#endregion
|
|
94
|
+
export { METRICS_CONTENT_TYPE, closeServer, createMetrics, startMetricsServer };
|
|
95
|
+
|
|
96
|
+
//# sourceMappingURL=metrics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metrics.js","names":[],"sources":["../src/metrics.ts"],"sourcesContent":["// A minimal Prometheus text-format registry (exposition format 0.0.4) and its listener. The\n// services count a handful of things (DEPLOY.md, \"Metrics\"); a client library would be more\n// than they need. Labels carry only fixed vocabularies, such as a status class, a game ID or a\n// log event name: never an address, a token, a room code or a name, because every visitor may\n// be a child (D-26).\n//\n// The listener runs on its own port (METRICS_PORT), never the service's public one. Traefik\n// routes only the port its labels name, and Compose publishes nothing, so the metrics are\n// reachable only from containers on the same Docker network.\nimport { createServer, type Server } from 'node:http';\n\nexport type Labels = Readonly<Record<string, string>>;\n\nexport interface Counter {\n inc(labels?: Labels, by?: number): void;\n /** The current value for these labels (tests, and gauges derived from counters). */\n value(labels?: Labels): number;\n}\n\nexport interface Metrics {\n /** A counter; `seed` lists label sets reported as 0 before their first increment. */\n counter(name: string, help: string, seed?: readonly Labels[]): Counter;\n /** A gauge read at scrape time. */\n gauge(name: string, help: string, read: () => number): void;\n /** Every metric, in registration order, in Prometheus text format. */\n render(): string;\n}\n\nconst NAME = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;\nconst LABEL = /^[a-zA-Z_][a-zA-Z0-9_]*$/;\n\nconst escapeLabel = (value: string): string => value.replace(/\\\\/g, '\\\\\\\\').replace(/\\n/g, '\\\\n').replace(/\"/g, '\\\\\"');\nconst escapeHelp = (value: string): string => value.replace(/\\\\/g, '\\\\\\\\').replace(/\\n/g, '\\\\n');\n\nconst labelKey = (labels: Labels): string => {\n const names = Object.keys(labels).sort();\n for (const name of names) if (!LABEL.test(name)) throw new Error(`invalid label name ${name}`);\n return names.length === 0 ? '' : `{${names.map((name) => `${name}=\"${escapeLabel(labels[name] ?? '')}\"`).join(',')}}`;\n};\n\nconst formatValue = (value: number): string =>\n Number.isFinite(value) ? String(value) : Number.isNaN(value) ? 'NaN' : value > 0 ? '+Inf' : '-Inf';\n\nexport const createMetrics = (): Metrics => {\n const sections: (() => string)[] = [];\n const names = new Set<string>();\n const register = (name: string): void => {\n if (!NAME.test(name)) throw new Error(`invalid metric name ${name}`);\n if (names.has(name)) throw new Error(`${name} is registered twice`);\n names.add(name);\n };\n\n return {\n counter: (name, help, seed = []) => {\n register(name);\n const values = new Map<string, number>();\n for (const labels of seed) values.set(labelKey(labels), 0);\n if (seed.length === 0) values.set('', 0);\n sections.push(() =>\n [\n `# HELP ${name} ${escapeHelp(help)}`,\n `# TYPE ${name} counter`,\n ...[...values].map(([key, value]) => `${name}${key} ${formatValue(value)}`),\n ].join('\\n'),\n );\n return {\n inc: (labels = {}, by = 1) => {\n if (!(by >= 0)) throw new Error('a counter only goes up');\n const key = labelKey(labels);\n values.set(key, (values.get(key) ?? 0) + by);\n // An unlabelled zero is only a placeholder once labelled values exist.\n if (key !== '' && seed.length === 0 && values.get('') === 0) values.delete('');\n },\n value: (labels = {}) => values.get(labelKey(labels)) ?? 0,\n };\n },\n gauge: (name, help, read) => {\n register(name);\n sections.push(() => {\n let value: number;\n try {\n value = read();\n } catch {\n value = Number.NaN;\n }\n return [`# HELP ${name} ${escapeHelp(help)}`, `# TYPE ${name} gauge`, `${name} ${formatValue(value)}`].join(\n '\\n',\n );\n });\n },\n render: () => `${sections.map((section) => section()).join('\\n')}\\n`,\n };\n};\n\nexport const METRICS_CONTENT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';\n\n/** Serves `GET /metrics` on its own port. Anything else is 404. Resolves once listening. */\nexport const startMetricsServer = (host: string, port: number, render: () => string): Promise<Server> =>\n new Promise((resolve, reject) => {\n const server = createServer((request, response) => {\n const path = new URL(request.url ?? '/', 'http://metrics.invalid').pathname;\n if (path !== '/metrics') {\n response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('Not found\\n');\n return;\n }\n if (request.method !== 'GET' && request.method !== 'HEAD') {\n response\n .writeHead(405, { allow: 'GET, HEAD', 'content-type': 'text/plain; charset=utf-8' })\n .end('Method not allowed\\n');\n return;\n }\n const body = render();\n response\n .writeHead(200, { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' })\n .end(request.method === 'HEAD' ? undefined : body);\n });\n server.once('error', reject);\n server.listen(port, host, () => {\n server.off('error', reject);\n resolve(server);\n });\n });\n\n/** Closes a listener and resolves when it has closed. */\nexport const closeServer = (server: Server): Promise<void> =>\n new Promise((resolve) => {\n server.close(() => resolve());\n server.closeAllConnections();\n });\n"],"mappings":"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"}
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
//#region src/network.ts
|
|
3
|
+
/** Canonical rate-limit key: IPv4 per address, IPv6 per /64, mapped IPv4 as IPv4. */
|
|
4
|
+
function rateLimitKey(address) {
|
|
5
|
+
const family = isIP(address);
|
|
6
|
+
if (family === 4) return address;
|
|
7
|
+
if (family !== 6) return "unknown";
|
|
8
|
+
const [head = "", tail] = new URL(`http://[${address.split("%")[0]}]/`).hostname.slice(1, -1).split("::");
|
|
9
|
+
const left = head === "" ? [] : head.split(":");
|
|
10
|
+
const right = tail === void 0 || tail === "" ? [] : tail.split(":");
|
|
11
|
+
const words = (tail === void 0 ? left : [
|
|
12
|
+
...left,
|
|
13
|
+
...Array(8 - left.length - right.length).fill("0"),
|
|
14
|
+
...right
|
|
15
|
+
]).map((word) => parseInt(word, 16));
|
|
16
|
+
if (words.slice(0, 5).every((word) => word === 0) && words[5] === 65535) return [
|
|
17
|
+
words[6] >>> 8,
|
|
18
|
+
words[6] & 255,
|
|
19
|
+
words[7] >>> 8,
|
|
20
|
+
words[7] & 255
|
|
21
|
+
].join(".");
|
|
22
|
+
return `${words.slice(0, 4).map((word) => word.toString(16)).join(":")}::/64`;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { rateLimitKey };
|
|
26
|
+
|
|
27
|
+
//# sourceMappingURL=network.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"network.js","names":[],"sources":["../src/network.ts"],"sourcesContent":["import { isIP } from 'node:net';\n\n/** Canonical rate-limit key: IPv4 per address, IPv6 per /64, mapped IPv4 as IPv4. */\nexport function rateLimitKey(address: string): string {\n const family = isIP(address);\n if (family === 4) return address;\n if (family !== 6) return 'unknown';\n // WHATWG parsing canonicalizes compressed, mixed-case and dotted-tail forms without IO.\n const host = new URL(`http://[${address.split('%')[0]}]/`).hostname.slice(1, -1);\n const [head = '', tail] = host.split('::');\n const left = head === '' ? [] : head.split(':');\n const right = tail === undefined || tail === '' ? [] : tail.split(':');\n const words = (\n tail === undefined ? left : [...left, ...Array<string>(8 - left.length - right.length).fill('0'), ...right]\n ).map((word) => parseInt(word, 16));\n if (words.slice(0, 5).every((word) => word === 0) && words[5] === 0xffff) {\n return [words[6]! >>> 8, words[6]! & 255, words[7]! >>> 8, words[7]! & 255].join('.');\n }\n return `${words\n .slice(0, 4)\n .map((word) => word.toString(16))\n .join(':')}::/64`;\n}\n"],"mappings":";;;AAGA,SAAgB,aAAa,SAAyB;CACpD,MAAM,SAAS,KAAK,OAAO;CAC3B,IAAI,WAAW,GAAG,OAAO;CACzB,IAAI,WAAW,GAAG,OAAO;CAGzB,MAAM,CAAC,OAAO,IAAI,QADL,IAAI,IAAI,WAAW,QAAQ,MAAM,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,SAAS,MAAM,GAAG,EACnD,CAAA,CAAK,MAAM,IAAI;CACzC,MAAM,OAAO,SAAS,KAAK,CAAC,IAAI,KAAK,MAAM,GAAG;CAC9C,MAAM,QAAQ,SAAS,KAAA,KAAa,SAAS,KAAK,CAAC,IAAI,KAAK,MAAM,GAAG;CACrE,MAAM,SACJ,SAAS,KAAA,IAAY,OAAO;EAAC,GAAG;EAAM,GAAG,MAAc,IAAI,KAAK,SAAS,MAAM,MAAM,CAAC,CAAC,KAAK,GAAG;EAAG,GAAG;CAAK,EAAA,CAC1G,KAAK,SAAS,SAAS,MAAM,EAAE,CAAC;CAClC,IAAI,MAAM,MAAM,GAAG,CAAC,CAAC,CAAC,OAAO,SAAS,SAAS,CAAC,KAAK,MAAM,OAAO,OAChE,OAAO;EAAC,MAAM,OAAQ;EAAG,MAAM,KAAM;EAAK,MAAM,OAAQ;EAAG,MAAM,KAAM;CAAG,CAAC,CAAC,KAAK,GAAG;CAEtF,OAAO,GAAG,MACP,MAAM,GAAG,CAAC,CAAC,CACX,KAAK,SAAS,KAAK,SAAS,EAAE,CAAC,CAAC,CAChC,KAAK,GAAG,EAAE;AACf"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type BackupResult, type Keep } from './backup.js';
|
|
2
|
+
export interface BackupSchedule {
|
|
3
|
+
/** Minutes between backups; divides a day. */
|
|
4
|
+
readonly intervalMinutes: number;
|
|
5
|
+
/** Minutes past each interval boundary, in UTC. */
|
|
6
|
+
readonly offsetMinutes: number;
|
|
7
|
+
}
|
|
8
|
+
/** The first scheduled instant strictly after `now`. */
|
|
9
|
+
export declare const nextBackupAt: (now: number, schedule: BackupSchedule) => number;
|
|
10
|
+
export interface BackupSettings {
|
|
11
|
+
/** Null when BACKUP_INTERVAL_MINUTES is 0: only on-demand backups (deploy/backup.sh). */
|
|
12
|
+
readonly schedule: BackupSchedule | null;
|
|
13
|
+
readonly keep: Keep;
|
|
14
|
+
}
|
|
15
|
+
export interface MetricsSettings {
|
|
16
|
+
readonly host: string;
|
|
17
|
+
readonly port: number;
|
|
18
|
+
}
|
|
19
|
+
export interface OpsSettings {
|
|
20
|
+
readonly backup: BackupSettings;
|
|
21
|
+
/** Null when METRICS_PORT is unset: no metrics listener. */
|
|
22
|
+
readonly metrics: MetricsSettings | null;
|
|
23
|
+
}
|
|
24
|
+
/** Reads the backup and metrics settings from the environment, or lists every problem. */
|
|
25
|
+
export declare const parseOpsEnv: (env: Readonly<Record<string, string | undefined>>) => {
|
|
26
|
+
ok: true;
|
|
27
|
+
settings: OpsSettings;
|
|
28
|
+
} | {
|
|
29
|
+
ok: false;
|
|
30
|
+
issues: string[];
|
|
31
|
+
};
|
|
32
|
+
export declare const describeSchedule: (schedule: BackupSchedule | null) => string;
|
|
33
|
+
export interface Timers {
|
|
34
|
+
now(): number;
|
|
35
|
+
/** Calls `fire` after `ms`; the returned function cancels it. */
|
|
36
|
+
after(ms: number, fire: () => void): () => void;
|
|
37
|
+
}
|
|
38
|
+
export declare const systemTimers: Timers;
|
|
39
|
+
export interface Scheduler {
|
|
40
|
+
/** The next scheduled run (epoch ms), or null once stopped. */
|
|
41
|
+
next(): number | null;
|
|
42
|
+
/** Cancels the timer and waits for a run in progress. */
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/** Runs `run` on the schedule until stopped. `run` must not throw; it reports its own outcome. */
|
|
46
|
+
export declare const startSchedule: (schedule: BackupSchedule, run: () => Promise<void>, timers?: Timers) => Scheduler;
|
|
47
|
+
export interface ScheduledDatabase {
|
|
48
|
+
/** The live database file. */
|
|
49
|
+
readonly file: string;
|
|
50
|
+
/** Where its copies go; default `backups/` beside it. */
|
|
51
|
+
readonly dir?: string;
|
|
52
|
+
/** Skip, rather than fail, while the database doesn't exist yet. */
|
|
53
|
+
readonly ifExists?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export type BackupReport = BackupResult | {
|
|
56
|
+
readonly event: 'backup_failed';
|
|
57
|
+
readonly database: string;
|
|
58
|
+
readonly error: string;
|
|
59
|
+
};
|
|
60
|
+
export interface ScheduleBackupsOptions {
|
|
61
|
+
readonly settings: BackupSettings;
|
|
62
|
+
readonly databases: readonly ScheduledDatabase[];
|
|
63
|
+
/** One call per database per run: the service writes it as a log line in its own format. */
|
|
64
|
+
readonly report: (report: BackupReport) => void;
|
|
65
|
+
readonly timers?: Timers;
|
|
66
|
+
}
|
|
67
|
+
/** Backs up each database on the schedule. Returns null when the schedule is off. */
|
|
68
|
+
export declare const scheduleBackups: (options: ScheduleBackupsOptions) => Scheduler | null;
|
package/dist/schedule.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { DEFAULT_KEEP, backupDatabase } from "./backup.js";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
//#region src/schedule.ts
|
|
5
|
+
/** The first scheduled instant strictly after `now`. */
|
|
6
|
+
var nextBackupAt = (now, schedule) => {
|
|
7
|
+
const period = schedule.intervalMinutes * 6e4;
|
|
8
|
+
const offset = schedule.offsetMinutes * 6e4;
|
|
9
|
+
return (Math.floor((now - offset) / period) + 1) * period + offset;
|
|
10
|
+
};
|
|
11
|
+
var blankToUndefined = (value) => typeof value === "string" && value.trim() === "" ? void 0 : value;
|
|
12
|
+
var optional = (schema) => z.preprocess(blankToUndefined, schema.optional());
|
|
13
|
+
var int = (min, max) => z.coerce.number().int().min(min).max(max);
|
|
14
|
+
var OpsEnv = z.object({
|
|
15
|
+
/** Minutes between scheduled backups; must divide 1440. 0 turns the schedule off. */
|
|
16
|
+
BACKUP_INTERVAL_MINUTES: optional(int(0, 1440).refine((m) => m === 0 || 1440 % m === 0, "must be 0 (off) or divide 1440 (a day), e.g. 60, 360, 1440")).default(1440),
|
|
17
|
+
/** The UTC time the schedule is anchored on, HH:MM. */
|
|
18
|
+
BACKUP_AT: optional(z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "must be a UTC time such as 03:17")).default("03:17"),
|
|
19
|
+
/** The newest backups kept per database. */
|
|
20
|
+
BACKUP_KEEP: optional(int(1, 1e4)).default(DEFAULT_KEEP.newest),
|
|
21
|
+
/** Also keep the newest backup of each of this many most recent UTC days. */
|
|
22
|
+
BACKUP_KEEP_DAILY: optional(int(0, 1e4)).default(DEFAULT_KEEP.daily),
|
|
23
|
+
/** The internal metrics port. Unset: no metrics listener. */
|
|
24
|
+
METRICS_PORT: optional(int(1, 65535)),
|
|
25
|
+
METRICS_HOST: optional(z.string()).default("0.0.0.0")
|
|
26
|
+
});
|
|
27
|
+
/** Reads the backup and metrics settings from the environment, or lists every problem. */
|
|
28
|
+
var parseOpsEnv = (env) => {
|
|
29
|
+
const parsed = OpsEnv.safeParse(env);
|
|
30
|
+
if (!parsed.success) return {
|
|
31
|
+
ok: false,
|
|
32
|
+
issues: parsed.error.issues.map((issue) => `${issue.path.join(".") || "env"}: ${issue.message}`)
|
|
33
|
+
};
|
|
34
|
+
const e = parsed.data;
|
|
35
|
+
const [hours, minutes] = e.BACKUP_AT.split(":").map(Number);
|
|
36
|
+
const anchor = (hours ?? 0) * 60 + (minutes ?? 0);
|
|
37
|
+
return {
|
|
38
|
+
ok: true,
|
|
39
|
+
settings: {
|
|
40
|
+
backup: {
|
|
41
|
+
schedule: e.BACKUP_INTERVAL_MINUTES === 0 ? null : {
|
|
42
|
+
intervalMinutes: e.BACKUP_INTERVAL_MINUTES,
|
|
43
|
+
offsetMinutes: anchor % e.BACKUP_INTERVAL_MINUTES
|
|
44
|
+
},
|
|
45
|
+
keep: {
|
|
46
|
+
newest: e.BACKUP_KEEP,
|
|
47
|
+
daily: e.BACKUP_KEEP_DAILY
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
metrics: e.METRICS_PORT === void 0 ? null : {
|
|
51
|
+
host: e.METRICS_HOST,
|
|
52
|
+
port: e.METRICS_PORT
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
var describeSchedule = (schedule) => {
|
|
58
|
+
if (schedule === null) return "off";
|
|
59
|
+
const hhmm = (m) => `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
|
60
|
+
return schedule.intervalMinutes === 1440 ? `daily at ${hhmm(schedule.offsetMinutes)} UTC` : `every ${String(schedule.intervalMinutes)} min at +${String(schedule.offsetMinutes)} min (UTC)`;
|
|
61
|
+
};
|
|
62
|
+
var systemTimers = {
|
|
63
|
+
now: () => Date.now(),
|
|
64
|
+
after: (ms, fire) => {
|
|
65
|
+
const handle = setTimeout(fire, Math.max(0, ms));
|
|
66
|
+
handle.unref();
|
|
67
|
+
return () => clearTimeout(handle);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
/** Runs `run` on the schedule until stopped. `run` must not throw; it reports its own outcome. */
|
|
71
|
+
var startSchedule = (schedule, run, timers = systemTimers) => {
|
|
72
|
+
let stopped = false;
|
|
73
|
+
let nextAt = null;
|
|
74
|
+
let cancel = null;
|
|
75
|
+
let running = null;
|
|
76
|
+
const arm = () => {
|
|
77
|
+
if (stopped) return;
|
|
78
|
+
const at = nextBackupAt(timers.now(), schedule);
|
|
79
|
+
nextAt = at;
|
|
80
|
+
const wake = () => {
|
|
81
|
+
if (stopped) return;
|
|
82
|
+
if (timers.now() < at) {
|
|
83
|
+
cancel = timers.after(at - timers.now(), wake);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
running = run().finally(() => {
|
|
87
|
+
running = null;
|
|
88
|
+
arm();
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
cancel = timers.after(at - timers.now(), wake);
|
|
92
|
+
};
|
|
93
|
+
arm();
|
|
94
|
+
return {
|
|
95
|
+
next: () => stopped ? null : nextAt,
|
|
96
|
+
stop: async () => {
|
|
97
|
+
stopped = true;
|
|
98
|
+
nextAt = null;
|
|
99
|
+
cancel?.();
|
|
100
|
+
await running;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
/** Backs up each database on the schedule. Returns null when the schedule is off. */
|
|
105
|
+
var scheduleBackups = (options) => {
|
|
106
|
+
const { schedule, keep } = options.settings;
|
|
107
|
+
if (schedule === null) return null;
|
|
108
|
+
return startSchedule(schedule, async () => {
|
|
109
|
+
for (const db of options.databases) try {
|
|
110
|
+
options.report(await backupDatabase(db.file, db.dir ?? join(dirname(db.file), "backups"), {
|
|
111
|
+
keep,
|
|
112
|
+
ifExists: db.ifExists === true
|
|
113
|
+
}));
|
|
114
|
+
} catch (error) {
|
|
115
|
+
options.report({
|
|
116
|
+
event: "backup_failed",
|
|
117
|
+
database: db.file,
|
|
118
|
+
error: error instanceof Error ? error.message : String(error)
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}, options.timers);
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
124
|
+
export { describeSchedule, nextBackupAt, parseOpsEnv, scheduleBackups, startSchedule, systemTimers };
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=schedule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schedule.js","names":[],"sources":["../src/schedule.ts"],"sourcesContent":["// Scheduled backups inside each service's own process (D-62, the same rule as D-56 for the\n// content service): no host cron, no cron daemon and no cron library. A backup runs every\n// BACKUP_INTERVAL_MINUTES at fixed UTC times anchored on BACKUP_AT, so the default (1440 and\n// 03:17) is one backup a night at 03:17 UTC, and 60 gives one at 17 minutes past every hour.\n// Each wait is computed afresh from the wall clock, so the schedule never drifts, and UTC has no\n// daylight saving to skip or repeat an hour. Nothing runs at startup: a container that restarts\n// in a loop must not fill its volume with copies.\nimport { dirname, join } from 'node:path';\nimport * as z from 'zod';\nimport { backupDatabase, DEFAULT_KEEP, type BackupResult, type Keep } from './backup.ts';\n\nexport interface BackupSchedule {\n /** Minutes between backups; divides a day. */\n readonly intervalMinutes: number;\n /** Minutes past each interval boundary, in UTC. */\n readonly offsetMinutes: number;\n}\n\n/** The first scheduled instant strictly after `now`. */\nexport const nextBackupAt = (now: number, schedule: BackupSchedule): number => {\n const period = schedule.intervalMinutes * 60_000;\n const offset = schedule.offsetMinutes * 60_000;\n return (Math.floor((now - offset) / period) + 1) * period + offset;\n};\n\nexport interface BackupSettings {\n /** Null when BACKUP_INTERVAL_MINUTES is 0: only on-demand backups (deploy/backup.sh). */\n readonly schedule: BackupSchedule | null;\n readonly keep: Keep;\n}\n\nexport interface MetricsSettings {\n readonly host: string;\n readonly port: number;\n}\n\nexport interface OpsSettings {\n readonly backup: BackupSettings;\n /** Null when METRICS_PORT is unset: no metrics listener. */\n readonly metrics: MetricsSettings | null;\n}\n\nconst blankToUndefined = (value: unknown) => (typeof value === 'string' && value.trim() === '' ? undefined : value);\nconst optional = <T extends z.ZodType>(schema: T) => z.preprocess(blankToUndefined, schema.optional());\nconst int = (min: number, max: number) => z.coerce.number().int().min(min).max(max);\n\nconst OpsEnv = z.object({\n /** Minutes between scheduled backups; must divide 1440. 0 turns the schedule off. */\n BACKUP_INTERVAL_MINUTES: optional(\n int(0, 1440).refine((m) => m === 0 || 1440 % m === 0, 'must be 0 (off) or divide 1440 (a day), e.g. 60, 360, 1440'),\n ).default(1440),\n /** The UTC time the schedule is anchored on, HH:MM. */\n BACKUP_AT: optional(z.string().regex(/^([01]\\d|2[0-3]):[0-5]\\d$/, 'must be a UTC time such as 03:17')).default(\n '03:17',\n ),\n /** The newest backups kept per database. */\n BACKUP_KEEP: optional(int(1, 10_000)).default(DEFAULT_KEEP.newest),\n /** Also keep the newest backup of each of this many most recent UTC days. */\n BACKUP_KEEP_DAILY: optional(int(0, 10_000)).default(DEFAULT_KEEP.daily),\n /** The internal metrics port. Unset: no metrics listener. */\n METRICS_PORT: optional(int(1, 65_535)),\n METRICS_HOST: optional(z.string()).default('0.0.0.0'),\n});\n\n/** Reads the backup and metrics settings from the environment, or lists every problem. */\nexport const parseOpsEnv = (\n env: Readonly<Record<string, string | undefined>>,\n): { ok: true; settings: OpsSettings } | { ok: false; issues: string[] } => {\n const parsed = OpsEnv.safeParse(env);\n if (!parsed.success)\n return {\n ok: false,\n issues: parsed.error.issues.map((issue) => `${issue.path.join('.') || 'env'}: ${issue.message}`),\n };\n const e = parsed.data;\n const [hours, minutes] = e.BACKUP_AT.split(':').map(Number);\n const anchor = (hours ?? 0) * 60 + (minutes ?? 0);\n return {\n ok: true,\n settings: {\n backup: {\n schedule:\n e.BACKUP_INTERVAL_MINUTES === 0\n ? null\n : { intervalMinutes: e.BACKUP_INTERVAL_MINUTES, offsetMinutes: anchor % e.BACKUP_INTERVAL_MINUTES },\n keep: { newest: e.BACKUP_KEEP, daily: e.BACKUP_KEEP_DAILY },\n },\n metrics: e.METRICS_PORT === undefined ? null : { host: e.METRICS_HOST, port: e.METRICS_PORT },\n },\n };\n};\n\nexport const describeSchedule = (schedule: BackupSchedule | null): string => {\n if (schedule === null) return 'off';\n const hhmm = (m: number) => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;\n return schedule.intervalMinutes === 1440\n ? `daily at ${hhmm(schedule.offsetMinutes)} UTC`\n : `every ${String(schedule.intervalMinutes)} min at +${String(schedule.offsetMinutes)} min (UTC)`;\n};\n\nexport interface Timers {\n now(): number;\n /** Calls `fire` after `ms`; the returned function cancels it. */\n after(ms: number, fire: () => void): () => void;\n}\n\nexport const systemTimers: Timers = {\n now: () => Date.now(),\n after: (ms, fire) => {\n const handle = setTimeout(fire, Math.max(0, ms));\n // The service's own server keeps the process alive; a pending backup must not.\n handle.unref();\n return () => clearTimeout(handle);\n },\n};\n\nexport interface Scheduler {\n /** The next scheduled run (epoch ms), or null once stopped. */\n next(): number | null;\n /** Cancels the timer and waits for a run in progress. */\n stop(): Promise<void>;\n}\n\n/** Runs `run` on the schedule until stopped. `run` must not throw; it reports its own outcome. */\nexport const startSchedule = (\n schedule: BackupSchedule,\n run: () => Promise<void>,\n timers: Timers = systemTimers,\n): Scheduler => {\n let stopped = false;\n let nextAt: number | null = null;\n let cancel: (() => void) | null = null;\n let running: Promise<void> | null = null;\n\n const arm = (): void => {\n if (stopped) return;\n const at = nextBackupAt(timers.now(), schedule);\n nextAt = at;\n const wake = (): void => {\n if (stopped) return;\n // Timers can fire early; wait out the rest rather than running before the scheduled time.\n if (timers.now() < at) {\n cancel = timers.after(at - timers.now(), wake);\n return;\n }\n running = run().finally(() => {\n running = null;\n arm();\n });\n };\n cancel = timers.after(at - timers.now(), wake);\n };\n arm();\n\n return {\n next: () => (stopped ? null : nextAt),\n stop: async () => {\n stopped = true;\n nextAt = null;\n cancel?.();\n await running;\n },\n };\n};\n\nexport interface ScheduledDatabase {\n /** The live database file. */\n readonly file: string;\n /** Where its copies go; default `backups/` beside it. */\n readonly dir?: string;\n /** Skip, rather than fail, while the database doesn't exist yet. */\n readonly ifExists?: boolean;\n}\n\nexport type BackupReport =\n BackupResult | { readonly event: 'backup_failed'; readonly database: string; readonly error: string };\n\nexport interface ScheduleBackupsOptions {\n readonly settings: BackupSettings;\n readonly databases: readonly ScheduledDatabase[];\n /** One call per database per run: the service writes it as a log line in its own format. */\n readonly report: (report: BackupReport) => void;\n readonly timers?: Timers;\n}\n\n/** Backs up each database on the schedule. Returns null when the schedule is off. */\nexport const scheduleBackups = (options: ScheduleBackupsOptions): Scheduler | null => {\n const { schedule, keep } = options.settings;\n if (schedule === null) return null;\n return startSchedule(\n schedule,\n async () => {\n for (const db of options.databases) {\n try {\n options.report(\n await backupDatabase(db.file, db.dir ?? join(dirname(db.file), 'backups'), {\n keep,\n ifExists: db.ifExists === true,\n }),\n );\n } catch (error) {\n options.report({\n event: 'backup_failed',\n database: db.file,\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n },\n options.timers,\n );\n};\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,20 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joystyk/ops",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Node operations helpers: scheduled SQLite backups, internal Prometheus metrics and canonical network rate-limit keys.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"zod": "4.6.5"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "24.10.1",
|
|
19
|
+
"typescript": "5.9.3",
|
|
20
|
+
"vitest": "4.1.11"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"NOTICE",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=24.15.0"
|
|
34
|
+
},
|
|
5
35
|
"license": "MIT",
|
|
6
36
|
"repository": {
|
|
7
37
|
"type": "git",
|
|
8
38
|
"url": "git+https://github.com/joystyk-games/game-libraries.git",
|
|
9
39
|
"directory": "packages/ops"
|
|
10
40
|
},
|
|
11
|
-
"homepage": "https://github.com/joystyk-games/game-libraries",
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"access": "public"
|
|
41
|
+
"homepage": "https://github.com/joystyk-games/game-libraries/tree/main/packages/ops#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/joystyk-games/game-libraries/issues"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"build": "node ../../scripts/build-package.ts"
|
|
19
48
|
}
|
|
20
|
-
}
|
|
49
|
+
}
|