@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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 +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
package/src/db-adapter.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
* Database Adapter — supports SQLite and PostgreSQL
|
|
3
3
|
*
|
|
4
4
|
* SQLite adapter: wraps bun:sqlite (sync)
|
|
5
|
-
* PostgreSQL adapter:
|
|
5
|
+
* PostgreSQL adapter: one Bun.SQL connection in a worker thread, bridged to the
|
|
6
|
+
* sync interface with Atomics.wait (pg-worker.ts)
|
|
6
7
|
*
|
|
7
8
|
* Key design: callers write SQLite-style SQL. PgAdapter auto-translates:
|
|
8
9
|
* - ?1, ?2 → $1, $2
|
|
9
|
-
* - datetime('now') →
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
10
|
+
* - datetime('now'[, offset]) → to_char(... AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')
|
|
11
|
+
* (timestamps stay TEXT on both backends, RFC-039 §4.1)
|
|
12
|
+
* - BLOB → BYTEA
|
|
13
|
+
* - INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY; INTEGER → BIGINT
|
|
12
14
|
* - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
|
|
13
15
|
*/
|
|
14
16
|
|
|
@@ -39,14 +41,32 @@ export interface DbAdapter {
|
|
|
39
41
|
|
|
40
42
|
/** Dialect identifier */
|
|
41
43
|
readonly dialect: "sqlite" | "postgres";
|
|
44
|
+
|
|
45
|
+
/** True when transaction() is all-or-nothing on one connection. */
|
|
46
|
+
readonly atomicTransactions: boolean;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why features that need atomic transactions (scheduler, runtime evidence,
|
|
50
|
+
* side-thread command outbox) must stay off on this adapter, or null when
|
|
51
|
+
* they may run. Gates refuse on anything but null, so an adapter that does
|
|
52
|
+
* not implement this also refuses.
|
|
53
|
+
*/
|
|
54
|
+
readonly transactionalFeaturesRefusal: string | null;
|
|
42
55
|
}
|
|
43
56
|
|
|
57
|
+
/** RFC-039: opt-in for PostgreSQL features whose PG tests do not exist yet (S4). */
|
|
58
|
+
export const PG_EXPERIMENTAL_ENV = "COMMHUB_PG_EXPERIMENTAL";
|
|
59
|
+
|
|
44
60
|
// ════════════════════════════════════════════
|
|
45
61
|
// SQLite Adapter (bun:sqlite, sync)
|
|
46
62
|
// ════════════════════════════════════════════
|
|
47
63
|
|
|
48
64
|
export class SQLiteAdapter implements DbAdapter {
|
|
49
65
|
readonly dialect = "sqlite" as const;
|
|
66
|
+
readonly atomicTransactions = true;
|
|
67
|
+
get transactionalFeaturesRefusal(): string | null {
|
|
68
|
+
return this.atomicTransactions ? null : "transactions on this adapter are not atomic";
|
|
69
|
+
}
|
|
50
70
|
constructor(private readonly rawDb: Database) {}
|
|
51
71
|
|
|
52
72
|
run(sql: string, params?: any[]): QueryResult {
|
|
@@ -70,6 +90,17 @@ export class SQLiteAdapter implements DbAdapter {
|
|
|
70
90
|
}
|
|
71
91
|
|
|
72
92
|
close(): void {
|
|
93
|
+
// #2151: once more than 20 distinct SQL strings have gone through query(),
|
|
94
|
+
// bun:sqlite's close() can no longer really close the connection (measured:
|
|
95
|
+
// close(true) reports "database is locked"), so SQLite never checkpoints and
|
|
96
|
+
// a cleanly stopped Hub left its latest writes in -wal. Checkpoint first,
|
|
97
|
+
// so the .db file alone is complete after a clean stop.
|
|
98
|
+
try {
|
|
99
|
+
const r = this.rawDb.query<{ busy: number; log: number; checkpointed: number }, []>("PRAGMA wal_checkpoint(TRUNCATE)").get();
|
|
100
|
+
if (r && r.busy !== 0) console.warn(`[commhub] WAL checkpoint on close was busy: ${JSON.stringify(r)}`);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
console.warn(`[commhub] WAL checkpoint on close failed: ${(e as Error)?.message ?? e}`);
|
|
103
|
+
}
|
|
73
104
|
this.rawDb.close();
|
|
74
105
|
}
|
|
75
106
|
}
|
|
@@ -78,6 +109,15 @@ export class SQLiteAdapter implements DbAdapter {
|
|
|
78
109
|
// PostgreSQL Adapter (pg Pool, sync bridge)
|
|
79
110
|
// ════════════════════════════════════════════
|
|
80
111
|
|
|
112
|
+
/**
|
|
113
|
+
* PostgreSQL expression for SQLite's `datetime(...)` text: UTC, `YYYY-MM-DD HH:MM:SS`.
|
|
114
|
+
* Timestamps stay TEXT on both backends (RFC-039 §4.1), so a column written by
|
|
115
|
+
* `datetime('now')` compares, sorts and serialises the same way on each.
|
|
116
|
+
*/
|
|
117
|
+
function pgUtcText(expr: string): string {
|
|
118
|
+
return `to_char((${expr}) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')`;
|
|
119
|
+
}
|
|
120
|
+
|
|
81
121
|
/**
|
|
82
122
|
* Translate SQLite-style SQL to PostgreSQL.
|
|
83
123
|
* Called on every query — must be fast (simple regex, no parsing).
|
|
@@ -85,18 +125,20 @@ export class SQLiteAdapter implements DbAdapter {
|
|
|
85
125
|
export function sqliteToPostgres(sql: string): string {
|
|
86
126
|
let s = sql;
|
|
87
127
|
// ── datetime translations (before ?N→$N to handle datetime('now', ?N)) ──
|
|
88
|
-
// datetime('now', ?N) → NOW() + $N
|
|
128
|
+
// datetime('now', ?N) → UTC text of NOW() + $N (param contains "+3600 seconds").
|
|
129
|
+
// The CAST gives PG a type for a parameter it could not otherwise infer.
|
|
89
130
|
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*\?(\d+)\s*\)/gi, (_, n) => {
|
|
90
|
-
return `NOW() + $${n}::INTERVAL
|
|
131
|
+
return pgUtcText(`NOW() + CAST($${n} AS TEXT)::INTERVAL`);
|
|
91
132
|
});
|
|
92
|
-
// datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
|
|
133
|
+
// datetime('now', '+N seconds') → UTC text of NOW() + INTERVAL 'N seconds'
|
|
93
134
|
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*'([^']+)'\s*\)/gi, (_, offset) => {
|
|
94
|
-
return `NOW() + INTERVAL '${offset.replace(/^\+/, "")}'
|
|
135
|
+
return pgUtcText(`NOW() + INTERVAL '${offset.replace(/^\+/, "")}'`);
|
|
95
136
|
});
|
|
96
|
-
// datetime('now') → NOW()
|
|
97
|
-
s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
|
|
98
|
-
//
|
|
99
|
-
s = s.replace(/
|
|
137
|
+
// datetime('now') → UTC text of NOW()
|
|
138
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, pgUtcText("NOW()"));
|
|
139
|
+
// strftime('%Y-%m-%d %H:%M:%f', 'now') → the same text with milliseconds
|
|
140
|
+
s = s.replace(/strftime\s*\(\s*'%Y-%m-%d %H:%M:%f'\s*,\s*'now'\s*\)/gi,
|
|
141
|
+
"to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS.MS')");
|
|
100
142
|
// ── Parameter placeholders ──
|
|
101
143
|
// ?1, ?2 → $1, $2 (positional params)
|
|
102
144
|
s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
|
|
@@ -104,78 +146,171 @@ export function sqliteToPostgres(sql: string): string {
|
|
|
104
146
|
let idx = 0;
|
|
105
147
|
s = s.replace(/\?(?!\d)/g, () => `$${++idx}`);
|
|
106
148
|
// ── DDL translations ──
|
|
107
|
-
// INTEGER PRIMARY KEY AUTOINCREMENT →
|
|
108
|
-
s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "
|
|
149
|
+
// INTEGER PRIMARY KEY AUTOINCREMENT → BIGSERIAL PRIMARY KEY
|
|
150
|
+
s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "BIGSERIAL PRIMARY KEY");
|
|
151
|
+
// INTEGER → BIGINT: SQLite integers are 64-bit, and the Hub stores epoch
|
|
152
|
+
// milliseconds in INTEGER columns (a 32-bit PG integer overflows).
|
|
153
|
+
s = s.replace(/\bINTEGER\b/gi, "BIGINT");
|
|
154
|
+
// REAL → DOUBLE PRECISION: SQLite REAL is 8-byte; PostgreSQL REAL is 4-byte
|
|
155
|
+
// and would silently round telemetry and scores.
|
|
156
|
+
s = s.replace(/\bREAL\b/gi, "DOUBLE PRECISION");
|
|
157
|
+
// BLOB → BYTEA
|
|
158
|
+
s = s.replace(/\bBLOB\b/gi, "BYTEA");
|
|
109
159
|
return s;
|
|
110
160
|
}
|
|
111
161
|
|
|
112
162
|
/**
|
|
113
|
-
*
|
|
163
|
+
* Split a multi-statement script on top-level `;`.
|
|
164
|
+
*
|
|
165
|
+
* A bare `split(";")` cut through SQL comments (db.ts has `;` inside `--`
|
|
166
|
+
* comments) and through plpgsql `$$ … $$` bodies, so the Hub died on its
|
|
167
|
+
* first schema block on PostgreSQL. This skips `--` and `/* *\/` comments
|
|
168
|
+
* (dropping them) and keeps quoted strings, quoted identifiers and
|
|
169
|
+
* dollar-quoted bodies intact.
|
|
170
|
+
*/
|
|
171
|
+
export function splitSqlStatements(sql: string): string[] {
|
|
172
|
+
const out: string[] = [];
|
|
173
|
+
let cur = "";
|
|
174
|
+
let i = 0;
|
|
175
|
+
const n = sql.length;
|
|
176
|
+
while (i < n) {
|
|
177
|
+
const c = sql[i];
|
|
178
|
+
if (c === "-" && sql[i + 1] === "-") {
|
|
179
|
+
while (i < n && sql[i] !== "\n") i++;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (c === "/" && sql[i + 1] === "*") {
|
|
183
|
+
const close = sql.indexOf("*/", i + 2);
|
|
184
|
+
i = close < 0 ? n : close + 2;
|
|
185
|
+
cur += " ";
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (c === "'" || c === '"') {
|
|
189
|
+
let j = i + 1;
|
|
190
|
+
while (j < n) {
|
|
191
|
+
if (sql[j] === c) {
|
|
192
|
+
if (sql[j + 1] === c) { j += 2; continue; }
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
j++;
|
|
196
|
+
}
|
|
197
|
+
cur += sql.slice(i, j + 1);
|
|
198
|
+
i = j + 1;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (c === "$") {
|
|
202
|
+
const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i));
|
|
203
|
+
if (tag) {
|
|
204
|
+
const close = sql.indexOf(tag[0], i + tag[0].length);
|
|
205
|
+
const stop = close < 0 ? n : close + tag[0].length;
|
|
206
|
+
cur += sql.slice(i, stop);
|
|
207
|
+
i = stop;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (c === ";") {
|
|
212
|
+
if (cur.trim()) out.push(cur.trim());
|
|
213
|
+
cur = "";
|
|
214
|
+
i++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
cur += c;
|
|
218
|
+
i++;
|
|
219
|
+
}
|
|
220
|
+
if (cur.trim()) out.push(cur.trim());
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* PostgreSQL adapter: synchronous facade over one long-lived worker thread
|
|
226
|
+
* (RFC-039 S2b).
|
|
114
227
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
228
|
+
* The worker (pg-worker.ts) holds a single reserved Bun.SQL connection. Each
|
|
229
|
+
* call posts one request and blocks on Atomics.wait until the worker answers,
|
|
230
|
+
* so the DbAdapter interface stays synchronous — the same contract bun:sqlite
|
|
231
|
+
* gives the rest of the Hub, including "nothing else runs while a transaction
|
|
232
|
+
* is open". Because every statement uses the same backend connection,
|
|
233
|
+
* BEGIN/COMMIT/ROLLBACK are real transactions.
|
|
234
|
+
*
|
|
235
|
+
* SQLite semantics kept on purpose:
|
|
236
|
+
* - a failed statement inside a transaction does not poison the transaction
|
|
237
|
+
* (each statement inside one runs under a savepoint);
|
|
238
|
+
* - nested transaction() calls become savepoints;
|
|
239
|
+
* - booleans bind as 1/0 and undefined as NULL.
|
|
120
240
|
*/
|
|
121
241
|
export class PgAdapter implements DbAdapter {
|
|
122
242
|
readonly dialect = "postgres" as const;
|
|
123
|
-
|
|
243
|
+
readonly atomicTransactions = true;
|
|
244
|
+
/** Closed by default on PG even with real transactions: explicit opt-in only. */
|
|
245
|
+
get transactionalFeaturesRefusal(): string | null {
|
|
246
|
+
if (!this.atomicTransactions) return "transactions on this adapter are not atomic";
|
|
247
|
+
if (process.env[PG_EXPERIMENTAL_ENV] === "1") return null;
|
|
248
|
+
return `PostgreSQL support is experimental; set ${PG_EXPERIMENTAL_ENV}=1 to enable this feature on it (RFC-039)`;
|
|
249
|
+
}
|
|
250
|
+
private readonly port: MessagePort;
|
|
251
|
+
private readonly flag: Int32Array;
|
|
252
|
+
private readonly worker: import("node:worker_threads").Worker;
|
|
253
|
+
private readonly waitMs: number;
|
|
254
|
+
private txDepth = 0;
|
|
124
255
|
|
|
125
256
|
constructor(connectionString: string) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
257
|
+
const { Worker, MessageChannel } = require("node:worker_threads") as typeof import("node:worker_threads");
|
|
258
|
+
const statementTimeoutMs = Number(process.env.COMMHUB_PG_STATEMENT_TIMEOUT_MS || 30_000);
|
|
259
|
+
// The main thread waits a little longer than PG itself will run a statement.
|
|
260
|
+
this.waitMs = statementTimeoutMs > 0 ? statementTimeoutMs + 5_000 : 300_000;
|
|
261
|
+
const { port1, port2 } = new MessageChannel();
|
|
262
|
+
this.port = port1 as unknown as MessagePort;
|
|
263
|
+
this.flag = new Int32Array(new SharedArrayBuffer(4));
|
|
264
|
+
this.worker = new Worker(new URL("./pg-worker.ts", import.meta.url), {
|
|
265
|
+
workerData: { port: port2, flag: this.flag, url: connectionString, statementTimeoutMs },
|
|
266
|
+
transferList: [port2 as any],
|
|
267
|
+
});
|
|
268
|
+
this.worker.unref();
|
|
269
|
+
(port1 as any).unref?.();
|
|
134
270
|
// Test connection on startup
|
|
135
|
-
const test = this.
|
|
271
|
+
const test = this.request("SELECT 1 as ok");
|
|
136
272
|
if (!test.rows?.[0]?.ok) throw new Error("PostgreSQL connection test failed");
|
|
137
273
|
console.log("[commhub] PostgreSQL connection verified");
|
|
138
274
|
}
|
|
139
275
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
p.query(q,v).then(r=>{
|
|
149
|
-
process.stdout.write(JSON.stringify({rows:r.rows,rowCount:r.rowCount||0}));
|
|
150
|
-
p.end();
|
|
151
|
-
}).catch(e=>{
|
|
152
|
-
process.stderr.write(e.message);
|
|
153
|
-
p.end();
|
|
154
|
-
process.exit(1);
|
|
155
|
-
});
|
|
156
|
-
`;
|
|
157
|
-
const proc = Bun.spawnSync(["node", "--no-warnings", "-e", script], {
|
|
158
|
-
stdout: "pipe", stderr: "pipe",
|
|
159
|
-
});
|
|
160
|
-
if (proc.exitCode !== 0) {
|
|
161
|
-
throw new Error(`PG: ${proc.stderr.toString().trim() || "query failed"}`);
|
|
276
|
+
/** Send one already-translated statement to the worker and block for the answer. */
|
|
277
|
+
private request(pgSql: string, params?: any[]): { rows: any[]; count: number } {
|
|
278
|
+
const { receiveMessageOnPort } = require("node:worker_threads") as typeof import("node:worker_threads");
|
|
279
|
+
const bound = (params ?? []).map(v => v === undefined ? null : typeof v === "boolean" ? (v ? 1 : 0) : v);
|
|
280
|
+
Atomics.store(this.flag, 0, 0);
|
|
281
|
+
(this.port as any).postMessage({ sql: pgSql, params: bound, guard: this.txDepth > 0 });
|
|
282
|
+
if (Atomics.wait(this.flag, 0, 0, this.waitMs) === "timed-out") {
|
|
283
|
+
throw new Error(`PG: no answer from the database worker within ${this.waitMs}ms`);
|
|
162
284
|
}
|
|
163
|
-
|
|
285
|
+
const reply = receiveMessageOnPort(this.port as any)?.message as any;
|
|
286
|
+
if (!reply) throw new Error("PG: database worker signalled without a reply");
|
|
287
|
+
if (!reply.ok) throw new Error(`PG: ${reply.error}`);
|
|
288
|
+
return reply;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private querySync(sql: string, params?: any[]): { rows: any[]; count: number } {
|
|
292
|
+
return this.request(sqliteToPostgres(sql), params);
|
|
164
293
|
}
|
|
165
294
|
|
|
166
295
|
run(sql: string, params?: any[]): QueryResult {
|
|
167
296
|
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return { changes: 0 };
|
|
168
297
|
const result = this.querySync(sql, params);
|
|
169
|
-
return { changes: result.
|
|
298
|
+
return { changes: result.count };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** bun:sqlite also accepts get(sql, [a, b]); a lone array argument is the parameter list. */
|
|
302
|
+
private static bindArgs(params: any[]): any[] | undefined {
|
|
303
|
+
if (params.length === 1 && Array.isArray(params[0])) return params[0];
|
|
304
|
+
return params.length > 0 ? params : undefined;
|
|
170
305
|
}
|
|
171
306
|
|
|
172
307
|
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
173
|
-
const result = this.querySync(sql,
|
|
308
|
+
const result = this.querySync(sql, PgAdapter.bindArgs(params));
|
|
174
309
|
return (result.rows?.[0] as T) ?? null;
|
|
175
310
|
}
|
|
176
311
|
|
|
177
312
|
all<T = any>(sql: string, ...params: any[]): T[] {
|
|
178
|
-
const result = this.querySync(sql,
|
|
313
|
+
const result = this.querySync(sql, PgAdapter.bindArgs(params));
|
|
179
314
|
return (result.rows as T[]) ?? [];
|
|
180
315
|
}
|
|
181
316
|
|
|
@@ -183,9 +318,9 @@ export class PgAdapter implements DbAdapter {
|
|
|
183
318
|
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return;
|
|
184
319
|
const pgSql = sqliteToPostgres(sql);
|
|
185
320
|
// Split multi-statement DDL (CREATE TABLE; CREATE INDEX; ...)
|
|
186
|
-
const stmts = pgSql
|
|
321
|
+
const stmts = splitSqlStatements(pgSql);
|
|
187
322
|
for (const stmt of stmts) {
|
|
188
|
-
try { this.
|
|
323
|
+
try { this.request(stmt); } catch (e: any) {
|
|
189
324
|
// Ignore "already exists" errors for CREATE TABLE/INDEX IF NOT EXISTS
|
|
190
325
|
if (!/already exists/.test(e.message)) throw e;
|
|
191
326
|
}
|
|
@@ -193,29 +328,60 @@ export class PgAdapter implements DbAdapter {
|
|
|
193
328
|
}
|
|
194
329
|
|
|
195
330
|
transaction<T>(fn: () => T): T {
|
|
196
|
-
|
|
197
|
-
|
|
331
|
+
const depth = this.txDepth;
|
|
332
|
+
const savepoint = `anet_tx_${depth}`;
|
|
333
|
+
// Control statements run unguarded: they are what the guard is made of.
|
|
334
|
+
this.txDepth = 0;
|
|
335
|
+
try { this.request(depth === 0 ? "BEGIN" : `SAVEPOINT ${savepoint}`); } finally { this.txDepth = depth; }
|
|
336
|
+
this.txDepth = depth + 1;
|
|
337
|
+
let result: T;
|
|
198
338
|
try {
|
|
199
|
-
|
|
200
|
-
this.querySync("COMMIT");
|
|
201
|
-
return result;
|
|
339
|
+
result = fn();
|
|
202
340
|
} catch (e) {
|
|
203
|
-
|
|
341
|
+
this.txDepth = 0;
|
|
342
|
+
try {
|
|
343
|
+
if (depth === 0) this.request("ROLLBACK");
|
|
344
|
+
else { this.request(`ROLLBACK TO SAVEPOINT ${savepoint}`); this.request(`RELEASE SAVEPOINT ${savepoint}`); }
|
|
345
|
+
} catch {}
|
|
346
|
+
this.txDepth = depth;
|
|
204
347
|
throw e;
|
|
205
348
|
}
|
|
349
|
+
this.txDepth = 0;
|
|
350
|
+
try { this.request(depth === 0 ? "COMMIT" : `RELEASE SAVEPOINT ${savepoint}`); } finally { this.txDepth = depth; }
|
|
351
|
+
return result;
|
|
206
352
|
}
|
|
207
353
|
|
|
208
|
-
close(): void {
|
|
354
|
+
close(): void {
|
|
355
|
+
this.worker.terminate();
|
|
356
|
+
}
|
|
209
357
|
}
|
|
210
358
|
|
|
211
359
|
// ════════════════════════════════════════════
|
|
212
360
|
// Factory
|
|
213
361
|
// ════════════════════════════════════════════
|
|
214
362
|
|
|
363
|
+
export type DbTarget =
|
|
364
|
+
| { kind: "postgres"; url: string }
|
|
365
|
+
| { kind: "sqlite"; path: string };
|
|
366
|
+
|
|
215
367
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
368
|
+
* Refuse every inherited DATABASE_URL under the Bun test environment before
|
|
369
|
+
* adapter selection, logging, DNS, or construction. A test that intentionally
|
|
370
|
+
* exercises PostgreSQL needs a separately reviewed isolated harness; accepting
|
|
371
|
+
* a shell/CI DATABASE_URL here could connect a unit test to production.
|
|
372
|
+
*/
|
|
373
|
+
export function assertSafeTestDatabaseEnv(env: NodeJS.ProcessEnv = process.env): void {
|
|
374
|
+
if (env.NODE_ENV === "test" && env.DATABASE_URL) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
"[commhub] REFUSING to honor inherited DATABASE_URL under NODE_ENV=test.\n" +
|
|
377
|
+
" Tests must unset DATABASE_URL and set COMMHUB_DB to an isolated path.\n" +
|
|
378
|
+
" This guard is fail-closed and has no opt-in bypass."
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Pure target selection after the inherited-DATABASE_URL safety gate.
|
|
219
385
|
*
|
|
220
386
|
* Test-default-prod GUARD: when `NODE_ENV === "test"` (set automatically by
|
|
221
387
|
* `bun test`) AND `COMMHUB_DB` is unset, we REFUSE to fall through to the
|
|
@@ -227,27 +393,67 @@ export class PgAdapter implements DbAdapter {
|
|
|
227
393
|
* pre-existing history of test pollution in the same DB). The guard makes
|
|
228
394
|
* the failure mode loud + actionable instead of silent + destructive.
|
|
229
395
|
*
|
|
230
|
-
* The
|
|
231
|
-
*
|
|
232
|
-
*
|
|
396
|
+
* The production default path is a capability, not a generic fallback:
|
|
397
|
+
* only canonical server entrypoints set COMMHUB_SERVER=1 before importing
|
|
398
|
+
* the database graph. Tests, scripts, and `bun -e` probes must name an
|
|
399
|
+
* explicit COMMHUB_DB (or a reviewed PostgreSQL DATABASE_URL), so forgetting
|
|
400
|
+
* one fails before mkdir/open/write instead of touching the live Hub.
|
|
401
|
+
*/
|
|
402
|
+
/**
|
|
403
|
+
* RFC-039 §9 — the one reviewed way a `bun test` process may use PostgreSQL.
|
|
233
404
|
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
405
|
+
* The inherited-DATABASE_URL guard stays absolute. A test that means to run
|
|
406
|
+
* on PostgreSQL names a *different* variable, COMMHUB_TEST_PG_URL, which is
|
|
407
|
+
* honoured only under NODE_ENV=test and only for a literal loopback host and
|
|
408
|
+
* a database called `anet_…_test`, so it cannot reach a real Hub database by
|
|
409
|
+
* accident. Error messages never carry credentials.
|
|
238
410
|
*/
|
|
239
|
-
export
|
|
240
|
-
|
|
411
|
+
export const TEST_PG_URL_ENV = "COMMHUB_TEST_PG_URL";
|
|
412
|
+
const TEST_PG_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
413
|
+
const TEST_PG_DB_RE = /^anet_[a-z0-9_]*_test$/;
|
|
414
|
+
const TEST_PG_QUERY_KEYS = new Set(["connect_timeout", "sslmode", "application_name"]);
|
|
415
|
+
|
|
416
|
+
/** A PostgreSQL URL with user and password replaced, safe to print. */
|
|
417
|
+
export function redactPgUrl(raw: string): string {
|
|
418
|
+
try {
|
|
419
|
+
const u = new URL(raw);
|
|
420
|
+
if (u.username || u.password) { u.username = "***"; u.password = ""; }
|
|
421
|
+
return u.toString();
|
|
422
|
+
} catch {
|
|
423
|
+
return "<unparseable URL>";
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function validateTestPgUrl(raw: string): string {
|
|
428
|
+
const refuse = (why: string) => new Error(
|
|
429
|
+
`[commhub] REFUSING ${TEST_PG_URL_ENV}=${redactPgUrl(raw)}: ${why}.\n` +
|
|
430
|
+
" It is test-only: postgres(ql)://<user>:<password>@127.0.0.1|localhost|[::1][:port]/anet_<name>_test",
|
|
431
|
+
);
|
|
432
|
+
let u: URL;
|
|
433
|
+
try { u = new URL(raw); } catch { throw refuse("not a URL"); }
|
|
434
|
+
if (u.protocol !== "postgres:" && u.protocol !== "postgresql:") throw refuse("scheme must be postgres:// or postgresql://");
|
|
435
|
+
if (!TEST_PG_HOSTS.has(u.hostname)) throw refuse("host must be literally 127.0.0.1, localhost or ::1");
|
|
436
|
+
const dbName = decodeURIComponent(u.pathname.replace(/^\//, ""));
|
|
437
|
+
if (!TEST_PG_DB_RE.test(dbName)) throw refuse("database name must match /^anet_[a-z0-9_]*_test$/");
|
|
438
|
+
for (const key of u.searchParams.keys()) {
|
|
439
|
+
if (!TEST_PG_QUERY_KEYS.has(key)) throw refuse(`query parameter "${key}" is not allowed`);
|
|
440
|
+
}
|
|
441
|
+
return raw;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function resolveDatabaseTargetAfterGuard(env: NodeJS.ProcessEnv): DbTarget {
|
|
445
|
+
const testPgUrl = env[TEST_PG_URL_ENV];
|
|
446
|
+
if (env.NODE_ENV === "test" && testPgUrl) {
|
|
447
|
+
return { kind: "postgres", url: validateTestPgUrl(testPgUrl) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const dbUrl = env.DATABASE_URL;
|
|
241
451
|
if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
|
|
242
|
-
|
|
243
|
-
return new PgAdapter(dbUrl);
|
|
452
|
+
return { kind: "postgres", url: dbUrl };
|
|
244
453
|
}
|
|
245
|
-
// Default: SQLite
|
|
246
|
-
const { mkdirSync } = require("fs");
|
|
247
|
-
const { dirname } = require("path");
|
|
248
454
|
|
|
249
455
|
// Test-default-prod GUARD (see docblock above).
|
|
250
|
-
if (
|
|
456
|
+
if (env.NODE_ENV === "test" && !env.COMMHUB_DB) {
|
|
251
457
|
throw new Error(
|
|
252
458
|
"[commhub] REFUSING to open the default SQLite database under NODE_ENV=test.\n" +
|
|
253
459
|
" Tests must explicitly set COMMHUB_DB to a throwaway path so they don't\n" +
|
|
@@ -257,10 +463,40 @@ export function createAdapter(): DbAdapter {
|
|
|
257
463
|
);
|
|
258
464
|
}
|
|
259
465
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
466
|
+
if (!env.COMMHUB_DB && env.COMMHUB_SERVER !== "1") {
|
|
467
|
+
throw new Error(
|
|
468
|
+
"[commhub] REFUSING to open the default production SQLite database from a non-server entrypoint.\n" +
|
|
469
|
+
" Scripts and probes must set an explicit COMMHUB_DB path. The canonical\n" +
|
|
470
|
+
" Hub entrypoints set COMMHUB_SERVER=1 before loading the database."
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return { kind: "sqlite", path: env.COMMHUB_DB || `${env.HOME}/.commhub/commhub.db` };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Pure public resolver used by tests without constructing an adapter. */
|
|
478
|
+
export function resolveDatabaseTarget(env: NodeJS.ProcessEnv = process.env): DbTarget {
|
|
479
|
+
assertSafeTestDatabaseEnv(env);
|
|
480
|
+
return resolveDatabaseTargetAfterGuard(env);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Create the appropriate adapter based on environment.
|
|
485
|
+
* The inherited-DATABASE_URL guard is deliberately the first executable line.
|
|
486
|
+
*/
|
|
487
|
+
export function createAdapter(): DbAdapter {
|
|
488
|
+
assertSafeTestDatabaseEnv(process.env);
|
|
489
|
+
const target = resolveDatabaseTargetAfterGuard(process.env);
|
|
490
|
+
if (target.kind === "postgres") {
|
|
491
|
+
console.log("[commhub] database: PostgreSQL");
|
|
492
|
+
return new PgAdapter(target.url);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const { mkdirSync } = require("fs");
|
|
496
|
+
const { dirname } = require("path");
|
|
497
|
+
mkdirSync(dirname(target.path), { recursive: true });
|
|
498
|
+
console.log(`[commhub] database: ${target.path}`);
|
|
499
|
+
const rawDb = new Database(target.path);
|
|
264
500
|
rawDb.exec("PRAGMA journal_mode=WAL");
|
|
265
501
|
rawDb.exec("PRAGMA busy_timeout=5000");
|
|
266
502
|
return new SQLiteAdapter(rawDb);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
3
|
+
|
|
4
|
+
const NAIVE = "2026-08-27 11:23:54";
|
|
5
|
+
const EXPECTED = Date.UTC(2026, 7, 27, 11, 23, 54);
|
|
6
|
+
|
|
7
|
+
describe("parseDbTimestampMs", () => {
|
|
8
|
+
// ⚠ 这条在 **TZ=UTC 的环境**下没有分辨力:裸 Date.parse 在 UTC 下恰好正确。
|
|
9
|
+
// CI runner 与容器默认就是 UTC(本仓 Docker 套件亦然),所以缺陷在常规跑法里
|
|
10
|
+
// 恒绿。(更正:Bun 是尊重 TZ 的,不存在"bun test 固定 UTC"——通信评审牛 #1279
|
|
11
|
+
// 独审③ 独立复跑证伪了我最初的说法。)真正稳定见红的门是下面那条强制子进程。
|
|
12
|
+
it("treats a naive SQLite timestamp as UTC", () => {
|
|
13
|
+
expect(parseDbTimestampMs(NAIVE)).toBe(EXPECTED);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// 分辨力关键:宿主是 UTC 时,裸 Date.parse 与正确写法结果相同,断言恒绿。
|
|
17
|
+
// 强制子进程 TZ=Asia/Shanghai,让缺陷在任何 CI 时区下都显形。
|
|
18
|
+
it("is unaffected by a non-UTC host timezone (child process TZ=Asia/Shanghai)", () => {
|
|
19
|
+
const r = Bun.spawnSync({
|
|
20
|
+
cmd: ["bun", "-e",
|
|
21
|
+
`const {parseDbTimestampMs} = await import("${import.meta.dir}/db-timestamp.ts");` +
|
|
22
|
+
`console.log(parseDbTimestampMs("${NAIVE}"));`],
|
|
23
|
+
env: { ...process.env, TZ: "Asia/Shanghai" },
|
|
24
|
+
});
|
|
25
|
+
expect(Number(r.stdout.toString().trim())).toBe(EXPECTED);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("keeps explicit-offset timestamps intact", () => {
|
|
29
|
+
expect(parseDbTimestampMs("2026-08-27T11:23:54Z")).toBe(EXPECTED);
|
|
30
|
+
expect(parseDbTimestampMs("2026-08-27T19:23:54+08:00")).toBe(EXPECTED);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("an online window of 5min holds for a heartbeat written 1s ago", () => {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const iso = new Date(now - 1000).toISOString().slice(0, 19).replace("T", " ");
|
|
36
|
+
expect(now - parseDbTimestampMs(iso)).toBeLessThanOrEqual(5 * 60_000);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite 里的时间戳列(sessions.last_seen_at、tasks.created_at 等)存的是
|
|
3
|
+
* **无时区标记的 UTC 串**(`YYYY-MM-DD HH:MM:SS`,由 SQLite `datetime('now')` 写入)。
|
|
4
|
+
*
|
|
5
|
+
* 裸 `Date.parse("2026-08-27 11:23:54")` 会按**宿主机本地时区**解释这个串。
|
|
6
|
+
* 生产机 TZ=CST+0800 时,它比真实 UTC 时刻早 8 小时——所以 `now - parsed`
|
|
7
|
+
* 恒为 +480 分钟,任何「最近 N 秒内算在线」的判据都会恒判离线。
|
|
8
|
+
* 在 TZ=UTC 的机器上两种写法结果相同,缺陷不会显形。
|
|
9
|
+
*/
|
|
10
|
+
export function parseDbTimestampMs(value: string): number {
|
|
11
|
+
const s = String(value).trim();
|
|
12
|
+
// 已带时区标记(ISO 的 Z / ±HH:MM)就原样交给 Date
|
|
13
|
+
if (/[Zz]$|[+-]\d{2}:?\d{2}$/.test(s)) return Date.parse(s);
|
|
14
|
+
return Date.parse(s.replace(" ", "T") + "Z");
|
|
15
|
+
}
|