@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.
@@ -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"}
@@ -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`);