···
124
124
const uri = `at://${did}/${collection}/${rkey}`;
125
125
const cid = generateCid();
126
126
127
127
-
const op: PdsOperation = { type: "create", collection, did, rkey, record };
127
127
+
const op: PdsOperation = {
128
128
+
type: "create",
129
129
+
collection,
130
130
+
did,
131
131
+
rkey,
132
132
+
record: { $type: collection, ...record },
133
133
+
};
128
134
if (insideAfterCallback) {
129
135
state.pdsOps.push(op);
130
136
} else {
···
145
151
const uri = `at://${did}/${collection}/${opts.rkey}`;
146
152
const cid = generateCid();
147
153
148
148
-
const op: PdsOperation = { type: "update", collection, did, rkey: opts.rkey, record };
154
154
+
const op: PdsOperation = {
155
155
+
type: "update",
156
156
+
collection,
157
157
+
did,
158
158
+
rkey: opts.rkey,
159
159
+
record: { $type: collection, ...record },
160
160
+
};
149
161
if (insideAfterCallback) {
150
162
state.pdsOps.push(op);
151
163
} else {
···
3
3
* Creates fake Jetstream events and processes them through the real ingester handler
4
4
*/
5
5
6
6
+
import { cidForLex } from "@atproto/lex-cbor";
6
7
import { handleEvent, type IngesterDb } from "../../../ingester/src/handler";
7
8
import type { JetstreamEvent } from "../../../ingester/src/jetstream";
8
9
import { getTestDb } from "./test-db";
···
15
16
return `test_${Date.now()}_${++rkeyCounter}`;
16
17
}
17
18
18
18
-
function generateCid(): string {
19
19
-
return `bafytest${++cidCounter}`;
19
19
+
// Real CIDs: ingested records are validated against the lexicon, which
20
20
+
// enforces cid format on strong refs
21
21
+
async function generateCid(): Promise<string> {
22
22
+
return (await cidForLex({ test: ++cidCounter })).toString();
20
23
}
21
24
22
25
function generateTimeUs(): number {
···
48
51
): Promise<EmitResult> {
49
52
const db = getTestDb() as unknown as IngesterDb;
50
53
const rkey = existingRkey || generateRkey();
51
51
-
const cid = generateCid();
54
54
+
const cid = await generateCid();
52
55
const operation = existingRkey ? "update" : "create";
53
56
54
57
const event: JetstreamEvent = {
···
1
1
/**
2
2
* TID generator for tests
3
3
-
* Generates unique TIDs for trail stops
3
3
+
*
4
4
+
* Must produce real TIDs: ingested records are validated against the lexicon,
5
5
+
* which enforces tid format on stop ids and visitedStops.
4
6
*/
5
7
6
6
-
let tidCounter = 0;
8
8
+
import { TID } from "@atproto/common-web";
7
9
8
10
/**
9
11
* Generate a unique TID for a trail stop
10
12
*/
11
13
export function generateTid(): string {
12
12
-
return `tid_test_${Date.now()}_${++tidCounter}`;
14
14
+
return TID.nextStr();
13
15
}
14
16
15
17
/**
16
18
* Reset the TID counter (call in beforeEach)
19
19
+
* No-op with real TIDs, kept for test setup compatibility.
17
20
*/
18
18
-
export function resetTidCounter(): void {
19
19
-
tidCounter = 0;
20
20
-
}
21
21
+
export function resetTidCounter(): void {}
···
17
17
$type: "app.sidetrail.trail",
18
18
title: "Alice's First Trail",
19
19
description: "Her debut",
20
20
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
20
20
+
stops: [
21
21
+
{ tid: generateTid(), title: "Stop", content: "Content" },
22
22
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
23
23
+
],
21
24
accentColor: "#ff0000",
22
25
backgroundColor: "#ffffff",
23
26
createdAt: now(),
···
27
30
$type: "app.sidetrail.trail",
28
31
title: "Alice's Second Trail",
29
32
description: "Another one",
30
30
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
33
33
+
stops: [
34
34
+
{ tid: generateTid(), title: "Stop", content: "Content" },
35
35
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
36
36
+
],
31
37
accentColor: "#00ff00",
32
38
backgroundColor: "#f0f0f0",
33
39
createdAt: now(),
···
42
48
$type: "app.sidetrail.trail",
43
49
title: "Old Trail",
44
50
description: "First",
45
45
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
51
51
+
stops: [
52
52
+
{ tid: generateTid(), title: "Stop", content: "Content" },
53
53
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
54
54
+
],
46
55
accentColor: "#111",
47
56
backgroundColor: "#eee",
48
57
createdAt: "2024-01-01T10:00:00Z",
···
52
61
$type: "app.sidetrail.trail",
53
62
title: "New Trail",
54
63
description: "Last",
55
55
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
64
64
+
stops: [
65
65
+
{ tid: generateTid(), title: "Stop", content: "Content" },
66
66
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
67
67
+
],
56
68
accentColor: "#222",
57
69
backgroundColor: "#ddd",
58
70
createdAt: "2024-06-01T10:00:00Z",
···
69
81
$type: "app.sidetrail.trail",
70
82
title: "Popular Trail",
71
83
description: "Many walkers",
72
72
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
84
84
+
stops: [
85
85
+
{ tid: stopTid, title: "Stop", content: "Content" },
86
86
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
87
87
+
],
73
88
accentColor: "#333",
74
89
backgroundColor: "#ccc",
75
90
createdAt: now(),
···
94
109
$type: "app.sidetrail.trail",
95
110
title: "Alice's Trail",
96
111
description: "By Alice",
97
97
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
112
112
+
stops: [
113
113
+
{ tid: generateTid(), title: "Stop", content: "Content" },
114
114
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
115
115
+
],
98
116
accentColor: "#aaa",
99
117
backgroundColor: "#bbb",
100
118
createdAt: now(),
···
104
122
$type: "app.sidetrail.trail",
105
123
title: "Bob's Trail",
106
124
description: "By Bob",
107
107
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
125
125
+
stops: [
126
126
+
{ tid: generateTid(), title: "Stop", content: "Content" },
127
127
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
128
128
+
],
108
129
accentColor: "#ccc",
109
130
backgroundColor: "#ddd",
110
131
createdAt: now(),
···
126
147
$type: "app.sidetrail.trail",
127
148
title: "Completable Trail",
128
149
description: "Finish this",
129
129
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
150
150
+
stops: [
151
151
+
{ tid: generateTid(), title: "Stop", content: "Content" },
152
152
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
153
153
+
],
130
154
accentColor: "#ff0000",
131
155
backgroundColor: "#ffffff",
132
156
createdAt: now(),
···
149
173
$type: "app.sidetrail.trail",
150
174
title: "First Completed",
151
175
description: "Test",
152
152
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
176
176
+
stops: [
177
177
+
{ tid: generateTid(), title: "Stop", content: "Content" },
178
178
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
179
179
+
],
153
180
accentColor: "#111",
154
181
backgroundColor: "#eee",
155
182
createdAt: now(),
···
159
186
$type: "app.sidetrail.trail",
160
187
title: "Last Completed",
161
188
description: "Test",
162
162
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
189
189
+
stops: [
190
190
+
{ tid: generateTid(), title: "Stop", content: "Content" },
191
191
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
192
192
+
],
163
193
accentColor: "#222",
164
194
backgroundColor: "#ddd",
165
195
createdAt: now(),
···
187
217
$type: "app.sidetrail.trail",
188
218
title: "Re-completable",
189
219
description: "Completed twice",
190
190
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
220
220
+
stops: [
221
221
+
{ tid: generateTid(), title: "Stop", content: "Content" },
222
222
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
223
223
+
],
191
224
accentColor: "#333",
192
225
backgroundColor: "#ccc",
193
226
createdAt: now(),
···
217
250
$type: "app.sidetrail.trail",
218
251
title: "Soon Deleted",
219
252
description: "Will be orphaned",
220
220
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
253
253
+
stops: [
254
254
+
{ tid: generateTid(), title: "Stop", content: "Content" },
255
255
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
256
256
+
],
221
257
accentColor: "#444",
222
258
backgroundColor: "#bbb",
223
259
createdAt: now(),
···
247
283
$type: "app.sidetrail.trail",
248
284
title: "Alice's Published Trail",
249
285
description: "Created by Alice",
250
250
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
286
286
+
stops: [
287
287
+
{ tid: generateTid(), title: "Stop", content: "Content" },
288
288
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
289
289
+
],
251
290
accentColor: "#ff0000",
252
291
backgroundColor: "#ffffff",
253
292
createdAt: now(),
···
258
297
$type: "app.sidetrail.trail",
259
298
title: "Bob's Trail",
260
299
description: "Created by Bob",
261
261
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
300
300
+
stops: [
301
301
+
{ tid: generateTid(), title: "Stop", content: "Content" },
302
302
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
303
303
+
],
262
304
accentColor: "#00ff00",
263
305
backgroundColor: "#f0f0f0",
264
306
createdAt: now(),
···
287
329
$type: "app.sidetrail.trail",
288
330
title: "Uncomplete Trail",
289
331
description: "Remove completion",
290
290
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
332
332
+
stops: [
333
333
+
{ tid: generateTid(), title: "Stop", content: "Content" },
334
334
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
335
335
+
],
291
336
accentColor: "#555",
292
337
backgroundColor: "#aaa",
293
338
createdAt: now(),
···
324
369
$type: "app.sidetrail.trail",
325
370
title: "Ephemeral Trail",
326
371
description: "Will be deleted",
327
327
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
372
372
+
stops: [
373
373
+
{ tid: generateTid(), title: "Stop", content: "Content" },
374
374
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
375
375
+
],
328
376
accentColor: "#666",
329
377
backgroundColor: "#999",
330
378
createdAt: now(),
···
346
394
$type: "app.sidetrail.trail",
347
395
title: "Popular Trail",
348
396
description: "Many completers",
349
349
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
397
397
+
stops: [
398
398
+
{ tid: generateTid(), title: "Stop", content: "Content" },
399
399
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
400
400
+
],
350
401
accentColor: "#777",
351
402
backgroundColor: "#888",
352
403
createdAt: now(),
···
72
72
$type: "app.sidetrail.trail",
73
73
title: "Lonely Trail",
74
74
description: "No one has walked this",
75
75
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
75
75
+
stops: [
76
76
+
{ tid: generateTid(), title: "Stop", content: "Content" },
77
77
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
78
78
+
],
76
79
accentColor: "#111",
77
80
backgroundColor: "#eee",
78
81
createdAt: now(),
···
96
99
$type: "app.sidetrail.trail",
97
100
title: "Popular Trail",
98
101
description: "Many people walking",
99
99
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
102
102
+
stops: [
103
103
+
{ tid: stopTid, title: "Stop", content: "Content" },
104
104
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
105
105
+
],
100
106
accentColor: "#ff0000",
101
107
backgroundColor: "#ffffff",
102
108
createdAt: now(),
···
167
173
$type: "app.sidetrail.trail",
168
174
title: "Activity Order Trail",
169
175
description: "Testing order",
170
170
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
176
176
+
stops: [
177
177
+
{ tid: stopTid, title: "Stop", content: "Content" },
178
178
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
179
179
+
],
171
180
accentColor: "#00ff00",
172
181
backgroundColor: "#f0fff0",
173
182
createdAt: now(),
···
259
268
$type: "app.sidetrail.trail",
260
269
title: "Walkers and Completers",
261
270
description: "Mixed activity",
262
262
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
271
271
+
stops: [
272
272
+
{ tid: stopTid, title: "Stop", content: "Content" },
273
273
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
274
274
+
],
263
275
accentColor: "#ff00ff",
264
276
backgroundColor: "#fff0ff",
265
277
createdAt: now(),
···
302
314
$type: "app.sidetrail.trail",
303
315
title: "IsYou Test",
304
316
description: "Testing current user marker",
305
305
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
317
317
+
stops: [
318
318
+
{ tid: stopTid, title: "Stop", content: "Content" },
319
319
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
320
320
+
],
306
321
accentColor: "#123456",
307
322
backgroundColor: "#654321",
308
323
createdAt: now(),
···
343
358
$type: "app.sidetrail.trail",
344
359
title: "Ephemeral Trail",
345
360
description: "Will be deleted",
346
346
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
361
361
+
stops: [
362
362
+
{ tid: stopTid, title: "Stop", content: "Content" },
363
363
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
364
364
+
],
347
365
accentColor: "#aaa",
348
366
backgroundColor: "#bbb",
349
367
createdAt: now(),
···
375
393
$type: "app.sidetrail.trail",
376
394
title: "Soon Gone",
377
395
description: "Will 404",
378
378
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
396
396
+
stops: [
397
397
+
{ tid: generateTid(), title: "Stop", content: "Content" },
398
398
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
399
399
+
],
379
400
accentColor: "#ccc",
380
401
backgroundColor: "#ddd",
381
402
createdAt: now(),
···
394
415
$type: "app.sidetrail.trail",
395
416
title: "Disappearing Trail",
396
417
description: "Walkers will be orphaned",
397
397
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
418
418
+
stops: [
419
419
+
{ tid: stopTid, title: "Stop", content: "Content" },
420
420
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
421
421
+
],
398
422
accentColor: "#111",
399
423
backgroundColor: "#222",
400
424
createdAt: now(),
···
425
449
$type: "app.sidetrail.trail",
426
450
title: "Completed Then Deleted",
427
451
description: "Completion will be orphaned",
428
428
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
452
452
+
stops: [
453
453
+
{ tid: generateTid(), title: "Stop", content: "Content" },
454
454
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
455
455
+
],
429
456
accentColor: "#333",
430
457
backgroundColor: "#444",
431
458
createdAt: now(),
···
451
478
$type: "app.sidetrail.trail",
452
479
title: "Badge Trail",
453
480
description: "Badge will disappear",
454
454
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
481
481
+
stops: [
482
482
+
{ tid: generateTid(), title: "Stop", content: "Content" },
483
483
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
484
484
+
],
455
485
accentColor: "#abcdef",
456
486
backgroundColor: "#fedcba",
457
487
createdAt: now(),
···
486
516
$type: "app.sidetrail.trail",
487
517
title: "Trail A - Old Activity",
488
518
description: "Walked a while ago",
489
489
-
stops: [{ tid: stopTidA, title: "Stop", content: "Content" }],
519
519
+
stops: [
520
520
+
{ tid: stopTidA, title: "Stop", content: "Content" },
521
521
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
522
522
+
],
490
523
accentColor: "#111",
491
524
backgroundColor: "#eee",
492
525
createdAt: now(),
···
496
529
$type: "app.sidetrail.trail",
497
530
title: "Trail B - Recent Activity",
498
531
description: "Just walked",
499
499
-
stops: [{ tid: stopTidB, title: "Stop", content: "Content" }],
532
532
+
stops: [
533
533
+
{ tid: stopTidB, title: "Stop", content: "Content" },
534
534
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
535
535
+
],
500
536
accentColor: "#222",
501
537
backgroundColor: "#ddd",
502
538
createdAt: now(),
···
537
573
$type: "app.sidetrail.trail",
538
574
title: "Trail A - One Walker",
539
575
description: "One person walking",
540
540
-
stops: [{ tid: stopTidA, title: "Stop", content: "Content" }],
576
576
+
stops: [
577
577
+
{ tid: stopTidA, title: "Stop", content: "Content" },
578
578
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
579
579
+
],
541
580
accentColor: "#111",
542
581
backgroundColor: "#eee",
543
582
createdAt: now(),
···
547
586
$type: "app.sidetrail.trail",
548
587
title: "Trail B - Three Walkers",
549
588
description: "Three people walking",
550
550
-
stops: [{ tid: stopTidB, title: "Stop", content: "Content" }],
589
589
+
stops: [
590
590
+
{ tid: stopTidB, title: "Stop", content: "Content" },
591
591
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
592
592
+
],
551
593
accentColor: "#222",
552
594
backgroundColor: "#ddd",
553
595
createdAt: now(),
···
603
645
$type: "app.sidetrail.trail",
604
646
title: "Trail A - Only Walk",
605
647
description: "Someone is walking",
606
606
-
stops: [{ tid: stopTidA, title: "Stop", content: "Content" }],
648
648
+
stops: [
649
649
+
{ tid: stopTidA, title: "Stop", content: "Content" },
650
650
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
651
651
+
],
607
652
accentColor: "#111",
608
653
backgroundColor: "#eee",
609
654
createdAt: now(),
···
613
658
$type: "app.sidetrail.trail",
614
659
title: "Trail B - Walk Plus Completion",
615
660
description: "Someone walked and completed",
616
616
-
stops: [{ tid: stopTidB, title: "Stop", content: "Content" }],
661
661
+
stops: [
662
662
+
{ tid: stopTidB, title: "Stop", content: "Content" },
663
663
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
664
664
+
],
617
665
accentColor: "#222",
618
666
backgroundColor: "#ddd",
619
667
createdAt: now(),
···
659
707
$type: "app.sidetrail.trail",
660
708
title: "Old Trail With Activity",
661
709
description: "Old but has activity",
662
662
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
710
710
+
stops: [
711
711
+
{ tid: stopTid, title: "Stop", content: "Content" },
712
712
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
713
713
+
],
663
714
accentColor: "#111",
664
715
backgroundColor: "#eee",
665
716
createdAt: sixtyDaysAgo,
···
671
722
$type: "app.sidetrail.trail",
672
723
title: "New Trail No Activity",
673
724
description: "Newer but no one has walked",
674
674
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
725
725
+
stops: [
726
726
+
{ tid: generateTid(), title: "Stop", content: "Content" },
727
727
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
728
728
+
],
675
729
accentColor: "#222",
676
730
backgroundColor: "#ddd",
677
731
createdAt: oneDayAgo,
···
703
757
$type: "app.sidetrail.trail",
704
758
title: "Trail A - Author Self-Walk",
705
759
description: "Author walked their own trail",
706
706
-
stops: [{ tid: stopTidA, title: "Stop", content: "Content" }],
760
760
+
stops: [
761
761
+
{ tid: stopTidA, title: "Stop", content: "Content" },
762
762
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
763
763
+
],
707
764
accentColor: "#111",
708
765
backgroundColor: "#eee",
709
766
createdAt: now(),
···
713
770
$type: "app.sidetrail.trail",
714
771
title: "Trail B - Non-Author Walk",
715
772
description: "Someone else walked",
716
716
-
stops: [{ tid: stopTidB, title: "Stop", content: "Content" }],
773
773
+
stops: [
774
774
+
{ tid: stopTidB, title: "Stop", content: "Content" },
775
775
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
776
776
+
],
717
777
accentColor: "#222",
718
778
backgroundColor: "#ddd",
719
779
createdAt: now(),
···
758
818
$type: "app.sidetrail.trail",
759
819
title: "Lonely Trail",
760
820
description: "Nobody here yet",
761
761
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
821
821
+
stops: [
822
822
+
{ tid: generateTid(), title: "Stop", content: "Content" },
823
823
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
824
824
+
],
762
825
accentColor: "#555",
763
826
backgroundColor: "#666",
764
827
createdAt: now(),
···
12
12
*/
13
13
14
14
import { describe, it, expect } from "vitest";
15
15
-
import { emit, ALICE, BOB, setCurrentUser, generateTid, captureInitialState } from "./helpers";
15
15
+
import {
16
16
+
emit,
17
17
+
ALICE,
18
18
+
BOB,
19
19
+
setCurrentUser,
20
20
+
generateTid,
21
21
+
captureInitialState,
22
22
+
getTestDb,
23
23
+
} from "./helpers";
24
24
+
import { trails } from "./helpers/test-db";
16
25
import {
17
26
startWalk,
18
27
visitStop,
···
63
72
});
64
73
65
74
it("fails if trail has no stops", async () => {
66
66
-
const trail = await emit("app.sidetrail.trail", ALICE.did, {
67
67
-
$type: "app.sidetrail.trail",
68
68
-
title: "Empty Trail",
69
69
-
description: "No stops",
70
70
-
stops: [],
71
71
-
accentColor: "#ff0000",
72
72
-
backgroundColor: "#ffffff",
73
73
-
createdAt: now(),
75
75
+
// A stop-less trail can no longer be ingested (rejected by lexicon
76
76
+
// validation), but legacy rows could still exist in the index -
77
77
+
// startWalk guards against them. Insert the row directly.
78
78
+
const db = getTestDb();
79
79
+
const rkey = generateTid();
80
80
+
const uri = `at://${ALICE.did}/app.sidetrail.trail/${rkey}`;
81
81
+
const cid = "bafyreib2rxk3rh6kzwq6kekiwkvrccdvx2dcztj47jyhgkamlpc5o4kf2u";
82
82
+
await db.insert(trails).values({
83
83
+
uri,
84
84
+
cid,
85
85
+
authorDid: ALICE.did,
86
86
+
rkey,
87
87
+
record: {
88
88
+
$type: "app.sidetrail.trail",
89
89
+
title: "Empty Trail",
90
90
+
description: "No stops",
91
91
+
stops: [],
92
92
+
accentColor: "#ff0000",
93
93
+
backgroundColor: "#ffffff",
94
94
+
createdAt: now(),
95
95
+
},
96
96
+
createdAt: new Date(),
74
97
});
75
98
76
99
await captureInitialState();
77
100
setCurrentUser(BOB);
78
101
79
79
-
await expect(startWalk(trail.uri, trail.cid)).rejects.toThrow("Trail has no stops");
102
102
+
await expect(startWalk(uri, cid)).rejects.toThrow("Trail has no stops");
80
103
});
81
104
});
82
105
···
171
194
$type: "app.sidetrail.trail",
172
195
title: "Completable Trail",
173
196
description: "Can be finished",
174
174
-
stops: [{ tid: stop1, title: "Final Stop", content: "Done!" }],
197
197
+
stops: [
198
198
+
{ tid: stop1, title: "Final Stop", content: "Done!" },
199
199
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
200
200
+
],
175
201
accentColor: "#ffd700",
176
202
backgroundColor: "#ffffff",
177
203
createdAt: now(),
···
211
237
$type: "app.sidetrail.trail",
212
238
title: "Abandonable Trail",
213
239
description: "User might give up",
214
214
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
240
240
+
stops: [
241
241
+
{ tid: stop1, title: "Stop", content: "Content" },
242
242
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
243
243
+
],
215
244
accentColor: "#ff6b6b",
216
245
backgroundColor: "#ffffff",
217
246
createdAt: now(),
···
251
280
$type: "app.sidetrail.trail",
252
281
title: "Completed Trail",
253
282
description: "Already finished",
254
254
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
283
283
+
stops: [
284
284
+
{ tid: stop1, title: "Stop", content: "Content" },
285
285
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
286
286
+
],
255
287
accentColor: "#00ff00",
256
288
backgroundColor: "#ffffff",
257
289
createdAt: now(),
···
289
321
$type: "app.sidetrail.trail",
290
322
title: "Forgettable Trail",
291
323
description: "User wants to forget",
292
292
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
324
324
+
stops: [
325
325
+
{ tid: stop1, title: "Stop", content: "Content" },
326
326
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
327
327
+
],
293
328
accentColor: "#purple",
294
329
backgroundColor: "#ffffff",
295
330
createdAt: now(),
···
397
432
$type: "app.sidetrail.trail",
398
433
title: "Deletable Trail",
399
434
description: "Will be deleted",
400
400
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
435
435
+
stops: [
436
436
+
{ tid: stop1, title: "Stop", content: "Content" },
437
437
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
438
438
+
],
401
439
accentColor: "#ff0000",
402
440
backgroundColor: "#ffffff",
403
441
createdAt: now(),
···
433
471
$type: "app.sidetrail.trail",
434
472
title: "Completed Trail",
435
473
description: "Bob completed this",
436
436
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
474
474
+
stops: [
475
475
+
{ tid: stop1, title: "Stop", content: "Content" },
476
476
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
477
477
+
],
437
478
accentColor: "#00ff00",
438
479
backgroundColor: "#ffffff",
439
480
createdAt: now(),
···
463
504
$type: "app.sidetrail.trail",
464
505
title: "Trail with Both",
465
506
description: "Bob has walk and completion",
466
466
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
507
507
+
stops: [
508
508
+
{ tid: stop1, title: "Stop", content: "Content" },
509
509
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
510
510
+
],
467
511
accentColor: "#0000ff",
468
512
backgroundColor: "#ffffff",
469
513
createdAt: now(),
···
503
547
$type: "app.sidetrail.trail",
504
548
title: "Second Attempt Trail",
505
549
description: "Bob will abandon second attempt",
506
506
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
550
550
+
stops: [
551
551
+
{ tid: stop1, title: "Stop", content: "Content" },
552
552
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
553
553
+
],
507
554
accentColor: "#ff6b6b",
508
555
backgroundColor: "#ffffff",
509
556
createdAt: now(),
···
545
592
$type: "app.sidetrail.trail",
546
593
title: "Re-complete Trail",
547
594
description: "Bob will complete again",
548
548
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
595
595
+
stops: [
596
596
+
{ tid: stop1, title: "Stop", content: "Content" },
597
597
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
598
598
+
],
549
599
accentColor: "#ffd700",
550
600
backgroundColor: "#ffffff",
551
601
createdAt: now(),
···
587
637
$type: "app.sidetrail.trail",
588
638
title: "Forgettable Trail",
589
639
description: "Bob will forget everything",
590
590
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
640
640
+
stops: [
641
641
+
{ tid: stop1, title: "Stop", content: "Content" },
642
642
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
643
643
+
],
591
644
accentColor: "#purple",
592
645
backgroundColor: "#ffffff",
593
646
createdAt: now(),
···
633
686
$type: "app.sidetrail.trail",
634
687
title: "No Duplicate Test",
635
688
description: "Testing startWalk idempotency",
636
636
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
689
689
+
stops: [
690
690
+
{ tid: stop1, title: "Stop", content: "Content" },
691
691
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
692
692
+
],
637
693
accentColor: "#ff0000",
638
694
backgroundColor: "#ffffff",
639
695
createdAt: now(),
···
670
726
$type: "app.sidetrail.trail",
671
727
title: "Complete With Duplicates",
672
728
description: "Testing complete cleans up duplicates",
673
673
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
729
729
+
stops: [
730
730
+
{ tid: stop1, title: "Stop", content: "Content" },
731
731
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
732
732
+
],
674
733
accentColor: "#00ff00",
675
734
backgroundColor: "#ffffff",
676
735
createdAt: now(),
···
733
792
$type: "app.sidetrail.trail",
734
793
title: "Abandon With Duplicates",
735
794
description: "Testing abandon cleans up duplicates",
736
736
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
795
795
+
stops: [
796
796
+
{ tid: stop1, title: "Stop", content: "Content" },
797
797
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
798
798
+
],
737
799
accentColor: "#0000ff",
738
800
backgroundColor: "#ffffff",
739
801
createdAt: now(),
···
68
68
$type: "app.sidetrail.trail",
69
69
title,
70
70
description: "Test",
71
71
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
71
71
+
stops: [
72
72
+
{ tid: generateTid(), title: "Stop", content: "Content" },
73
73
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
74
74
+
],
72
75
accentColor: `#${title.replace(" ", "")}`,
73
76
backgroundColor: "#ffffff",
74
77
createdAt: now(),
···
239
242
$type: "app.sidetrail.trail",
240
243
title: "Activity Order Trail",
241
244
description: "Test",
242
242
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
245
245
+
stops: [
246
246
+
{ tid: stopTid, title: "Stop", content: "Content" },
247
247
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
248
248
+
],
243
249
accentColor: "#00ff00",
244
250
backgroundColor: "#f0fff0",
245
251
createdAt: now(),
···
295
301
$type: "app.sidetrail.trail",
296
302
title: "Completable Trail",
297
303
description: "Finish this one",
298
298
-
stops: [{ tid: stopTid, title: "Only Stop", content: "Done!" }],
304
304
+
stops: [
305
305
+
{ tid: stopTid, title: "Only Stop", content: "Done!" },
306
306
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
307
307
+
],
299
308
accentColor: "#ffd700",
300
309
backgroundColor: "#fffde7",
301
310
createdAt: now(),
···
329
338
$type: "app.sidetrail.trail",
330
339
title: "Re-completable",
331
340
description: "Can complete multiple times",
332
332
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
341
341
+
stops: [
342
342
+
{ tid: generateTid(), title: "Stop", content: "Content" },
343
343
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
344
344
+
],
333
345
accentColor: "#ff00ff",
334
346
backgroundColor: "#fff0ff",
335
347
createdAt: now(),
···
365
377
$type: "app.sidetrail.trail",
366
378
title: "Walk And Complete",
367
379
description: "Testing precedence",
368
368
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
380
380
+
stops: [
381
381
+
{ tid: stopTid, title: "Stop", content: "Content" },
382
382
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
383
383
+
],
369
384
accentColor: "#0000ff",
370
385
backgroundColor: "#f0f0ff",
371
386
createdAt: now(),
···
402
417
$type: "app.sidetrail.trail",
403
418
title: "Abandonable Trail",
404
419
description: "User might give up",
405
405
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
420
420
+
stops: [
421
421
+
{ tid: stopTid, title: "Stop", content: "Content" },
422
422
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
423
423
+
],
406
424
accentColor: "#ff6b6b",
407
425
backgroundColor: "#fff5f5",
408
426
createdAt: now(),
···
499
517
$type: "app.sidetrail.trail",
500
518
title: "Forgettable Trail",
501
519
description: "Remove all traces",
502
502
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
520
520
+
stops: [
521
521
+
{ tid: stopTid, title: "Stop", content: "Content" },
522
522
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
523
523
+
],
503
524
accentColor: "#9c27b0",
504
525
backgroundColor: "#f3e5f5",
505
526
createdAt: now(),
···
551
572
$type: "app.sidetrail.trail",
552
573
title: "Some Trail",
553
574
description: "Test",
554
554
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
575
575
+
stops: [
576
576
+
{ tid: generateTid(), title: "Stop", content: "Content" },
577
577
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
578
578
+
],
555
579
accentColor: "#123",
556
580
backgroundColor: "#456",
557
581
createdAt: now(),
···
581
605
$type: "app.sidetrail.trail",
582
606
title: "Trail",
583
607
description: "Test",
584
584
-
stops: [{ tid: stopTid, title: "Stop", content: "Content" }],
608
608
+
stops: [
609
609
+
{ tid: stopTid, title: "Stop", content: "Content" },
610
610
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
611
611
+
],
585
612
accentColor: "#abc",
586
613
backgroundColor: "#def",
587
614
createdAt: now(),
···
622
649
$type: "app.sidetrail.trail",
623
650
title,
624
651
description: "Test",
625
625
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
652
652
+
stops: [
653
653
+
{ tid: generateTid(), title: "Stop", content: "Content" },
654
654
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
655
655
+
],
626
656
accentColor: "#111",
627
657
backgroundColor: "#eee",
628
658
createdAt: now(),
···
654
684
$type: "app.sidetrail.trail",
655
685
title: "Fallback Test",
656
686
description: "Test",
657
657
-
stops: [{ tid: "s1", title: "Stop", content: "Content" }],
687
687
+
stops: [
688
688
+
{ tid: generateTid(), title: "Stop", content: "Content" },
689
689
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
690
690
+
],
658
691
accentColor: "#000",
659
692
backgroundColor: "#fff",
660
693
createdAt: now(),
···
693
726
$type: "app.sidetrail.trail",
694
727
title: "Trail With Duplicates",
695
728
description: "Testing deduplication",
696
696
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
729
729
+
stops: [
730
730
+
{ tid: stop1, title: "Stop", content: "Content" },
731
731
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
732
732
+
],
697
733
accentColor: "#ff0000",
698
734
backgroundColor: "#ffffff",
699
735
createdAt: now(),
···
731
767
$type: "app.sidetrail.trail",
732
768
title: "Duplicate Walk Test",
733
769
description: "Testing yourWalk dedup",
734
734
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
770
770
+
stops: [
771
771
+
{ tid: stop1, title: "Stop", content: "Content" },
772
772
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
773
773
+
],
735
774
accentColor: "#00ff00",
736
775
backgroundColor: "#ffffff",
737
776
createdAt: now(),
···
769
808
$type: "app.sidetrail.trail",
770
809
title: "Active Walkers Dedup",
771
810
description: "Testing walker dedup",
772
772
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
811
811
+
stops: [
812
812
+
{ tid: stop1, title: "Stop", content: "Content" },
813
813
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
814
814
+
],
773
815
accentColor: "#0000ff",
774
816
backgroundColor: "#ffffff",
775
817
createdAt: now(),
···
825
867
$type: "app.sidetrail.trail",
826
868
title: "Walkers List Dedup",
827
869
description: "Testing walkers dedup",
828
828
-
stops: [{ tid: stop1, title: "Stop", content: "Content" }],
870
870
+
stops: [
871
871
+
{ tid: stop1, title: "Stop", content: "Content" },
872
872
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
873
873
+
],
829
874
accentColor: "#ff00ff",
830
875
backgroundColor: "#ffffff",
831
876
createdAt: now(),
···
907
952
$type: "app.sidetrail.trail",
908
953
title: "Badge Dedup Trail",
909
954
description: "Testing badge dedup",
910
910
-
stops: [{ tid: generateTid(), title: "Stop", content: "Content" }],
955
955
+
stops: [
956
956
+
{ tid: generateTid(), title: "Stop", content: "Content" },
957
957
+
{ tid: generateTid(), title: "Filler stop", content: "Filler content" },
958
958
+
],
911
959
accentColor: "#badge1",
912
960
backgroundColor: "#ffffff",
913
961
createdAt: now(),
···
357
357
const errors: string[] = [];
358
358
const inlineErrors: Record<string, string> = {};
359
359
360
360
+
// Length limits mirror the lexicon (lexicons/app/sidetrail/trail.json) -
361
361
+
// records that exceed them would be rejected by our own ingester.
362
362
+
const graphemes = (s: string) => [...new Intl.Segmenter().segment(s)].length;
363
363
+
360
364
if (!draftData.title.trim()) {
361
365
errors.push("trail needs a title");
366
366
+
} else if (graphemes(draftData.title) > 64) {
367
367
+
errors.push("trail title is too long (max 64 characters)");
362
368
}
363
369
364
370
if (!draftData.description.trim()) {
365
371
errors.push("trail needs a description");
372
372
+
} else if (graphemes(draftData.description) > 300) {
373
373
+
errors.push("trail description is too long (max 300 characters)");
366
374
}
367
375
368
376
if (draftData.stops.length < 2) {
369
377
errors.push("trails need at least 2 stops to be published");
370
378
}
371
379
372
372
-
if (draftData.stops.length > 12) {
373
373
-
errors.push("trails can have a maximum of 12 stops");
380
380
+
if (draftData.stops.length > 24) {
381
381
+
errors.push("trails can have a maximum of 24 stops");
374
382
}
375
383
376
384
for (let i = 0; i < draftData.stops.length; i++) {
···
379
387
380
388
if (!stop.title.trim()) {
381
389
stopErrors.push("needs a title");
390
390
+
} else if (graphemes(stop.title) > 128) {
391
391
+
stopErrors.push("title is too long (max 128 characters)");
382
392
}
383
393
384
394
if (!stop.content.trim()) {
385
395
stopErrors.push("needs content");
396
396
+
} else if (graphemes(stop.content) > 5000) {
397
397
+
stopErrors.push("content is too long (max 5000 characters)");
398
398
+
}
399
399
+
400
400
+
if (stop.buttonText && graphemes(stop.buttonText) > 64) {
401
401
+
stopErrors.push("button text is too long (max 64 characters)");
386
402
}
387
403
388
404
if (stopErrors.length > 0) {
···
4
4
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
5
5
import { trails, walks, completions, accounts } from "@sidetrail/db";
6
6
import type { JetstreamEvent, AccountEvent } from "./jetstream.js";
7
7
+
import { validateRecord, type IndexedCollection } from "./lexicons.js";
7
8
8
9
export const COLLECTIONS = [
9
10
"app.sidetrail.trail",
···
236
237
}
237
238
238
239
const record = commit.record as Record<string, unknown>;
240
240
+
241
241
+
const validation = validateRecord(collection as IndexedCollection, record);
242
242
+
if (!validation.success) {
243
243
+
log(`Rejecting invalid ${collection} ${uri}: ${validation.reason}`);
244
244
+
return;
245
245
+
}
246
246
+
239
247
await ensureAccount(db, evt.did);
240
248
241
249
switch (collection) {
···
1
1
+
import { jsonToLex } from "@atproto/lex-json";
2
2
+
import { main as trail } from "../../lib/lexicons/app/sidetrail/trail.defs";
3
3
+
import { main as walk } from "../../lib/lexicons/app/sidetrail/walk.defs";
4
4
+
import { main as completion } from "../../lib/lexicons/app/sidetrail/completion.defs";
5
5
+
6
6
+
// Runtime lexicon schemas for every collection we index. Records that fail
7
7
+
// validation are rejected: PDSes don't reliably enforce third-party lexicons,
8
8
+
// so validating is the indexer's responsibility.
9
9
+
export const RECORD_SCHEMAS = {
10
10
+
"app.sidetrail.trail": trail,
11
11
+
"app.sidetrail.walk": walk,
12
12
+
"app.sidetrail.completion": completion,
13
13
+
} as const;
14
14
+
15
15
+
export type IndexedCollection = keyof typeof RECORD_SCHEMAS;
16
16
+
17
17
+
export function validateRecord(
18
18
+
collection: IndexedCollection,
19
19
+
record: unknown,
20
20
+
): { success: true } | { success: false; reason: string } {
21
21
+
// Records arrive as JSON (jetstream, listRecords, jsonb storage), where CID
22
22
+
// links are {"$link": ...} objects. Schemas validate lex values, so convert
23
23
+
// first; a record that can't even be converted (e.g. malformed CID) is invalid.
24
24
+
let lexValue: unknown;
25
25
+
try {
26
26
+
lexValue = jsonToLex(record as Parameters<typeof jsonToLex>[0]);
27
27
+
} catch (err) {
28
28
+
return { success: false, reason: `unparseable as lex: ${(err as Error).message}` };
29
29
+
}
30
30
+
const result = RECORD_SCHEMAS[collection].validate(lexValue);
31
31
+
if (result.success) return { success: true };
32
32
+
return {
33
33
+
success: false,
34
34
+
reason: result.error.issues
35
35
+
.map((issue) => `${issue.code} at ${issue.path.join(".") || "(root)"}`)
36
36
+
.join("; "),
37
37
+
};
38
38
+
}
···
32
32
"stops": {
33
33
"type": "array",
34
34
"minLength": 2,
35
35
-
"maxLength": 12,
35
35
+
"maxLength": 24,
36
36
"items": {
37
37
"type": "ref",
38
38
"ref": "#stop"
···
66
66
},
67
67
"title": {
68
68
"type": "string",
69
69
-
"maxGraphemes": 64,
70
70
-
"maxLength": 640,
69
69
+
"maxGraphemes": 128,
70
70
+
"maxLength": 1280,
71
71
"description": "stop title"
72
72
},
73
73
"content": {
74
74
"type": "string",
75
75
-
"maxGraphemes": 1000,
76
76
-
"maxLength": 10000,
75
75
+
"maxGraphemes": 5000,
76
76
+
"maxLength": 50000,
77
77
"description": "stop content text"
78
78
},
79
79
"buttonText": {
80
80
"type": "string",
81
81
-
"maxGraphemes": 20,
82
82
-
"maxLength": 200,
81
81
+
"maxGraphemes": 64,
82
82
+
"maxLength": 640,
83
83
"description": "custom button text (e.g., 'got it', 'done that')"
84
84
},
85
85
"external": {