A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use @atproto/lex

+266 -42
+1
.eslintignore
··· 1 + src/lexicons/
+1 -1
backend/eslint.config.mjs
··· 6 6 7 7 export default tseslint.config( 8 8 { 9 - ignores: ['eslint.config.mjs'], 9 + ignores: ['eslint.config.mjs', 'src/lexicons/**'], 10 10 }, 11 11 eslint.configs.recommended, 12 12 ...tseslint.configs.recommendedTypeChecked,
+10
backend/lexicons.json
··· 1 + { 2 + "version": 1, 3 + "lexicons": [ 4 + { 5 + "id": "app.opnshelf.movie", 6 + "path": "lexicons/app/opnshelf/movie.json", 7 + "local": true 8 + } 9 + ] 10 + }
+4 -1
backend/package.json
··· 7 7 "license": "UNLICENSED", 8 8 "scripts": { 9 9 "build": "nest build", 10 - "prebuild": "prisma generate", 10 + "prebuild": "prisma generate && pnpm lex:build", 11 + "lex:build": "lex build --lexicons ./lexicons --out ./src/lexicons --clear", 12 + "lex:install": "lex install", 11 13 "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 14 "start": "nest start", 13 15 "start:dev": "nest start --watch", ··· 24 26 "dependencies": { 25 27 "@atproto/api": "^0.18.18", 26 28 "@atproto/identity": "^0.4.10", 29 + "@atproto/lex": "^0.0.14", 27 30 "@atproto/oauth-client-node": "^0.3.16", 28 31 "@atproto/sync": "^0.1.39", 29 32 "@nestjs/common": "^11.0.1",
+15 -28
backend/src/ingester/ingester.service.ts
··· 8 8 import { Firehose } from '@atproto/sync'; 9 9 import { IdResolver } from '@atproto/identity'; 10 10 import { PrismaService } from '../prisma/prisma.service'; 11 - 12 - const COLLECTION = 'app.opnshelf.movie'; 13 - 14 - // Expected record shape for app.opnshelf.movie 15 - interface MovieRecord { 16 - $type: string; 17 - movieId: string; 18 - source: string; 19 - watchedAt: string; 20 - createdAt: string; 21 - } 22 - 23 - function isMovieRecord(record: unknown): record is MovieRecord { 24 - if (!record || typeof record !== 'object') return false; 25 - const r = record as Record<string, unknown>; 26 - return ( 27 - (r.$type === COLLECTION || r.$type === `${COLLECTION}#main`) && 28 - typeof r.movieId === 'string' && 29 - typeof r.source === 'string' && 30 - typeof r.watchedAt === 'string' && 31 - typeof r.createdAt === 'string' 32 - ); 33 - } 11 + import { 12 + main as movieSchema, 13 + $nsid as COLLECTION, 14 + } from '../lexicons/app/opnshelf/movie'; 15 + import type { Main as MovieRecord } from '../lexicons/app/opnshelf/movie.defs'; 34 16 35 17 interface FirehoseEvent { 36 18 event: string; ··· 116 98 if (evt.collection !== COLLECTION) return; 117 99 118 100 const record = evt.record; 119 - if (!isMovieRecord(record)) { 101 + 102 + // Validate the record using the generated schema 103 + let movieRecord: MovieRecord; 104 + try { 105 + movieRecord = movieSchema.parse(record); 106 + } catch { 120 107 this.logger.debug('Received invalid movie record, skipping'); 121 108 return; 122 109 } ··· 153 140 rkey, 154 141 cid, 155 142 userDid: authorDid, 156 - movieId: record.movieId, 157 - watchedDate: new Date(record.watchedAt), 143 + movieId: movieRecord.movieId, 144 + watchedDate: new Date(movieRecord.watchedAt), 158 145 status: 'watched', 159 146 }, 160 147 update: { 161 148 cid, 162 - watchedDate: new Date(record.watchedAt), 149 + watchedDate: new Date(movieRecord.watchedAt), 163 150 status: 'watched', 164 151 }, 165 152 }); 166 153 167 154 this.logger.debug( 168 - `Indexed movie ${record.movieId} for user ${authorDid}`, 155 + `Indexed movie ${movieRecord.movieId} for user ${authorDid}`, 169 156 ); 170 157 } 171 158
+5
backend/src/lexicons/app.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as opnshelf from './app/opnshelf.js';
+5
backend/src/lexicons/app/opnshelf.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * as movie from './opnshelf/movie.js';
+63
backend/src/lexicons/app/opnshelf/movie.defs.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + import { l } from '@atproto/lex'; 6 + 7 + const $nsid = 'app.opnshelf.movie'; 8 + 9 + export { $nsid }; 10 + 11 + /** A tracked movie record for OpnShelf */ 12 + type Main = { 13 + $type: 'app.opnshelf.movie'; 14 + 15 + /** 16 + * TMDB movie ID 17 + */ 18 + movieId: string; 19 + 20 + /** 21 + * Source of the movie data (e.g., tmdb) 22 + */ 23 + source: string; 24 + 25 + /** 26 + * When the movie was watched 27 + */ 28 + watchedAt: l.DatetimeString; 29 + 30 + /** 31 + * Record creation timestamp 32 + */ 33 + createdAt: l.DatetimeString; 34 + }; 35 + 36 + export type { Main }; 37 + 38 + /** A tracked movie record for OpnShelf */ 39 + const main = l.record<'tid', Main>( 40 + 'tid', 41 + $nsid, 42 + l.object({ 43 + movieId: l.string(), 44 + source: l.string(), 45 + watchedAt: l.string({ format: 'datetime' }), 46 + createdAt: l.string({ format: 'datetime' }), 47 + }), 48 + ); 49 + 50 + export { main }; 51 + 52 + export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main), 53 + $build = /*#__PURE__*/ main.build.bind(main), 54 + $type = /*#__PURE__*/ main.$type; 55 + export const $assert = /*#__PURE__*/ main.assert.bind(main), 56 + $check = /*#__PURE__*/ main.check.bind(main), 57 + $cast = /*#__PURE__*/ main.cast.bind(main), 58 + $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main), 59 + $matches = /*#__PURE__*/ main.matches.bind(main), 60 + $parse = /*#__PURE__*/ main.parse.bind(main), 61 + $safeParse = /*#__PURE__*/ main.safeParse.bind(main), 62 + $validate = /*#__PURE__*/ main.validate.bind(main), 63 + $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main);
+6
backend/src/lexicons/app/opnshelf/movie.ts
··· 1 + /* 2 + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. 3 + */ 4 + 5 + export * from './movie.defs.js'; 6 + export * as $defs from './movie.defs.js';
+9 -6
backend/src/movies/movies.service.ts
··· 2 2 import { PrismaService } from '../prisma/prisma.service'; 3 3 import { ConfigService } from '@nestjs/config'; 4 4 import { Agent } from '@atproto/api'; 5 - 6 - const COLLECTION = 'app.opnshelf.movie'; 5 + import { 6 + main as movieSchema, 7 + $nsid as COLLECTION, 8 + } from '../lexicons/app/opnshelf/movie'; 9 + import type { Main as MovieRecord } from '../lexicons/app/opnshelf/movie.defs'; 7 10 8 11 export interface TMDBMovie { 9 12 id: number; ··· 118 121 const rkey = `movie-${movieId}`; 119 122 const now = new Date().toISOString(); 120 123 121 - // Build the AT Protocol record 122 - const record = { 123 - $type: COLLECTION, 124 + // Build the AT Protocol record using the generated schema builder 125 + // This ensures type safety and validation 126 + const record: MovieRecord = movieSchema.build({ 124 127 movieId, 125 128 source: 'tmdb', 126 129 watchedAt: now, 127 130 createdAt: now, 128 - }; 131 + }); 129 132 130 133 // Create agent from session and write record to user's PDS 131 134 const agent = new Agent(
+8 -6
lexicons/app/opnshelf/movie.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["movieId", "status"], 11 + "required": ["movieId", "source", "watchedAt", "createdAt"], 12 12 "properties": { 13 13 "movieId": { 14 14 "type": "string", 15 15 "description": "TMDB movie ID" 16 16 }, 17 - "status": { 17 + "source": { 18 18 "type": "string", 19 - "description": "Watch status of the movie (e.g., watched, wantToWatch, watching)" 19 + "description": "Source of the movie data (e.g., tmdb)" 20 20 }, 21 - "watchedDate": { 22 - "type": "datetime", 21 + "watchedAt": { 22 + "type": "string", 23 + "format": "datetime", 23 24 "description": "When the movie was watched" 24 25 }, 25 26 "createdAt": { 26 - "type": "datetime", 27 + "type": "string", 28 + "format": "datetime", 27 29 "description": "Record creation timestamp" 28 30 } 29 31 }
+139
pnpm-lock.yaml
··· 196 196 '@atproto/identity': 197 197 specifier: ^0.4.10 198 198 version: 0.4.10 199 + '@atproto/lex': 200 + specifier: ^0.0.14 201 + version: 0.0.14 199 202 '@atproto/oauth-client-node': 200 203 specifier: ^0.3.16 201 204 version: 0.3.16 ··· 458 461 '@atproto/jwk@0.6.0': 459 462 resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} 460 463 464 + '@atproto/lex-builder@0.0.13': 465 + resolution: {integrity: sha512-6p3Th0iL/y521zKGvoHPesg91TZNH0KvjgxWv7Y6Ucv2pbXVWGN4l+Ej1SWFKgcYZbqE4i4myD7w+8TopjCuoQ==} 466 + 467 + '@atproto/lex-cbor@0.0.10': 468 + resolution: {integrity: sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==} 469 + 461 470 '@atproto/lex-cbor@0.0.9': 462 471 resolution: {integrity: sha512-szkS569j1eZsIxZKh2VZHVq7pSpewy1wHh8c6nVYekHfYcJhFkevQq/DjTeatZ7YZKNReGYthQulgaZq2ytfWQ==} 463 472 473 + '@atproto/lex-client@0.0.11': 474 + resolution: {integrity: sha512-2DCidAlhATtZc1Z11PUd+C98BiW/Od4pWtDlQSAxkjHSC/56ZwuSkZQVx27ISk1HldfKVc9qUvQWA9nhmrxYIw==} 475 + 476 + '@atproto/lex-data@0.0.10': 477 + resolution: {integrity: sha512-FDbcy8VIUVzS9Mi1F8SMxbkL/jOUmRRpqbeM1xB4A0fMxeZJTxf6naAbFt4gYF3quu/+TPJGmio6/7cav05FqQ==} 478 + 464 479 '@atproto/lex-data@0.0.9': 465 480 resolution: {integrity: sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw==} 466 481 482 + '@atproto/lex-document@0.0.12': 483 + resolution: {integrity: sha512-+no+ZXyCNdOdjkOj6a4n4WHAQzZz3M6VJTwx7IQQC2+to41/4fFr6k8U1y3Jtq2lYcbHqapkJj3RGIxzFcrtwA==} 484 + 485 + '@atproto/lex-installer@0.0.14': 486 + resolution: {integrity: sha512-jyFF9v+9s/Ru64uoLd20u0wrDrZjRKtzOTGth4aNkHFwewSC/jUGsTe9/6L5BKKoj5PbG3oAzDA7F+nlTz+zlg==} 487 + 488 + '@atproto/lex-json@0.0.10': 489 + resolution: {integrity: sha512-L6MyXU17C5ODMeob8myQ2F3xvgCTvJUtM0ew8qSApnN//iDasB/FDGgd7ty4UVNmx4NQ/rtvz8xV94YpG6kneQ==} 490 + 467 491 '@atproto/lex-json@0.0.9': 468 492 resolution: {integrity: sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw==} 493 + 494 + '@atproto/lex-resolver@0.0.13': 495 + resolution: {integrity: sha512-CcqCE6W3ZVMVzAihatpVbXLxO15mtvddzRzovIJ9QxBTpUmtkdmIk9/gle0oAsRToTJYQy2a6dwAmnAosxP/XQ==} 496 + 497 + '@atproto/lex-schema@0.0.11': 498 + resolution: {integrity: sha512-1vLUPQIMeawKP6ehSx2RiqaJDkiseFTXyUk3C4PaoFCktaH8FgVgPVKgUSSy02m1pxVMKnsOBV5psKeN53HG+Q==} 499 + 500 + '@atproto/lex@0.0.14': 501 + resolution: {integrity: sha512-U2sq9v5Maw/btIDDHUh/XXuc/Nb9NccLPh+kzykLXP4MWIPtajdinRM1j+6KmKI0Btwit56TQ6e7J4DqarvKOQ==} 502 + hasBin: true 469 503 470 504 '@atproto/lexicon@0.6.1': 471 505 resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==} ··· 3022 3056 '@tokenizer/token@0.3.0': 3023 3057 resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} 3024 3058 3059 + '@ts-morph/common@0.28.1': 3060 + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} 3061 + 3025 3062 '@tsconfig/node10@1.0.12': 3026 3063 resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} 3027 3064 ··· 3944 3981 co@4.6.0: 3945 3982 resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} 3946 3983 engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} 3984 + 3985 + code-block-writer@13.0.3: 3986 + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} 3947 3987 3948 3988 collect-v8-coverage@1.0.3: 3949 3989 resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} ··· 6087 6127 resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} 6088 6128 engines: {node: '>= 0.8'} 6089 6129 6130 + path-browserify@1.0.1: 6131 + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} 6132 + 6090 6133 path-exists@4.0.0: 6091 6134 resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} 6092 6135 engines: {node: '>=8'} ··· 7107 7150 typescript: '*' 7108 7151 webpack: ^5.0.0 7109 7152 7153 + ts-morph@27.0.2: 7154 + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} 7155 + 7110 7156 ts-node@10.9.2: 7111 7157 resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} 7112 7158 hasBin: true ··· 7942 7988 multiformats: 9.9.0 7943 7989 zod: 3.25.76 7944 7990 7991 + '@atproto/lex-builder@0.0.13': 7992 + dependencies: 7993 + '@atproto/lex-document': 0.0.12 7994 + '@atproto/lex-schema': 0.0.11 7995 + prettier: 3.8.1 7996 + ts-morph: 27.0.2 7997 + tslib: 2.8.1 7998 + 7999 + '@atproto/lex-cbor@0.0.10': 8000 + dependencies: 8001 + '@atproto/lex-data': 0.0.10 8002 + tslib: 2.8.1 8003 + 7945 8004 '@atproto/lex-cbor@0.0.9': 7946 8005 dependencies: 7947 8006 '@atproto/lex-data': 0.0.9 7948 8007 tslib: 2.8.1 7949 8008 8009 + '@atproto/lex-client@0.0.11': 8010 + dependencies: 8011 + '@atproto/lex-data': 0.0.10 8012 + '@atproto/lex-json': 0.0.10 8013 + '@atproto/lex-schema': 0.0.11 8014 + tslib: 2.8.1 8015 + 8016 + '@atproto/lex-data@0.0.10': 8017 + dependencies: 8018 + multiformats: 9.9.0 8019 + tslib: 2.8.1 8020 + uint8arrays: 3.0.0 8021 + unicode-segmenter: 0.14.5 8022 + 7950 8023 '@atproto/lex-data@0.0.9': 7951 8024 dependencies: 7952 8025 multiformats: 9.9.0 ··· 7954 8027 uint8arrays: 3.0.0 7955 8028 unicode-segmenter: 0.14.5 7956 8029 8030 + '@atproto/lex-document@0.0.12': 8031 + dependencies: 8032 + '@atproto/lex-schema': 0.0.11 8033 + core-js: 3.48.0 8034 + tslib: 2.8.1 8035 + 8036 + '@atproto/lex-installer@0.0.14': 8037 + dependencies: 8038 + '@atproto/lex-builder': 0.0.13 8039 + '@atproto/lex-cbor': 0.0.10 8040 + '@atproto/lex-data': 0.0.10 8041 + '@atproto/lex-document': 0.0.12 8042 + '@atproto/lex-resolver': 0.0.13 8043 + '@atproto/lex-schema': 0.0.11 8044 + '@atproto/syntax': 0.4.3 8045 + tslib: 2.8.1 8046 + 8047 + '@atproto/lex-json@0.0.10': 8048 + dependencies: 8049 + '@atproto/lex-data': 0.0.10 8050 + tslib: 2.8.1 8051 + 7957 8052 '@atproto/lex-json@0.0.9': 7958 8053 dependencies: 7959 8054 '@atproto/lex-data': 0.0.9 7960 8055 tslib: 2.8.1 7961 8056 8057 + '@atproto/lex-resolver@0.0.13': 8058 + dependencies: 8059 + '@atproto-labs/did-resolver': 0.2.6 8060 + '@atproto/crypto': 0.4.5 8061 + '@atproto/lex-client': 0.0.11 8062 + '@atproto/lex-data': 0.0.10 8063 + '@atproto/lex-document': 0.0.12 8064 + '@atproto/lex-schema': 0.0.11 8065 + '@atproto/repo': 0.8.12 8066 + '@atproto/syntax': 0.4.3 8067 + tslib: 2.8.1 8068 + 8069 + '@atproto/lex-schema@0.0.11': 8070 + dependencies: 8071 + '@atproto/lex-data': 0.0.10 8072 + '@atproto/syntax': 0.4.3 8073 + tslib: 2.8.1 8074 + 8075 + '@atproto/lex@0.0.14': 8076 + dependencies: 8077 + '@atproto/lex-builder': 0.0.13 8078 + '@atproto/lex-client': 0.0.11 8079 + '@atproto/lex-data': 0.0.10 8080 + '@atproto/lex-installer': 0.0.14 8081 + '@atproto/lex-json': 0.0.10 8082 + '@atproto/lex-schema': 0.0.11 8083 + tslib: 2.8.1 8084 + yargs: 17.7.2 8085 + 7962 8086 '@atproto/lexicon@0.6.1': 7963 8087 dependencies: 7964 8088 '@atproto/common-web': 0.4.14 ··· 10931 11055 10932 11056 '@tokenizer/token@0.3.0': {} 10933 11057 11058 + '@ts-morph/common@0.28.1': 11059 + dependencies: 11060 + minimatch: 10.1.1 11061 + path-browserify: 1.0.1 11062 + tinyglobby: 0.2.15 11063 + 10934 11064 '@tsconfig/node10@1.0.12': {} 10935 11065 10936 11066 '@tsconfig/node12@1.0.11': {} ··· 12024 12154 12025 12155 co@4.6.0: {} 12026 12156 12157 + code-block-writer@13.0.3: {} 12158 + 12027 12159 collect-v8-coverage@1.0.3: {} 12028 12160 12029 12161 color-convert@1.9.3: ··· 14517 14649 14518 14650 parseurl@1.3.3: {} 14519 14651 14652 + path-browserify@1.0.1: {} 14653 + 14520 14654 path-exists@4.0.0: {} 14521 14655 14522 14656 path-is-absolute@1.0.1: {} ··· 15563 15697 source-map: 0.7.4 15564 15698 typescript: 5.9.3 15565 15699 webpack: 5.104.1 15700 + 15701 + ts-morph@27.0.2: 15702 + dependencies: 15703 + '@ts-morph/common': 0.28.1 15704 + code-block-writer: 13.0.3 15566 15705 15567 15706 ts-node@10.9.2(@types/node@22.19.7)(typescript@5.9.3): 15568 15707 dependencies: