@wfm-cn/dsh-storage 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -2
  4. package/cordis.patch.yml +3 -0
  5. package/lib/backends/database.js +578 -0
  6. package/lib/backfill.js +331 -0
  7. package/lib/client/index.js +79 -0
  8. package/lib/client/panel.js +299 -0
  9. package/lib/client/toast.js +66 -0
  10. package/lib/client.js +1 -0
  11. package/lib/compaction.js +108 -0
  12. package/lib/config.js +171 -0
  13. package/lib/ddl.js +323 -0
  14. package/lib/generated/mysql/browser.js +17 -0
  15. package/lib/generated/mysql/client.js +34 -0
  16. package/lib/generated/mysql/commonInputTypes.js +10 -0
  17. package/lib/generated/mysql/enums.js +10 -0
  18. package/lib/generated/mysql/internal/class.js +49 -0
  19. package/lib/generated/mysql/internal/prismaNamespace.js +146 -0
  20. package/lib/generated/mysql/internal/prismaNamespaceBrowser.js +117 -0
  21. package/lib/generated/mysql/models/AiChatHistory.js +1 -0
  22. package/lib/generated/mysql/models/AiMessage.js +1 -0
  23. package/lib/generated/mysql/models.js +1 -0
  24. package/lib/generated/postgresql/browser.js +17 -0
  25. package/lib/generated/postgresql/client.js +34 -0
  26. package/lib/generated/postgresql/commonInputTypes.js +10 -0
  27. package/lib/generated/postgresql/enums.js +10 -0
  28. package/lib/generated/postgresql/internal/class.js +49 -0
  29. package/lib/generated/postgresql/internal/prismaNamespace.js +131 -0
  30. package/lib/generated/postgresql/internal/prismaNamespaceBrowser.js +102 -0
  31. package/lib/generated/postgresql/models/AiChatHistory.js +1 -0
  32. package/lib/generated/postgresql/models/AiMessage.js +1 -0
  33. package/lib/generated/postgresql/models.js +1 -0
  34. package/lib/generated/sqlite/browser.js +17 -0
  35. package/lib/generated/sqlite/client.js +34 -0
  36. package/lib/generated/sqlite/commonInputTypes.js +10 -0
  37. package/lib/generated/sqlite/enums.js +10 -0
  38. package/lib/generated/sqlite/internal/class.js +49 -0
  39. package/lib/generated/sqlite/internal/prismaNamespace.js +128 -0
  40. package/lib/generated/sqlite/internal/prismaNamespaceBrowser.js +99 -0
  41. package/lib/generated/sqlite/models/AiChatHistory.js +1 -0
  42. package/lib/generated/sqlite/models/AiMessage.js +1 -0
  43. package/lib/generated/sqlite/models.js +1 -0
  44. package/lib/generated/sqlserver/browser.js +17 -0
  45. package/lib/generated/sqlserver/client.js +34 -0
  46. package/lib/generated/sqlserver/commonInputTypes.js +10 -0
  47. package/lib/generated/sqlserver/enums.js +10 -0
  48. package/lib/generated/sqlserver/internal/class.js +49 -0
  49. package/lib/generated/sqlserver/internal/prismaNamespace.js +119 -0
  50. package/lib/generated/sqlserver/internal/prismaNamespaceBrowser.js +90 -0
  51. package/lib/generated/sqlserver/models/AiChatHistory.js +1 -0
  52. package/lib/generated/sqlserver/models/AiMessage.js +1 -0
  53. package/lib/generated/sqlserver/models.js +1 -0
  54. package/lib/health.js +180 -0
  55. package/lib/index.js +681 -0
  56. package/lib/memory-tools.js +365 -0
  57. package/lib/notify.js +56 -0
  58. package/lib/projector.js +191 -0
  59. package/lib/status-route.js +172 -0
  60. package/lib/types/backends/database.d.ts +78 -0
  61. package/lib/types/backfill.d.ts +67 -0
  62. package/lib/types/client/index.d.ts +12 -0
  63. package/lib/types/client/panel.d.ts +79 -0
  64. package/lib/types/client/toast.d.ts +38 -0
  65. package/lib/types/compaction.d.ts +3 -0
  66. package/lib/types/config.d.ts +52 -0
  67. package/lib/types/ddl.d.ts +49 -0
  68. package/lib/types/generated/mysql/browser.d.ts +14 -0
  69. package/lib/types/generated/mysql/client.d.ts +33 -0
  70. package/lib/types/generated/mysql/commonInputTypes.d.ts +396 -0
  71. package/lib/types/generated/mysql/enums.d.ts +1 -0
  72. package/lib/types/generated/mysql/internal/class.d.ts +143 -0
  73. package/lib/types/generated/mysql/internal/prismaNamespace.d.ts +709 -0
  74. package/lib/types/generated/mysql/internal/prismaNamespaceBrowser.d.ts +112 -0
  75. package/lib/types/generated/mysql/models/AiChatHistory.d.ts +1258 -0
  76. package/lib/types/generated/mysql/models/AiMessage.d.ts +1359 -0
  77. package/lib/types/generated/mysql/models.d.ts +3 -0
  78. package/lib/types/generated/postgresql/browser.d.ts +14 -0
  79. package/lib/types/generated/postgresql/client.d.ts +33 -0
  80. package/lib/types/generated/postgresql/commonInputTypes.d.ts +392 -0
  81. package/lib/types/generated/postgresql/enums.d.ts +1 -0
  82. package/lib/types/generated/postgresql/internal/class.d.ts +143 -0
  83. package/lib/types/generated/postgresql/internal/prismaNamespace.d.ts +728 -0
  84. package/lib/types/generated/postgresql/internal/prismaNamespaceBrowser.d.ts +95 -0
  85. package/lib/types/generated/postgresql/models/AiChatHistory.d.ts +1373 -0
  86. package/lib/types/generated/postgresql/models/AiMessage.d.ts +1496 -0
  87. package/lib/types/generated/postgresql/models.d.ts +3 -0
  88. package/lib/types/generated/sqlite/browser.d.ts +14 -0
  89. package/lib/types/generated/sqlite/client.d.ts +33 -0
  90. package/lib/types/generated/sqlite/commonInputTypes.d.ts +373 -0
  91. package/lib/types/generated/sqlite/enums.d.ts +1 -0
  92. package/lib/types/generated/sqlite/internal/class.d.ts +143 -0
  93. package/lib/types/generated/sqlite/internal/prismaNamespace.d.ts +705 -0
  94. package/lib/types/generated/sqlite/internal/prismaNamespaceBrowser.d.ts +92 -0
  95. package/lib/types/generated/sqlite/models/AiChatHistory.d.ts +1371 -0
  96. package/lib/types/generated/sqlite/models/AiMessage.d.ts +1493 -0
  97. package/lib/types/generated/sqlite/models.d.ts +3 -0
  98. package/lib/types/generated/sqlserver/browser.d.ts +14 -0
  99. package/lib/types/generated/sqlserver/client.d.ts +33 -0
  100. package/lib/types/generated/sqlserver/commonInputTypes.d.ts +333 -0
  101. package/lib/types/generated/sqlserver/enums.d.ts +1 -0
  102. package/lib/types/generated/sqlserver/internal/class.d.ts +143 -0
  103. package/lib/types/generated/sqlserver/internal/prismaNamespace.d.ts +669 -0
  104. package/lib/types/generated/sqlserver/internal/prismaNamespaceBrowser.d.ts +80 -0
  105. package/lib/types/generated/sqlserver/models/AiChatHistory.d.ts +1257 -0
  106. package/lib/types/generated/sqlserver/models/AiMessage.d.ts +1375 -0
  107. package/lib/types/generated/sqlserver/models.d.ts +3 -0
  108. package/lib/types/health.d.ts +52 -0
  109. package/lib/types/index.d.ts +91 -0
  110. package/lib/types/memory-tools.d.ts +168 -0
  111. package/lib/types/notify.d.ts +35 -0
  112. package/lib/types/projector.d.ts +51 -0
  113. package/lib/types/status-route.d.ts +48 -0
  114. package/lib/types/types.d.ts +58 -0
  115. package/lib/types.js +8 -0
  116. package/package.json +127 -4
  117. package/preview.png +0 -0
  118. package/prisma/schema.mysql.prisma +78 -0
  119. package/prisma/schema.postgresql.prisma +70 -0
  120. package/prisma/schema.sqlite.prisma +70 -0
  121. package/prisma/schema.sqlserver.prisma +76 -0
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Host→browser bridge for the settings panel: exposes the dshStorageStatus
3
+ * service over the DSH connection generic-RPC channel `/dsh-storage`.
4
+ *
5
+ * Pattern verified against the installed reference plugins (ds-harness-remote
6
+ * `registerControlRoute`, dsh-video-skin settings.section client): the browser
7
+ * client calls `ctx.connection.rpc.call('/dsh-storage', endpoint, payload)`,
8
+ * which POSTs `{type:'client-request', rpcId, method, payload}` to
9
+ * `/<channel>/<endpoint>` and expects `{type:'server-response', rpcId,
10
+ * result:{ok:true,value}|{ok:false,error:{code,message,details}}}`. When the
11
+ * webServer service is available we serve that path with a prefix route
12
+ * (guarded by `connection.requestRejection` for authentication); otherwise we
13
+ * fall back to `connection.rpc.handle` for in-process carriers. Both halves
14
+ * stay optional (`ctx.get`), so profiles without a web carrier simply skip
15
+ * the bridge.
16
+ */
17
+ import { syncConfigProblem } from './config.js';
18
+ export const STATUS_CHANNEL = '/dsh-storage';
19
+ const ENDPOINT_SEGMENT = /^[A-Za-z0-9_$.-]+$/;
20
+ function badRequest(message) {
21
+ return { ok: false, error: { code: 'gateway/bad-request', message, details: {} } };
22
+ }
23
+ /**
24
+ * Guard for cfg-taking endpoints. Rejections name the offending field (via
25
+ * syncConfigProblem) so a cleared/NaN port surfaces as 'cfg.port must be…',
26
+ * not a misleading generic 'requires a cfg object'.
27
+ */
28
+ function cfgGuard(endpoint, cfg) {
29
+ const problem = syncConfigProblem(cfg);
30
+ return problem === undefined
31
+ ? { cfg: cfg }
32
+ : { error: { code: 'gateway/bad-request', message: `${endpoint}: ${problem}`, details: {} } };
33
+ }
34
+ /** Dispatch one endpoint; never throws — failures become ok:false results. */
35
+ async function dispatch(service, endpoint, payload) {
36
+ const body = (typeof payload === 'object' && payload !== null ? payload : {});
37
+ try {
38
+ switch (endpoint) {
39
+ case 'snapshot':
40
+ return { ok: true, value: service.snapshot() };
41
+ case 'testConnection': {
42
+ const guarded = cfgGuard('testConnection', body.cfg);
43
+ if ('error' in guarded)
44
+ return { ok: false, error: guarded.error };
45
+ return { ok: true, value: await service.testConnection(guarded.cfg) };
46
+ }
47
+ case 'createTables': {
48
+ const guarded = cfgGuard('createTables', body.cfg);
49
+ if ('error' in guarded)
50
+ return { ok: false, error: guarded.error };
51
+ return { ok: true, value: await service.createTables(guarded.cfg) };
52
+ }
53
+ case 'getConfig':
54
+ return { ok: true, value: service.getConfig() };
55
+ case 'saveConfig': {
56
+ const guarded = cfgGuard('saveConfig', body.cfg);
57
+ if ('error' in guarded)
58
+ return { ok: false, error: guarded.error };
59
+ return { ok: true, value: service.saveConfig(guarded.cfg) };
60
+ }
61
+ case 'backfill':
62
+ return { ok: true, value: await service.backfill() };
63
+ case 'toasts':
64
+ if (body.sinceId !== undefined && typeof body.sinceId !== 'number')
65
+ return badRequest('toasts sinceId must be a number');
66
+ return { ok: true, value: service.toasts({ sinceId: body.sinceId }) };
67
+ default:
68
+ return badRequest(`unknown endpoint ${JSON.stringify(endpoint)}`);
69
+ }
70
+ }
71
+ catch (error) {
72
+ return {
73
+ ok: false,
74
+ error: {
75
+ code: 'gateway/internal',
76
+ message: error instanceof Error ? error.message : String(error),
77
+ details: {},
78
+ },
79
+ };
80
+ }
81
+ }
82
+ function endpointFromPath(pathname) {
83
+ if (!pathname.startsWith(`${STATUS_CHANNEL}/`))
84
+ return undefined;
85
+ const endpoint = pathname.slice(STATUS_CHANNEL.length + 1);
86
+ if (!ENDPOINT_SEGMENT.test(endpoint))
87
+ return undefined;
88
+ return endpoint;
89
+ }
90
+ function parseClientRequest(value) {
91
+ if (typeof value !== 'object' || value === null)
92
+ return undefined;
93
+ const v = value;
94
+ if (v.type !== 'client-request' || typeof v.rpcId !== 'string' || typeof v.method !== 'string')
95
+ return undefined;
96
+ return { type: 'client-request', rpcId: v.rpcId, method: v.method, payload: v.payload };
97
+ }
98
+ function writeJson(res, status, body) {
99
+ res.writeHead(status, { 'content-type': 'application/json' });
100
+ res.end(JSON.stringify(body));
101
+ }
102
+ function fullResponse(rpcId, result) {
103
+ return { type: 'server-response', rpcId, result };
104
+ }
105
+ /**
106
+ * Register the /dsh-storage channel. Wire order mirrors ds-harness-remote's
107
+ * control route: an authenticated webServer prefix route when both services
108
+ * allow it, otherwise connection.rpc.handle for logical carriers.
109
+ */
110
+ export function registerStatusRoute(ctx, service) {
111
+ const connection = ctx.get('connection');
112
+ if (!connection)
113
+ return undefined;
114
+ const handler = (endpoint, payload) => dispatch(service, endpoint, payload);
115
+ const webServer = ctx.get('webServer');
116
+ if (webServer !== undefined && connection.requestRejection !== undefined) {
117
+ const dispose = webServer.register({
118
+ kind: 'prefix',
119
+ path: STATUS_CHANNEL,
120
+ handler: async (req, res) => {
121
+ const rejection = connection.requestRejection?.(req);
122
+ if (rejection !== undefined) {
123
+ res.writeHead(rejection);
124
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden');
125
+ return;
126
+ }
127
+ const endpoint = endpointFromPath(new URL(req.url ?? '/', 'http://dsh.internal').pathname);
128
+ if (req.method !== 'POST' || endpoint === undefined) {
129
+ writeJson(res, 404, 'not found');
130
+ return;
131
+ }
132
+ const contentType = Array.isArray(req.headers?.['content-type'])
133
+ ? req.headers['content-type'][0]
134
+ : req.headers?.['content-type'];
135
+ if ((contentType ?? '').split(';', 1)[0]?.trim().toLowerCase() !== 'application/json') {
136
+ writeJson(res, 415, 'content type must be application/json');
137
+ return;
138
+ }
139
+ let body;
140
+ try {
141
+ const chunks = [];
142
+ for await (const chunk of req) {
143
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
144
+ }
145
+ body = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
146
+ }
147
+ catch {
148
+ writeJson(res, 400, 'body is not JSON');
149
+ return;
150
+ }
151
+ const message = parseClientRequest(body);
152
+ if (message === undefined || message.method !== endpoint) {
153
+ writeJson(res, 200, fullResponse('invalid-request', badRequest('invalid client-request message')));
154
+ return;
155
+ }
156
+ try {
157
+ writeJson(res, 200, fullResponse(message.rpcId, await handler(endpoint, message.payload)));
158
+ }
159
+ catch (error) {
160
+ res.writeHead(500);
161
+ res.end(`handler failure: ${String(error)}`);
162
+ }
163
+ },
164
+ });
165
+ return () => void dispose();
166
+ }
167
+ const rpcHandle = connection.rpc?.handle;
168
+ if (rpcHandle === undefined)
169
+ return undefined;
170
+ const dispose = rpcHandle(STATUS_CHANNEL, handler, { authority: 'loopback' });
171
+ return () => void dispose();
172
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * MySQL/PostgreSQL/SQLite/SQL Server backend via Prisma 7. The clients are
3
+ * pre-generated at package build time (`pnpm generate`, see
4
+ * scripts/prisma-generate.mjs) from the schema variants in prisma/ and
5
+ * shipped compiled in lib/generated — consumers never run `prisma generate`.
6
+ *
7
+ * Prisma 7 requires a driver adapter per database; `@prisma/client` and the
8
+ * adapter for your provider are optional peer dependencies (sqlite uses
9
+ * `@prisma/adapter-libsql` — no native build scripts, unlike
10
+ * `@prisma/adapter-better-sqlite3`). Table creation
11
+ * (`prisma db push` / migrations) uses the shipped schema variant with the
12
+ * connection URL in the user's prisma.config.ts.
13
+ */
14
+ import type { MessageRow, SessionRow, StorageBackend } from '../types.js';
15
+ export type DatabaseProvider = 'mysql' | 'postgresql' | 'sqlite' | 'sqlserver';
16
+ export interface DatabaseBackendConfig {
17
+ provider: DatabaseProvider;
18
+ /** Full connection URL, e.g. mysql://user:pass@host:3306/dbname, file:/data/dsh.db */
19
+ url: string;
20
+ /**
21
+ * IANA-named offset for the mariadb driver's `timezone` option (e.g.
22
+ * '+08:00'), applied when writing DATETIME columns. Only used by mysql.
23
+ */
24
+ timezone?: string;
25
+ /**
26
+ * Device identifier stamped on every ai_messages / ai_chat_histories row
27
+ * this backend writes, so several machines can share one database.
28
+ * Falls back to the OS hostname when unset.
29
+ */
30
+ device?: string;
31
+ }
32
+ /**
33
+ * Parse mysql://user:password@host[:port]/database into discrete connection
34
+ * options for the mariadb driver adapter. Passing the URI string itself
35
+ * pool-times-out in production (verified against a real MySQL 8.4);
36
+ * discrete host/port/user/password/database options work. Components are
37
+ * percent-decoded — credentials with @ : / arrive encoded in the URL.
38
+ */
39
+ export declare function parseMysqlUrl(url: string): {
40
+ host: string;
41
+ port?: number;
42
+ user: string;
43
+ password: string;
44
+ database: string;
45
+ };
46
+ export declare function createAdapter(config: DatabaseBackendConfig): Promise<unknown>;
47
+ export declare class DatabaseBackend implements StorageBackend {
48
+ private config;
49
+ readonly name = "database";
50
+ private prisma;
51
+ constructor(config: DatabaseBackendConfig);
52
+ /** Device identifier stamped on rows written through this backend. */
53
+ private get device();
54
+ private client;
55
+ /** Load the pre-generated client for the configured provider (seam for tests). */
56
+ protected loadClient(): Promise<any>;
57
+ init(): Promise<void>;
58
+ /** JSON column value, text-serialized on SQL Server (no Prisma Json type). */
59
+ private jsonField;
60
+ private toDbData;
61
+ private writeMessage;
62
+ upsertMessage(row: MessageRow): Promise<void>;
63
+ readMessages(sessionId: string, messageId: string): Promise<MessageRow[]>;
64
+ readToolCallMessages(sessionId: string, callId: string): Promise<MessageRow[]>;
65
+ private readRows;
66
+ readSession(sessionId: string): Promise<SessionRow | null>;
67
+ /**
68
+ * Max created_at per session over active message rows — the database-side
69
+ * watermark for backfill gap detection (sessions with newer local logs
70
+ * have missing events).
71
+ */
72
+ latestPerSession(): Promise<Map<string, Date>>;
73
+ private writeSession;
74
+ upsertSession(row: SessionRow): Promise<void>;
75
+ upsertEvent(message: MessageRow | null, session: SessionRow): Promise<void>;
76
+ archiveSession(sessionId: string): Promise<string | null>;
77
+ close(): Promise<void>;
78
+ }
@@ -0,0 +1,67 @@
1
+ import type { StorageBackend } from './types.js';
2
+ /** Exactly the event types the live tap mirrors (see index.ts session/event). */
3
+ export declare const MIRROR_EVENT_TYPES: readonly ["user/message", "assistant/message", "assistant/attempt", "tool/result", "turn/end", "session/title", "compaction/end"];
4
+ interface MirrorEvent {
5
+ type: string;
6
+ seq?: number;
7
+ time?: number;
8
+ data?: Record<string, unknown>;
9
+ }
10
+ /** Metadata key for the usage high-water mark — shared with the live tap. */
11
+ export declare const LAST_USAGE_KEY = "dsh-storage:lastUsage";
12
+ /** One session with missing events: replay starts at `fromSeq` (inclusive). */
13
+ export interface BackfillGap {
14
+ sessionId: string;
15
+ fromSeq: number;
16
+ }
17
+ export interface BackfillSummary {
18
+ /** Sessions whose events were replayed (excludes reported errors). */
19
+ sessions: number;
20
+ /** Message rows written by this run. */
21
+ replayed: number;
22
+ /** Events already present in the database (idempotent skips). */
23
+ skipped: number;
24
+ /** Per-session failures — one entry per session that could not be replayed. */
25
+ errors: string[];
26
+ }
27
+ interface LogFile {
28
+ file: string;
29
+ mtime: Date;
30
+ }
31
+ interface SessionLog extends LogFile {
32
+ sessionId: string;
33
+ }
34
+ /**
35
+ * Read one session log as text. Plain files are read directly; `.zstd` files
36
+ * are decompressed through the first available system binary.
37
+ */
38
+ export declare function readSessionLogText(file: string, binaries?: readonly string[]): Promise<string>;
39
+ /** Parse JSONL lines into event objects; malformed lines are skipped. */
40
+ export declare function parseSessionEvents(text: string): MirrorEvent[];
41
+ /**
42
+ * Enumerate session logs. Real DSH layout is
43
+ * `<sessionsDir>/<workspace-dir>/<session-id>/session.v4.jsonl[.zstd]`
44
+ * (two levels); a log directly under a child directory is also accepted so
45
+ * a flattened sessions dir keeps working.
46
+ */
47
+ export declare function listSessionLogs(sessionsDir: string): Promise<SessionLog[]>;
48
+ /**
49
+ * Select sessions whose latest local activity (log mtime) is newer than the
50
+ * database's last mirrored activity. Sessions absent from the database are
51
+ * selected for a full replay (fromSeq 0) — but only when their log holds at
52
+ * least one row-producing event; a log-only session mirrors no rows and its
53
+ * "gap" would never converge. For known sessions `fromSeq` is the sequence
54
+ * of the first row-producing event newer than the DB watermark.
55
+ */
56
+ export declare function computeGap(sessionsDir: string, dbLatestPerSession: Map<string, Date>): Promise<BackfillGap[]>;
57
+ /** computeGap + replayEvents in one call, for the panel button. */
58
+ export declare function backfillAll(sessionsDir: string, backend: StorageBackend & {
59
+ latestPerSession?(): Promise<Map<string, Date>>;
60
+ }): Promise<BackfillSummary>;
61
+ /**
62
+ * Replay the missing events of each gap through the projector into the
63
+ * backend. Idempotent: message rows whose deterministic id already exists
64
+ * are skipped without error, and the session rollup is never double-counted.
65
+ */
66
+ export declare function replayEvents(gaps: BackfillGap[], sessionsDir: string, backend: StorageBackend): Promise<BackfillSummary>;
67
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Browser half of dsh-storage: registers the settings panel into the DSH
3
+ * settings page (`settings.section` slot) and reaches the host plugin's
4
+ * dshStorageStatus service through the connection generic-RPC channel
5
+ * /dsh-storage (registered host-side in src/status-route.ts).
6
+ *
7
+ * Module shape follows the reference client plugins (dsh-pet, dsh-video-skin,
8
+ * ds-harness-remote): a `window.__ModuleLoader__.load({ id, factory })` call
9
+ * whose factory receives the page's `require` — React comes from the host
10
+ * page, never bundled. Built by esbuild into the single-file lib/client.js.
11
+ */
12
+ export {};
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Settings-panel component for dsh-storage (会话存储镜像).
3
+ *
4
+ * Authored against the reference client plugins (dsh-pet / dsh-video-skin):
5
+ * the browser half receives React from the host module loader inside the
6
+ * __ModuleLoader__ factory, so the component is built through a factory that
7
+ * takes the React module — no module-scope React import survives into the
8
+ * bundle. All RPC goes through the injected `client` (connection rpc; see
9
+ * src/client/index.ts), which keeps this file render-pure and testable with
10
+ * a fake client.
11
+ */
12
+ import type { SyncConfig } from '../config.js';
13
+ import type { BackfillSummary } from '../backfill.js';
14
+ /** Shapes mirrored from the host (type-only imports — erased in the bundle). */
15
+ export interface HealthStatus {
16
+ state: 'ok' | 'down' | 'disabled' | 'config-error';
17
+ lastOkAt?: number;
18
+ lastError?: string;
19
+ retryCount: number;
20
+ mirroredTotal?: number;
21
+ }
22
+ export type TestResult = 'ok' | 'no-tables' | `fail:${string}`;
23
+ /** Promise-based panel API; the client entry implements it over connection rpc. */
24
+ export interface StorageClient {
25
+ snapshot(): Promise<HealthStatus>;
26
+ testConnection(cfg: SyncConfig): Promise<TestResult>;
27
+ createTables(cfg: SyncConfig): Promise<'ok' | `fail:${string}`>;
28
+ getConfig(): Promise<{
29
+ config: SyncConfig;
30
+ error?: 'parse' | 'missing-field';
31
+ }>;
32
+ saveConfig(cfg: SyncConfig): Promise<unknown>;
33
+ backfill(): Promise<BackfillSummary>;
34
+ }
35
+ export declare const DEFAULT_TIMEZONES: string[];
36
+ /** Mirrors config.ts isValidPort — kept local so no node builtins enter the browser bundle. */
37
+ export declare function isValidPort(value: unknown): value is number;
38
+ /**
39
+ * The cfg object to persist: the port is sent only when it is a valid
40
+ * integer — a cleared/garbage port field is omitted so the RPC rejects it
41
+ * with an honest per-field error instead of building a host:NaN URL.
42
+ */
43
+ export declare function buildSaveConfig(cfg: SyncConfig): SyncConfig;
44
+ /** Structural React surface the factory needs (satisfied by react 18). */
45
+ export interface ReactLike {
46
+ createElement: any;
47
+ useState: <S>(initial: S) => [S, (value: S | ((prev: S) => S)) => void];
48
+ useEffect: (effect: () => void | (() => void), deps?: unknown[]) => void;
49
+ }
50
+ export type Tone = 'ok' | 'warn' | 'bad' | 'idle';
51
+ /** Status-light copy per HealthStatus.state. */
52
+ export declare function statusView(status: HealthStatus): {
53
+ tone: Tone;
54
+ label: string;
55
+ details: string[];
56
+ };
57
+ /** Three-state connection-test copy: green ok / yellow no-tables / red fail. */
58
+ export declare function testResultView(result: TestResult): {
59
+ tone: Tone;
60
+ label: string;
61
+ };
62
+ export declare function createResultView(result: 'ok' | `fail:${string}`): {
63
+ tone: Tone;
64
+ label: string;
65
+ };
66
+ /** 补传 summary copy: green when clean, yellow when some sessions failed. */
67
+ export declare function backfillResultView(summary: BackfillSummary): {
68
+ tone: Tone;
69
+ label: string;
70
+ };
71
+ export interface PanelProps {
72
+ client: StorageClient;
73
+ /** Status poll interval; default 30s per the task brief. */
74
+ pollMs?: number;
75
+ }
76
+ export declare function makeStoragePanel(React: ReactLike): {
77
+ (props: PanelProps): any;
78
+ displayName: string;
79
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Sync-status toast notifications (Task 6): a standalone React portal the
3
+ * client entry mounts on document.body — independent of the settings panel
4
+ * section, so toasts appear even when the panel is closed. The host records
5
+ * toasts on HealthMonitor transitions (src/notify.ts); this component polls
6
+ * them through the status bridge with a `sinceId` cursor.
7
+ *
8
+ * Factory pattern like panel.tsx: React (and react-dom's createPortal) come
9
+ * from the host page's module loader, never bundled.
10
+ */
11
+ import type { StorageToast } from '../notify.js';
12
+ export interface ToastClient {
13
+ toasts(payload: {
14
+ sinceId: number;
15
+ }): Promise<StorageToast[]>;
16
+ }
17
+ /** Default poll interval; transitions are rare, this only carries toasts. */
18
+ export declare const TOAST_POLL_MS = 5000;
19
+ /** How long a toast stays on screen. */
20
+ export declare const TOAST_DISPLAY_MS = 8000;
21
+ export interface ReactLike {
22
+ createElement: any;
23
+ useState: <S>(initial: S) => [S, (value: S | ((prev: S) => S)) => void];
24
+ useEffect: (effect: () => void | (() => void), deps?: unknown[]) => void;
25
+ useRef: <T>(initial: T) => {
26
+ current: T;
27
+ };
28
+ }
29
+ export interface PortalDom {
30
+ createPortal: (node: any, container: Element) => any;
31
+ }
32
+ export declare function makeToastHost(React: ReactLike, ReactDOM: PortalDom): {
33
+ (props: {
34
+ client: ToastClient;
35
+ pollMs?: number;
36
+ }): any;
37
+ displayName: string;
38
+ };
@@ -0,0 +1,3 @@
1
+ import type { MessageRow } from './types.js';
2
+ /** Legacy Content[] checkpoints remain readable by databaseChatStorage. */
3
+ export declare function compressionSnapshot(session: any, event: any): MessageRow | null;
@@ -0,0 +1,52 @@
1
+ export type SyncConfig = {
2
+ enabled: boolean;
3
+ provider: 'mysql' | 'postgresql' | 'sqlite' | 'sqlserver';
4
+ host: string;
5
+ port: number;
6
+ user: string;
7
+ password: string;
8
+ database: string;
9
+ timezone: string;
10
+ /** Identifier stamped on mirrored rows so several machines can share one database. */
11
+ device: string;
12
+ };
13
+ /**
14
+ * Default device identifier: the OS hostname, truncated to the column width
15
+ * (VARCHAR(100)). Empty hostnames (theoretically possible) fall back to a
16
+ * stable placeholder so rows are never stamped with ''.
17
+ */
18
+ export declare function defaultDevice(): string;
19
+ export declare const DEFAULT_CONFIG: SyncConfig;
20
+ /** Resolve the DSH home directory: $DSH_HOME or ~/.dsh. */
21
+ export declare function dshHome(): string;
22
+ export declare function defaultConfigPath(): string;
23
+ /**
24
+ * Parse a config timezone string ('+08:00', '-05:00', '+05:30', 'Z'/'UTC')
25
+ * into signed minutes east of UTC. Invalid or absent input → 0 (the caller
26
+ * decides whether to warn). This mirrors what the patched
27
+ * @prisma/adapter-mariadb reads via globalThis.__DSH_STORAGE_TZ_OFFSET_MIN__.
28
+ */
29
+ export declare function tzOffsetMinutes(tz: string): number;
30
+ /** Valid TCP port for the form: a finite integer in [0, 65535] (0 allowed for sqlite configs). */
31
+ export declare function isValidPort(value: unknown): value is number;
32
+ /**
33
+ * First shape problem with a candidate sync config, or undefined when valid.
34
+ * Field-specific reasons keep RPC rejections honest ('cfg.port must be…'
35
+ * instead of a generic 'requires a cfg object').
36
+ */
37
+ export declare function syncConfigProblem(value: unknown): string | undefined;
38
+ export declare function loadConfig(path?: string): {
39
+ config: SyncConfig;
40
+ error?: 'parse' | 'missing-field';
41
+ };
42
+ /**
43
+ * Atomic write: temp file in the same directory + rename. The file holds the
44
+ * database password, so it is created owner-only (0600) — writeFileSync's
45
+ * mode is masked by umask, so chmod the temp file explicitly before rename
46
+ * (rename keeps the temp file's mode, so rewrites stay 0600 too).
47
+ */
48
+ export declare function saveConfig(c: SyncConfig, path?: string): void;
49
+ /** Watch the config file; bursts are debounced (300ms). Returns a disposer. */
50
+ export declare function watchConfig(path: string, cb: (c: SyncConfig) => void): () => void;
51
+ /** Build a connection URL from discrete form fields; sqlite → file:<path>. */
52
+ export declare function configToUrl(c: SyncConfig): string;
@@ -0,0 +1,49 @@
1
+ import { type SyncConfig } from './config.js';
2
+ export type CreateTableResult = 'ok' | `fail:${string}`;
3
+ /**
4
+ * MySQL DDL — captured from Prisma 7.9 `db push` of
5
+ * prisma/schema.mysql.prisma (MySQL 8.4) via SHOW CREATE TABLE. Column
6
+ * types must match the schema exactly: char(36) ids, varchar lengths,
7
+ * longtext content, json columns, timestamp(0) message timestamps,
8
+ * datetime(6) history timestamps. ai_chat_histories is created first —
9
+ * ai_messages carries a foreign key into it.
10
+ */
11
+ export declare const MYSQL_DDL: string[];
12
+ /**
13
+ * SQLite DDL — captured from Prisma 7.9 `db push` of
14
+ * prisma/schema.sqlite.prisma (sqlite_master dump), converted to
15
+ * IF NOT EXISTS forms. The histories table precedes the FK-carrying
16
+ * messages table.
17
+ */
18
+ export declare const SQLITE_DDL: string[];
19
+ /** Embedded DDL statements for a provider; undefined → prisma-CLI fallback. */
20
+ export declare function ddlStatementsFor(provider: SyncConfig['provider']): readonly string[] | undefined;
21
+ /**
22
+ * Ensure both tables carry the nullable `device` column, then stamp every
23
+ * still-NULL row with `device` (the current machine's identifier — at
24
+ * migration time this machine owns all pre-feature rows). Executed by the
25
+ * panel's 「建表」 (createTables) and once per mirror startup; a no-op when
26
+ * the columns already exist (cheap information_schema check). mysql only —
27
+ * the other providers gain the column via their prisma schema `db push`.
28
+ *
29
+ * Returns 'ok' or a `fail:<reason>` string; never throws.
30
+ */
31
+ export declare function ensureDeviceColumn(cfg: {
32
+ provider: SyncConfig['provider'];
33
+ url: string;
34
+ timezone?: string;
35
+ }, device?: string): Promise<CreateTableResult>;
36
+ /**
37
+ * Sanitize a device identifier for direct embedding into the migration SQL
38
+ * (no bound parameters — see ensureDeviceColumn). Strips quote, backslash,
39
+ * backtick and NUL characters and truncates to the column width; the value
40
+ * is still wrapped in single quotes at the call site.
41
+ */
42
+ export declare function sqlString(device: string): string;
43
+ /**
44
+ * Create the ai_messages / ai_chat_histories tables for `cfg.provider`.
45
+ * mysql/sqlite run embedded idempotent DDL over the adapter connection;
46
+ * postgresql/sqlserver shell out to `prisma db push` against the packaged
47
+ * schema variant (requires the prisma CLI — a devDependency).
48
+ */
49
+ export declare function createTables(cfg: SyncConfig): Promise<CreateTableResult>;
@@ -0,0 +1,14 @@
1
+ import * as Prisma from './internal/prismaNamespaceBrowser.js';
2
+ export { Prisma };
3
+ export * as $Enums from './enums.js';
4
+ export * from './enums.js';
5
+ /**
6
+ * Model AiMessage
7
+ * User and assistant messages; tool results update the assistant row's tool_calls.
8
+ */
9
+ export type AiMessage = Prisma.AiMessageModel;
10
+ /**
11
+ * Model AiChatHistory
12
+ * Per-session rollup, maintained incrementally by the projector.
13
+ */
14
+ export type AiChatHistory = Prisma.AiChatHistoryModel;
@@ -0,0 +1,33 @@
1
+ import * as runtime from "@prisma/client/runtime/client";
2
+ import * as $Class from "./internal/class.js";
3
+ import * as Prisma from "./internal/prismaNamespace.js";
4
+ export * as $Enums from './enums.js';
5
+ export * from "./enums.js";
6
+ /**
7
+ * ## Prisma Client
8
+ *
9
+ * Type-safe database client for TypeScript
10
+ * @example
11
+ * ```
12
+ * const prisma = new PrismaClient({
13
+ * adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
14
+ * })
15
+ * // Fetch zero or more AiMessages
16
+ * const aiMessages = await prisma.aiMessage.findMany()
17
+ * ```
18
+ *
19
+ * Read more in our [docs](https://pris.ly/d/client).
20
+ */
21
+ export declare const PrismaClient: $Class.PrismaClientConstructor;
22
+ export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>;
23
+ export { Prisma };
24
+ /**
25
+ * Model AiMessage
26
+ * User and assistant messages; tool results update the assistant row's tool_calls.
27
+ */
28
+ export type AiMessage = Prisma.AiMessageModel;
29
+ /**
30
+ * Model AiChatHistory
31
+ * Per-session rollup, maintained incrementally by the projector.
32
+ */
33
+ export type AiChatHistory = Prisma.AiChatHistoryModel;