@chaos-overlords/bug-reports 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/NOTICE +21 -0
- package/README.md +27 -0
- package/dist/blobs.d.ts +17 -0
- package/dist/blobs.d.ts.map +1 -0
- package/dist/blobs.js +46 -0
- package/dist/blobs.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/node/index.d.ts +24 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +67 -0
- package/dist/node/index.js.map +1 -0
- package/dist/ports.d.ts +44 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +2 -0
- package/dist/ports.js.map +1 -0
- package/dist/repository.d.ts +11 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +66 -0
- package/dist/repository.js.map +1 -0
- package/dist/schema.d.ts +278 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +44 -0
- package/dist/schema.js.map +1 -0
- package/dist/service.d.ts +50 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +140 -0
- package/dist/service.js.map +1 -0
- package/migrations/sqlite/0000_fine_squadron_supreme.sql +18 -0
- package/migrations/sqlite/meta/0000_snapshot.json +132 -0
- package/migrations/sqlite/meta/_journal.json +13 -0
- package/package.json +85 -0
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One table, in a database of its own.
|
|
3
|
+
*
|
|
4
|
+
* Bug reports do not live beside matches. They arrive unauthenticated, they are kept for far longer
|
|
5
|
+
* than a match is, and the thing that makes them worth having — an attached journal of somebody
|
|
6
|
+
* else's game — is exactly the thing a live multiplayer database should never be holding. Keeping
|
|
7
|
+
* them in a separate D1 instance (a separate SQLite file when self-hosted) means the two cannot
|
|
8
|
+
* share a lock, a retention sweep, a migration lineage, or a blast radius: a report that arrives
|
|
9
|
+
* while a turn is sealing writes to a different database entirely, and a mistake in the intake path
|
|
10
|
+
* cannot reach a match.
|
|
11
|
+
*
|
|
12
|
+
* The journal itself is usually *not* here. `body` holds the base64 archive only when it is small
|
|
13
|
+
* and the deployment has nowhere better; otherwise `blob_key` names an object in R2 (or in a
|
|
14
|
+
* directory, self-hosted) and `body` stays null. Exactly one of the two is set — see
|
|
15
|
+
* `BugReportService`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const bugReports: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
18
|
+
name: "bug_reports";
|
|
19
|
+
schema: undefined;
|
|
20
|
+
columns: {
|
|
21
|
+
id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
22
|
+
name: "id";
|
|
23
|
+
tableName: "bug_reports";
|
|
24
|
+
dataType: "string";
|
|
25
|
+
columnType: "SQLiteText";
|
|
26
|
+
data: string;
|
|
27
|
+
driverParam: string;
|
|
28
|
+
notNull: true;
|
|
29
|
+
hasDefault: false;
|
|
30
|
+
isPrimaryKey: true;
|
|
31
|
+
isAutoincrement: false;
|
|
32
|
+
hasRuntimeDefault: false;
|
|
33
|
+
enumValues: [string, ...string[]];
|
|
34
|
+
baseColumn: never;
|
|
35
|
+
identity: undefined;
|
|
36
|
+
generated: undefined;
|
|
37
|
+
}, {}, {
|
|
38
|
+
length: number | undefined;
|
|
39
|
+
}>;
|
|
40
|
+
receivedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
41
|
+
name: "received_at";
|
|
42
|
+
tableName: "bug_reports";
|
|
43
|
+
dataType: "date";
|
|
44
|
+
columnType: "SQLiteTimestamp";
|
|
45
|
+
data: Date;
|
|
46
|
+
driverParam: number;
|
|
47
|
+
notNull: true;
|
|
48
|
+
hasDefault: false;
|
|
49
|
+
isPrimaryKey: false;
|
|
50
|
+
isAutoincrement: false;
|
|
51
|
+
hasRuntimeDefault: false;
|
|
52
|
+
enumValues: undefined;
|
|
53
|
+
baseColumn: never;
|
|
54
|
+
identity: undefined;
|
|
55
|
+
generated: undefined;
|
|
56
|
+
}, {}, {}>;
|
|
57
|
+
message: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
58
|
+
name: "message";
|
|
59
|
+
tableName: "bug_reports";
|
|
60
|
+
dataType: "string";
|
|
61
|
+
columnType: "SQLiteText";
|
|
62
|
+
data: string;
|
|
63
|
+
driverParam: string;
|
|
64
|
+
notNull: true;
|
|
65
|
+
hasDefault: false;
|
|
66
|
+
isPrimaryKey: false;
|
|
67
|
+
isAutoincrement: false;
|
|
68
|
+
hasRuntimeDefault: false;
|
|
69
|
+
enumValues: [string, ...string[]];
|
|
70
|
+
baseColumn: never;
|
|
71
|
+
identity: undefined;
|
|
72
|
+
generated: undefined;
|
|
73
|
+
}, {}, {
|
|
74
|
+
length: number | undefined;
|
|
75
|
+
}>;
|
|
76
|
+
clientVersion: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
77
|
+
name: "client_version";
|
|
78
|
+
tableName: "bug_reports";
|
|
79
|
+
dataType: "string";
|
|
80
|
+
columnType: "SQLiteText";
|
|
81
|
+
data: string;
|
|
82
|
+
driverParam: string;
|
|
83
|
+
notNull: true;
|
|
84
|
+
hasDefault: false;
|
|
85
|
+
isPrimaryKey: false;
|
|
86
|
+
isAutoincrement: false;
|
|
87
|
+
hasRuntimeDefault: false;
|
|
88
|
+
enumValues: [string, ...string[]];
|
|
89
|
+
baseColumn: never;
|
|
90
|
+
identity: undefined;
|
|
91
|
+
generated: undefined;
|
|
92
|
+
}, {}, {
|
|
93
|
+
length: number | undefined;
|
|
94
|
+
}>;
|
|
95
|
+
clientPlatform: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
96
|
+
name: "client_platform";
|
|
97
|
+
tableName: "bug_reports";
|
|
98
|
+
dataType: "string";
|
|
99
|
+
columnType: "SQLiteText";
|
|
100
|
+
data: string;
|
|
101
|
+
driverParam: string;
|
|
102
|
+
notNull: true;
|
|
103
|
+
hasDefault: false;
|
|
104
|
+
isPrimaryKey: false;
|
|
105
|
+
isAutoincrement: false;
|
|
106
|
+
hasRuntimeDefault: false;
|
|
107
|
+
enumValues: [string, ...string[]];
|
|
108
|
+
baseColumn: never;
|
|
109
|
+
identity: undefined;
|
|
110
|
+
generated: undefined;
|
|
111
|
+
}, {}, {
|
|
112
|
+
length: number | undefined;
|
|
113
|
+
}>;
|
|
114
|
+
context: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
115
|
+
name: "context";
|
|
116
|
+
tableName: "bug_reports";
|
|
117
|
+
dataType: "json";
|
|
118
|
+
columnType: "SQLiteTextJson";
|
|
119
|
+
data: unknown;
|
|
120
|
+
driverParam: string;
|
|
121
|
+
notNull: false;
|
|
122
|
+
hasDefault: false;
|
|
123
|
+
isPrimaryKey: false;
|
|
124
|
+
isAutoincrement: false;
|
|
125
|
+
hasRuntimeDefault: false;
|
|
126
|
+
enumValues: undefined;
|
|
127
|
+
baseColumn: never;
|
|
128
|
+
identity: undefined;
|
|
129
|
+
generated: undefined;
|
|
130
|
+
}, {}, {}>;
|
|
131
|
+
stateCodec: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
132
|
+
name: "state_codec";
|
|
133
|
+
tableName: "bug_reports";
|
|
134
|
+
dataType: "string";
|
|
135
|
+
columnType: "SQLiteText";
|
|
136
|
+
data: string;
|
|
137
|
+
driverParam: string;
|
|
138
|
+
notNull: false;
|
|
139
|
+
hasDefault: false;
|
|
140
|
+
isPrimaryKey: false;
|
|
141
|
+
isAutoincrement: false;
|
|
142
|
+
hasRuntimeDefault: false;
|
|
143
|
+
enumValues: [string, ...string[]];
|
|
144
|
+
baseColumn: never;
|
|
145
|
+
identity: undefined;
|
|
146
|
+
generated: undefined;
|
|
147
|
+
}, {}, {
|
|
148
|
+
length: number | undefined;
|
|
149
|
+
}>;
|
|
150
|
+
stateFormatVersion: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
151
|
+
name: "state_format_version";
|
|
152
|
+
tableName: "bug_reports";
|
|
153
|
+
dataType: "number";
|
|
154
|
+
columnType: "SQLiteInteger";
|
|
155
|
+
data: number;
|
|
156
|
+
driverParam: number;
|
|
157
|
+
notNull: false;
|
|
158
|
+
hasDefault: false;
|
|
159
|
+
isPrimaryKey: false;
|
|
160
|
+
isAutoincrement: false;
|
|
161
|
+
hasRuntimeDefault: false;
|
|
162
|
+
enumValues: undefined;
|
|
163
|
+
baseColumn: never;
|
|
164
|
+
identity: undefined;
|
|
165
|
+
generated: undefined;
|
|
166
|
+
}, {}, {}>;
|
|
167
|
+
stateUncompressedBytes: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
168
|
+
name: "state_uncompressed_bytes";
|
|
169
|
+
tableName: "bug_reports";
|
|
170
|
+
dataType: "number";
|
|
171
|
+
columnType: "SQLiteInteger";
|
|
172
|
+
data: number;
|
|
173
|
+
driverParam: number;
|
|
174
|
+
notNull: false;
|
|
175
|
+
hasDefault: false;
|
|
176
|
+
isPrimaryKey: false;
|
|
177
|
+
isAutoincrement: false;
|
|
178
|
+
hasRuntimeDefault: false;
|
|
179
|
+
enumValues: undefined;
|
|
180
|
+
baseColumn: never;
|
|
181
|
+
identity: undefined;
|
|
182
|
+
generated: undefined;
|
|
183
|
+
}, {}, {}>;
|
|
184
|
+
stateCompressedBytes: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
185
|
+
name: "state_compressed_bytes";
|
|
186
|
+
tableName: "bug_reports";
|
|
187
|
+
dataType: "number";
|
|
188
|
+
columnType: "SQLiteInteger";
|
|
189
|
+
data: number;
|
|
190
|
+
driverParam: number;
|
|
191
|
+
notNull: false;
|
|
192
|
+
hasDefault: false;
|
|
193
|
+
isPrimaryKey: false;
|
|
194
|
+
isAutoincrement: false;
|
|
195
|
+
hasRuntimeDefault: false;
|
|
196
|
+
enumValues: undefined;
|
|
197
|
+
baseColumn: never;
|
|
198
|
+
identity: undefined;
|
|
199
|
+
generated: undefined;
|
|
200
|
+
}, {}, {}>;
|
|
201
|
+
stateSha256: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
202
|
+
name: "state_sha256";
|
|
203
|
+
tableName: "bug_reports";
|
|
204
|
+
dataType: "string";
|
|
205
|
+
columnType: "SQLiteText";
|
|
206
|
+
data: string;
|
|
207
|
+
driverParam: string;
|
|
208
|
+
notNull: false;
|
|
209
|
+
hasDefault: false;
|
|
210
|
+
isPrimaryKey: false;
|
|
211
|
+
isAutoincrement: false;
|
|
212
|
+
hasRuntimeDefault: false;
|
|
213
|
+
enumValues: [string, ...string[]];
|
|
214
|
+
baseColumn: never;
|
|
215
|
+
identity: undefined;
|
|
216
|
+
generated: undefined;
|
|
217
|
+
}, {}, {
|
|
218
|
+
length: number | undefined;
|
|
219
|
+
}>;
|
|
220
|
+
stateAnonymized: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
221
|
+
name: "state_anonymized";
|
|
222
|
+
tableName: "bug_reports";
|
|
223
|
+
dataType: "boolean";
|
|
224
|
+
columnType: "SQLiteBoolean";
|
|
225
|
+
data: boolean;
|
|
226
|
+
driverParam: number;
|
|
227
|
+
notNull: false;
|
|
228
|
+
hasDefault: false;
|
|
229
|
+
isPrimaryKey: false;
|
|
230
|
+
isAutoincrement: false;
|
|
231
|
+
hasRuntimeDefault: false;
|
|
232
|
+
enumValues: undefined;
|
|
233
|
+
baseColumn: never;
|
|
234
|
+
identity: undefined;
|
|
235
|
+
generated: undefined;
|
|
236
|
+
}, {}, {}>;
|
|
237
|
+
blobKey: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
238
|
+
name: "blob_key";
|
|
239
|
+
tableName: "bug_reports";
|
|
240
|
+
dataType: "string";
|
|
241
|
+
columnType: "SQLiteText";
|
|
242
|
+
data: string;
|
|
243
|
+
driverParam: string;
|
|
244
|
+
notNull: false;
|
|
245
|
+
hasDefault: false;
|
|
246
|
+
isPrimaryKey: false;
|
|
247
|
+
isAutoincrement: false;
|
|
248
|
+
hasRuntimeDefault: false;
|
|
249
|
+
enumValues: [string, ...string[]];
|
|
250
|
+
baseColumn: never;
|
|
251
|
+
identity: undefined;
|
|
252
|
+
generated: undefined;
|
|
253
|
+
}, {}, {
|
|
254
|
+
length: number | undefined;
|
|
255
|
+
}>;
|
|
256
|
+
body: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
257
|
+
name: "body";
|
|
258
|
+
tableName: "bug_reports";
|
|
259
|
+
dataType: "string";
|
|
260
|
+
columnType: "SQLiteText";
|
|
261
|
+
data: string;
|
|
262
|
+
driverParam: string;
|
|
263
|
+
notNull: false;
|
|
264
|
+
hasDefault: false;
|
|
265
|
+
isPrimaryKey: false;
|
|
266
|
+
isAutoincrement: false;
|
|
267
|
+
hasRuntimeDefault: false;
|
|
268
|
+
enumValues: [string, ...string[]];
|
|
269
|
+
baseColumn: never;
|
|
270
|
+
identity: undefined;
|
|
271
|
+
generated: undefined;
|
|
272
|
+
}, {}, {
|
|
273
|
+
length: number | undefined;
|
|
274
|
+
}>;
|
|
275
|
+
};
|
|
276
|
+
dialect: "sqlite";
|
|
277
|
+
}>;
|
|
278
|
+
//# sourceMappingURL=schema.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA8BtB,CAAA"}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
/**
|
|
3
|
+
* One table, in a database of its own.
|
|
4
|
+
*
|
|
5
|
+
* Bug reports do not live beside matches. They arrive unauthenticated, they are kept for far longer
|
|
6
|
+
* than a match is, and the thing that makes them worth having — an attached journal of somebody
|
|
7
|
+
* else's game — is exactly the thing a live multiplayer database should never be holding. Keeping
|
|
8
|
+
* them in a separate D1 instance (a separate SQLite file when self-hosted) means the two cannot
|
|
9
|
+
* share a lock, a retention sweep, a migration lineage, or a blast radius: a report that arrives
|
|
10
|
+
* while a turn is sealing writes to a different database entirely, and a mistake in the intake path
|
|
11
|
+
* cannot reach a match.
|
|
12
|
+
*
|
|
13
|
+
* The journal itself is usually *not* here. `body` holds the base64 archive only when it is small
|
|
14
|
+
* and the deployment has nowhere better; otherwise `blob_key` names an object in R2 (or in a
|
|
15
|
+
* directory, self-hosted) and `body` stays null. Exactly one of the two is set — see
|
|
16
|
+
* `BugReportService`.
|
|
17
|
+
*/
|
|
18
|
+
export const bugReports = sqliteTable('bug_reports', {
|
|
19
|
+
id: text('id').primaryKey(),
|
|
20
|
+
receivedAt: integer('received_at', { mode: 'timestamp_ms' }).notNull(),
|
|
21
|
+
/** What the player typed, verbatim. Never parsed, never interpolated anywhere. */
|
|
22
|
+
message: text('message').notNull(),
|
|
23
|
+
clientVersion: text('client_version').notNull(),
|
|
24
|
+
clientPlatform: text('client_platform').notNull(),
|
|
25
|
+
/** The triage fields (`bugReportContextSchema`), or null when the client sent none. */
|
|
26
|
+
context: text('context', { mode: 'json' }),
|
|
27
|
+
// --- The attached journal, when there is one. -------------------------------------------
|
|
28
|
+
/** Null when no journal came with the report. */
|
|
29
|
+
stateCodec: text('state_codec'),
|
|
30
|
+
stateFormatVersion: integer('state_format_version'),
|
|
31
|
+
stateUncompressedBytes: integer('state_uncompressed_bytes'),
|
|
32
|
+
stateCompressedBytes: integer('state_compressed_bytes'),
|
|
33
|
+
/** SHA-256 of the compressed archive, lowercase hex, as the client computed it. */
|
|
34
|
+
stateSha256: text('state_sha256'),
|
|
35
|
+
stateAnonymized: integer('state_anonymized', { mode: 'boolean' }),
|
|
36
|
+
/** Object key in the blob store, when the archive was written there. */
|
|
37
|
+
blobKey: text('blob_key'),
|
|
38
|
+
/** Base64 archive, only for a small one on a deployment with no blob store. */
|
|
39
|
+
body: text('body'),
|
|
40
|
+
}, (table) => [
|
|
41
|
+
// Triage reads newest-first and nothing else does a range scan over this table.
|
|
42
|
+
index('bug_reports_received_idx').on(table.receivedAt),
|
|
43
|
+
]);
|
|
44
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAA;AAE3E;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,WAAW,CACnC,aAAa,EACb;IACE,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE;IAC3B,UAAU,EAAE,OAAO,CAAC,aAAa,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC,OAAO,EAAE;IACtE,kFAAkF;IAClF,OAAO,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE;IAClC,aAAa,EAAE,IAAI,CAAC,gBAAgB,CAAC,CAAC,OAAO,EAAE;IAC/C,cAAc,EAAE,IAAI,CAAC,iBAAiB,CAAC,CAAC,OAAO,EAAE;IACjD,uFAAuF;IACvF,OAAO,EAAE,IAAI,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;IAE1C,2FAA2F;IAC3F,iDAAiD;IACjD,UAAU,EAAE,IAAI,CAAC,aAAa,CAAC;IAC/B,kBAAkB,EAAE,OAAO,CAAC,sBAAsB,CAAC;IACnD,sBAAsB,EAAE,OAAO,CAAC,0BAA0B,CAAC;IAC3D,oBAAoB,EAAE,OAAO,CAAC,wBAAwB,CAAC;IACvD,mFAAmF;IACnF,WAAW,EAAE,IAAI,CAAC,cAAc,CAAC;IACjC,eAAe,EAAE,OAAO,CAAC,kBAAkB,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;IACjE,wEAAwE;IACxE,OAAO,EAAE,IAAI,CAAC,UAAU,CAAC;IACzB,+EAA+E;IAC/E,IAAI,EAAE,IAAI,CAAC,MAAM,CAAC;CACnB,EACD,CAAC,KAAK,EAAE,EAAE,CAAC;IACT,gFAAgF;IAChF,KAAK,CAAC,0BAA0B,CAAC,CAAC,EAAE,CAAC,KAAK,CAAC,UAAU,CAAC;CACvD,CACF,CAAA"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type BugReportReceipt, type SubmitBugReportRequest } from '@chaos-overlords/contracts';
|
|
2
|
+
import { type Clock, type Logger } from '@chaos-overlords/kernel';
|
|
3
|
+
import type { BlobStore, BugReportRepository, StoredBugReport } from './ports';
|
|
4
|
+
export interface BugReportServiceDeps {
|
|
5
|
+
repository: BugReportRepository;
|
|
6
|
+
clock: Clock;
|
|
7
|
+
logger: Logger;
|
|
8
|
+
/**
|
|
9
|
+
* Where archives go. Omit it and a deployment keeps small ones in its database row and turns
|
|
10
|
+
* large ones away — see {@link BugReportService.submit}.
|
|
11
|
+
*/
|
|
12
|
+
blobs?: BlobStore;
|
|
13
|
+
}
|
|
14
|
+
export interface BugReportService {
|
|
15
|
+
submit(request: SubmitBugReportRequest): Promise<BugReportReceipt>;
|
|
16
|
+
/** Triage reads. Not reachable over HTTP; an operator calls them from a script or a console. */
|
|
17
|
+
get(id: string): Promise<StoredBugReport | null>;
|
|
18
|
+
list(limit: number): Promise<StoredBugReport[]>;
|
|
19
|
+
/** The archive itself, wherever it was kept. */
|
|
20
|
+
archive(id: string): Promise<Uint8Array | null>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Intake for one bug report.
|
|
24
|
+
*
|
|
25
|
+
* The interesting decision is where the attached journal goes, and it is decided by one thing:
|
|
26
|
+
* whether the deployment has a blob store.
|
|
27
|
+
*
|
|
28
|
+
* - **With one** (R2 on Cloudflare, a directory self-hosted) every archive goes to it, whatever its
|
|
29
|
+
* size, and the row keeps only the key and the metadata. This is the configuration a public
|
|
30
|
+
* server runs: a full-match journal is hundreds of kilobytes to a few megabytes compressed, D1
|
|
31
|
+
* refuses a row over 2 MB outright, and even the ones that fit would make every triage query drag
|
|
32
|
+
* megabytes of base64 through the query path to answer a question about a timestamp.
|
|
33
|
+
* - **Without one** an archive up to {@link BUG_REPORT_LIMITS.inlineStateBytes} is kept in the row,
|
|
34
|
+
* which is what makes a `pnpm --filter node-server start` with no configuration work end to end.
|
|
35
|
+
* A larger one is dropped and the report is still accepted, because the player's description is
|
|
36
|
+
* worth having even when the journal is not storable — the receipt says `omitted` so they know.
|
|
37
|
+
*
|
|
38
|
+
* Nothing here decompresses or parses an archive. It is opaque bytes with a digest, and keeping it
|
|
39
|
+
* that way is what makes accepting one from an unauthenticated stranger safe.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createBugReportService(deps: BugReportServiceDeps): BugReportService;
|
|
42
|
+
/**
|
|
43
|
+
* Base64 to bytes.
|
|
44
|
+
*
|
|
45
|
+
* `atob` rather than `Buffer`, which the Workers runtime only has under `nodejs_compat`; the
|
|
46
|
+
* contract has already held the string to the standard alphabet and a length that is a multiple of
|
|
47
|
+
* four, so this cannot be handed something it has to guess about.
|
|
48
|
+
*/
|
|
49
|
+
export declare function decodeBase64(value: string): Uint8Array;
|
|
50
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,gBAAgB,EACrB,KAAK,sBAAsB,EAC5B,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,KAAK,KAAK,EAAE,KAAK,MAAM,EAA8B,MAAM,yBAAyB,CAAA;AAE7F,OAAO,KAAK,EAAE,SAAS,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAE9E,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,mBAAmB,CAAA;IAC/B,KAAK,EAAE,KAAK,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,KAAK,CAAC,EAAE,SAAS,CAAA;CAClB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,CAAC,OAAO,EAAE,sBAAsB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAA;IAClE,gGAAgG;IAChG,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAAA;IAChD,IAAI,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAA;IAC/C,gDAAgD;IAChD,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAA;CAChD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,oBAAoB,GAAG,gBAAgB,CAqHnF;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAKtD"}
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { BUG_REPORT_LIMITS, } from '@chaos-overlords/contracts';
|
|
2
|
+
import { sha256Hex, ValidationError } from '@chaos-overlords/kernel';
|
|
3
|
+
import { ARCHIVE_CONTENT_TYPE, archiveKey } from './blobs';
|
|
4
|
+
/**
|
|
5
|
+
* Intake for one bug report.
|
|
6
|
+
*
|
|
7
|
+
* The interesting decision is where the attached journal goes, and it is decided by one thing:
|
|
8
|
+
* whether the deployment has a blob store.
|
|
9
|
+
*
|
|
10
|
+
* - **With one** (R2 on Cloudflare, a directory self-hosted) every archive goes to it, whatever its
|
|
11
|
+
* size, and the row keeps only the key and the metadata. This is the configuration a public
|
|
12
|
+
* server runs: a full-match journal is hundreds of kilobytes to a few megabytes compressed, D1
|
|
13
|
+
* refuses a row over 2 MB outright, and even the ones that fit would make every triage query drag
|
|
14
|
+
* megabytes of base64 through the query path to answer a question about a timestamp.
|
|
15
|
+
* - **Without one** an archive up to {@link BUG_REPORT_LIMITS.inlineStateBytes} is kept in the row,
|
|
16
|
+
* which is what makes a `pnpm --filter node-server start` with no configuration work end to end.
|
|
17
|
+
* A larger one is dropped and the report is still accepted, because the player's description is
|
|
18
|
+
* worth having even when the journal is not storable — the receipt says `omitted` so they know.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here decompresses or parses an archive. It is opaque bytes with a digest, and keeping it
|
|
21
|
+
* that way is what makes accepting one from an unauthenticated stranger safe.
|
|
22
|
+
*/
|
|
23
|
+
export function createBugReportService(deps) {
|
|
24
|
+
const { repository, clock, logger, blobs } = deps;
|
|
25
|
+
return {
|
|
26
|
+
async submit(request) {
|
|
27
|
+
const receivedAt = clock.now();
|
|
28
|
+
const id = crypto.randomUUID();
|
|
29
|
+
const stored = request.state
|
|
30
|
+
? await placeArchive(id, receivedAt, request.state)
|
|
31
|
+
: { state: null, outcome: 'not_sent' };
|
|
32
|
+
const report = {
|
|
33
|
+
id,
|
|
34
|
+
receivedAt,
|
|
35
|
+
message: request.message,
|
|
36
|
+
clientVersion: request.client.version,
|
|
37
|
+
clientPlatform: request.client.platform,
|
|
38
|
+
context: request.context ?? null,
|
|
39
|
+
state: stored.state,
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
await repository.insert(report);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// The archive was written first, so a failed insert would otherwise leave an object nobody
|
|
46
|
+
// can ever find a key for. Best effort: if this fails too the object is merely orphaned,
|
|
47
|
+
// which is a cleanup job rather than a lost report.
|
|
48
|
+
if (stored.state?.blobKey)
|
|
49
|
+
await forget(stored.state.blobKey);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
logger.info('bug report received', {
|
|
53
|
+
id,
|
|
54
|
+
bytes: stored.state?.compressedBytes ?? 0,
|
|
55
|
+
state: stored.outcome,
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
receivedAt: receivedAt.toISOString(),
|
|
60
|
+
stateStored: stored.outcome,
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
get: (id) => repository.get(id),
|
|
64
|
+
list: (limit) => repository.list(limit),
|
|
65
|
+
async archive(id) {
|
|
66
|
+
const report = await repository.get(id);
|
|
67
|
+
const state = report?.state;
|
|
68
|
+
if (!state)
|
|
69
|
+
return null;
|
|
70
|
+
if (state.blobKey)
|
|
71
|
+
return (await blobs?.get(state.blobKey)) ?? null;
|
|
72
|
+
return state.body === null ? null : decodeBase64(state.body);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Decodes, verifies and files the archive.
|
|
77
|
+
*
|
|
78
|
+
* The digest is checked before anything is written. The client computes it over the compressed
|
|
79
|
+
* bytes it sent, so a mismatch is a truncated or mangled upload — and an archive that does not
|
|
80
|
+
* hash to what its own report claims is worse than no archive, because somebody would spend an
|
|
81
|
+
* afternoon replaying it before finding out.
|
|
82
|
+
*/
|
|
83
|
+
async function placeArchive(id, receivedAt, state) {
|
|
84
|
+
const bytes = decodeBase64(state.body);
|
|
85
|
+
const digest = await sha256Hex(bytes);
|
|
86
|
+
if (digest !== state.sha256) {
|
|
87
|
+
throw new ValidationError('The attached state does not match its declared digest', {
|
|
88
|
+
reason: 'state_digest_mismatch',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const common = {
|
|
92
|
+
codec: state.codec,
|
|
93
|
+
replayFormatVersion: state.replayFormatVersion,
|
|
94
|
+
uncompressedBytes: state.uncompressedBytes,
|
|
95
|
+
compressedBytes: bytes.byteLength,
|
|
96
|
+
sha256: digest,
|
|
97
|
+
anonymized: state.anonymized,
|
|
98
|
+
};
|
|
99
|
+
if (blobs) {
|
|
100
|
+
const key = archiveKey(id, receivedAt);
|
|
101
|
+
await blobs.put(key, bytes, ARCHIVE_CONTENT_TYPE);
|
|
102
|
+
return { state: { ...common, blobKey: key, body: null }, outcome: 'stored' };
|
|
103
|
+
}
|
|
104
|
+
if (bytes.byteLength > BUG_REPORT_LIMITS.inlineStateBytes) {
|
|
105
|
+
logger.warn('bug report state dropped: no blob store and the archive is too large for a row', {
|
|
106
|
+
id,
|
|
107
|
+
bytes: bytes.byteLength,
|
|
108
|
+
limit: BUG_REPORT_LIMITS.inlineStateBytes,
|
|
109
|
+
});
|
|
110
|
+
return { state: null, outcome: 'omitted' };
|
|
111
|
+
}
|
|
112
|
+
return { state: { ...common, blobKey: null, body: state.body }, outcome: 'stored' };
|
|
113
|
+
}
|
|
114
|
+
async function forget(key) {
|
|
115
|
+
try {
|
|
116
|
+
await blobs?.delete(key);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
logger.warn('could not remove the archive of a report that failed to store', {
|
|
120
|
+
key,
|
|
121
|
+
error: String(error),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Base64 to bytes.
|
|
128
|
+
*
|
|
129
|
+
* `atob` rather than `Buffer`, which the Workers runtime only has under `nodejs_compat`; the
|
|
130
|
+
* contract has already held the string to the standard alphabet and a length that is a multiple of
|
|
131
|
+
* four, so this cannot be handed something it has to guess about.
|
|
132
|
+
*/
|
|
133
|
+
export function decodeBase64(value) {
|
|
134
|
+
const binary = atob(value);
|
|
135
|
+
const bytes = new Uint8Array(binary.length);
|
|
136
|
+
for (let index = 0; index < binary.length; index++)
|
|
137
|
+
bytes[index] = binary.charCodeAt(index);
|
|
138
|
+
return bytes;
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
CREATE TABLE `bug_reports` (
|
|
2
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
3
|
+
`received_at` integer NOT NULL,
|
|
4
|
+
`message` text NOT NULL,
|
|
5
|
+
`client_version` text NOT NULL,
|
|
6
|
+
`client_platform` text NOT NULL,
|
|
7
|
+
`context` text,
|
|
8
|
+
`state_codec` text,
|
|
9
|
+
`state_format_version` integer,
|
|
10
|
+
`state_uncompressed_bytes` integer,
|
|
11
|
+
`state_compressed_bytes` integer,
|
|
12
|
+
`state_sha256` text,
|
|
13
|
+
`state_anonymized` integer,
|
|
14
|
+
`blob_key` text,
|
|
15
|
+
`body` text
|
|
16
|
+
);
|
|
17
|
+
--> statement-breakpoint
|
|
18
|
+
CREATE INDEX `bug_reports_received_idx` ON `bug_reports` (`received_at`);
|