From ff2b65f530816d284e7d1d6df9aef258e0772b7b Mon Sep 17 00:00:00 2001 From: Kat Schelonka Date: Thu, 21 Nov 2024 13:44:51 -0800 Subject: [PATCH] feat(notes): query to retrieve a note POCKET-10857 --- pnpm-lock.yaml | 226 ++++++++++++++-- servers/notes-api/codegen.ts | 2 +- servers/notes-api/jest.setup.js | 2 +- servers/notes-api/package.json | 23 +- .../20241121200307_note_tweaks/migration.sql | 14 + servers/notes-api/prisma/schema.prisma | 13 +- servers/notes-api/schema.graphql | 23 +- servers/notes-api/src/__generated__/db.d.ts | 66 +++++ .../__generated__/{types.ts => graphql.d.ts} | 43 ++- servers/notes-api/src/apollo/context.ts | 29 +- servers/notes-api/src/apollo/index.ts | 1 + servers/notes-api/src/apollo/resolvers.ts | 8 +- servers/notes-api/src/config/index.ts | 3 +- .../notes-api/src/datasources/NoteService.ts | 39 +++ servers/notes-api/src/datasources/db.ts | 29 ++ servers/notes-api/src/models/Note.ts | 80 ++++++ .../src/models/ProseMirrorDoc.spec.ts | 11 + .../notes-api/src/models/ProseMirrorDoc.ts | 26 ++ .../src/test/documents/basicText.json | 42 +++ .../src/test/documents/complexFormat.json | 249 ++++++++++++++++++ .../notes-api/src/test/example.integration.ts | 5 - servers/notes-api/src/test/example.spec.ts | 5 - servers/notes-api/src/test/fakes/Note.ts | 37 +++ .../notes-api/src/test/operations/index.ts | 1 + .../notes-api/src/test/operations/queries.ts | 28 ++ .../src/test/queries/note.integration.ts | 66 +++++ servers/notes-api/src/utils/dataloader.ts | 21 ++ servers/notes-api/tsconfig.json | 6 +- 28 files changed, 1029 insertions(+), 69 deletions(-) create mode 100644 servers/notes-api/prisma/migrations/20241121200307_note_tweaks/migration.sql create mode 100644 servers/notes-api/src/__generated__/db.d.ts rename servers/notes-api/src/__generated__/{types.ts => graphql.d.ts} (84%) create mode 100644 servers/notes-api/src/datasources/NoteService.ts create mode 100644 servers/notes-api/src/datasources/db.ts create mode 100644 servers/notes-api/src/models/Note.ts create mode 100644 servers/notes-api/src/models/ProseMirrorDoc.spec.ts create mode 100644 servers/notes-api/src/models/ProseMirrorDoc.ts create mode 100644 servers/notes-api/src/test/documents/basicText.json create mode 100644 servers/notes-api/src/test/documents/complexFormat.json delete mode 100644 servers/notes-api/src/test/example.integration.ts delete mode 100644 servers/notes-api/src/test/example.spec.ts create mode 100644 servers/notes-api/src/test/fakes/Note.ts create mode 100644 servers/notes-api/src/test/operations/index.ts create mode 100644 servers/notes-api/src/test/operations/queries.ts create mode 100644 servers/notes-api/src/test/queries/note.integration.ts create mode 100644 servers/notes-api/src/utils/dataloader.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 117adfe78..f3cd5e188 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,14 +29,14 @@ importers: dependencies: dotenv-cli: specifier: latest - version: 7.4.3 + version: 7.4.4 husky: specifier: 9.1.6 version: 9.1.6 devDependencies: '@commitlint/cli': specifier: 19.5.0 - version: 19.5.0(@types/node@22.9.0)(typescript@5.8.0-dev.20241119) + version: 19.5.0(@types/node@22.9.0)(typescript@5.8.0-dev.20241121) '@commitlint/config-conventional': specifier: ^19.5.0 version: 19.5.0 @@ -45,7 +45,7 @@ importers: version: link:packages/eslint-config syncpack: specifier: ^13.0.0 - version: 13.0.0(typescript@5.8.0-dev.20241119) + version: 13.0.0(typescript@5.8.0-dev.20241121) tsconfig: specifier: workspace:* version: link:packages/tsconfig @@ -75,7 +75,7 @@ importers: version: 0.20.10(constructs@10.4.2) cdktf-cli: specifier: 0.20.10 - version: 0.20.10(encoding@0.1.13)(ink@3.2.0(react@18.3.1))(react@18.3.1) + version: 0.20.10(encoding@0.1.13)(ink@5.0.1(react-devtools-core@4.28.5)(react@18.3.1))(react@18.3.1) constructs: specifier: 10.4.2 version: 10.4.2 @@ -118,7 +118,7 @@ importers: version: 0.20.10(constructs@10.4.2) cdktf-cli: specifier: 0.20.10 - version: 0.20.10(encoding@0.1.13)(ink@5.0.1(react-devtools-core@4.28.5)(react@18.3.1))(react@18.3.1) + version: 0.20.10(encoding@0.1.13)(ink@3.2.0(react@18.3.1))(react@18.3.1) constructs: specifier: 10.4.2 version: 10.4.2 @@ -3477,6 +3477,9 @@ importers: cors: specifier: 2.8.5 version: 2.8.5 + dataloader: + specifier: 2.2.2 + version: 2.2.2 express: specifier: 4.20.0 version: 4.20.0 @@ -3495,6 +3498,15 @@ importers: kysely: specifier: 0.27.4 version: 0.27.4 + pg: + specifier: ^8.13.1 + version: 8.13.1 + prosemirror-markdown: + specifier: ^1.13.1 + version: 1.13.1 + prosemirror-model: + specifier: ^1.23.0 + version: 1.23.0 tslib: specifier: 2.8.0 version: 2.8.0 @@ -3517,6 +3529,9 @@ importers: '@pocket-tools/eslint-config': specifier: workspace:* version: link:../../packages/eslint-config + '@types/chance': + specifier: 1.1.6 + version: 1.1.6 '@types/cors': specifier: ^2.8.17 version: 2.8.17 @@ -3529,9 +3544,15 @@ importers: '@types/node': specifier: ^22.8.2 version: 22.9.0 + '@types/pg': + specifier: ^8.11.10 + version: 8.11.10 '@types/supertest': specifier: ^6.0.2 version: 6.0.2 + chance: + specifier: 1.1.12 + version: 1.1.12 concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -3541,6 +3562,9 @@ importers: jest-extended: specifier: 4.0.2 version: 4.0.2(jest@29.7.0(@types/node@22.9.0)(ts-node@10.9.2(@types/node@22.9.0)(typescript@5.5.4))) + kysely-codegen: + specifier: ^0.16.4 + version: 0.16.5(kysely@0.27.4)(mysql2@3.11.3)(pg@8.13.1)(tarn@3.0.2) nock: specifier: 14.0.0-beta.11 version: 14.0.0-beta.11 @@ -7801,6 +7825,9 @@ packages: '@types/koa@2.15.0': resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/locutus@0.0.8': resolution: {integrity: sha512-96nd8Rx7DmcBuCYQ1PPNsBgo85VUV23yRO4twnFkvNUoqvm+yjlu+g9xsBBX5IlDitsEQmoMH0W5NBPLYvpgAw==} @@ -7813,9 +7840,15 @@ packages: '@types/luxon@3.4.2': resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/md5@2.3.5': resolution: {integrity: sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/memcached@2.2.10': resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} @@ -7882,6 +7915,9 @@ packages: '@types/pg-pool@2.0.6': resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} + '@types/pg@8.11.10': + resolution: {integrity: sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==} + '@types/pg@8.6.1': resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} @@ -9037,6 +9073,10 @@ packages: resolution: {integrity: sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==} engines: {node: '>= 8'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + crypt@0.0.2: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} @@ -9289,8 +9329,8 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} - dotenv-cli@7.4.3: - resolution: {integrity: sha512-lf1E+TL1xFeoOHy2hSO3kLkx3KX8CDi17ccn5z5dVCnk2PuWqUKAnBVgQmhfS0BPuzFbptTEHVcIKFsGF0NAcg==} + dotenv-cli@7.4.4: + resolution: {integrity: sha512-XkBYCG0tPIes+YZr4SpfFv76SQrV/LeCE8CI7JSEMi3VR9MvTihCGTOtbIexD6i2mXF+6px7trb1imVCXSNMDw==} hasBin: true dotenv-expand@10.0.0: @@ -11301,6 +11341,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + listr2@4.0.5: resolution: {integrity: sha512-juGHV1doQdpNT3GSTs9IUN43QJb7KHdF9uqg7Vufs/tG9VTzpFphqF4pm/ICdAABGQxsyNn9CiYA3StkI6jpwA==} engines: {node: '>=12'} @@ -11558,6 +11601,10 @@ packages: resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} engines: {node: '>=0.10.0'} + markdown-it@14.1.0: + resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} + hasBin: true + markdown-to-txt@2.0.1: resolution: {integrity: sha512-Hsj7KTN8k1gutlLum3vosHwVZGnv8/cbYKWVkUyo/D1rzOYddbDesILebRfOsaVfjIBJank/AVOySBlHAYqfZw==} @@ -11583,6 +11630,9 @@ packages: mdurl@1.0.1: resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -12088,6 +12138,9 @@ packages: obliterator@2.0.4: resolution: {integrity: sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==} + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + on-finished@2.3.0: resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} engines: {node: '>= 0.8'} @@ -12144,6 +12197,9 @@ packages: resolution: {integrity: sha512-ANIvzobt1rls2BDny5fWZ3ZVKyD6nscLvfFRpQgfWsythlcsVUC9kL0zq6j2Z5z9wwp1kd7wpsD/T9qNPVLCaQ==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + os-tmpdir@1.0.2: resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} engines: {node: '>=0.10.0'} @@ -12432,6 +12488,10 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} + pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + pg-pool@3.7.0: resolution: {integrity: sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==} peerDependencies: @@ -12444,6 +12504,10 @@ packages: resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} engines: {node: '>=4'} + pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + pg@8.13.1: resolution: {integrity: sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==} engines: {node: '>= 8.0.0'} @@ -12534,18 +12598,37 @@ packages: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} + postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + postgres-bytea@1.0.0: resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} engines: {node: '>=0.10.0'} + postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + postgres-date@1.0.7: resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} engines: {node: '>=0.10.0'} + postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + postgres-interval@1.2.0: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + + postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + prebuild-install@7.1.2: resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} engines: {node: '>=10'} @@ -12627,6 +12710,12 @@ packages: resolution: {integrity: sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==} engines: {node: '>= 8'} + prosemirror-markdown@1.13.1: + resolution: {integrity: sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==} + + prosemirror-model@1.23.0: + resolution: {integrity: sha512-Q/fgsgl/dlOAW9ILu4OOhYWQbc7TQd4BwKH/RwmUjyVf8682Be4zj3rOYdLnYEcGzyg8LL9Q5IWYKD8tdToreQ==} + proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} @@ -12661,6 +12750,10 @@ packages: pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@1.3.2: resolution: {integrity: sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==} @@ -13882,8 +13975,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@5.8.0-dev.20241119: - resolution: {integrity: sha512-qj+yMCPvzsR5XXQ2Xq5k5O1tfzhHx94vxSdGWAuagZQJ2tS8CTuQSXGoCsvaKg2rpSnxtCjPECFifCqFB9t5Dw==} + typescript@5.8.0-dev.20241121: + resolution: {integrity: sha512-6jaGeY7DfhSYIavJI6JeDMQFG5ezytKNOfXDThnyK7UI9MnMc3AxlPyjwJ1fAAAWQl8gf9nL7AvtPrPhGtxoKw==} engines: {node: '>=14.17'} hasBin: true @@ -13891,6 +13984,9 @@ packages: resolution: {integrity: sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -16169,11 +16265,11 @@ snapshots: dependencies: commander: 12.1.0 - '@commitlint/cli@19.5.0(@types/node@22.9.0)(typescript@5.8.0-dev.20241119)': + '@commitlint/cli@19.5.0(@types/node@22.9.0)(typescript@5.8.0-dev.20241121)': dependencies: '@commitlint/format': 19.5.0 '@commitlint/lint': 19.5.0 - '@commitlint/load': 19.5.0(@types/node@22.9.0)(typescript@5.8.0-dev.20241119) + '@commitlint/load': 19.5.0(@types/node@22.9.0)(typescript@5.8.0-dev.20241121) '@commitlint/read': 19.5.0 '@commitlint/types': 19.5.0 tinyexec: 0.3.1 @@ -16220,15 +16316,15 @@ snapshots: '@commitlint/rules': 19.5.0 '@commitlint/types': 19.5.0 - '@commitlint/load@19.5.0(@types/node@22.9.0)(typescript@5.8.0-dev.20241119)': + '@commitlint/load@19.5.0(@types/node@22.9.0)(typescript@5.8.0-dev.20241121)': dependencies: '@commitlint/config-validator': 19.5.0 '@commitlint/execute-rule': 19.5.0 '@commitlint/resolve-extends': 19.5.0 '@commitlint/types': 19.5.0 chalk: 5.3.0 - cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241119) - cosmiconfig-typescript-loader: 5.1.0(@types/node@22.9.0)(cosmiconfig@9.0.0(typescript@5.8.0-dev.20241119))(typescript@5.8.0-dev.20241119) + cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241121) + cosmiconfig-typescript-loader: 5.1.0(@types/node@22.9.0)(cosmiconfig@9.0.0(typescript@5.8.0-dev.20241121))(typescript@5.8.0-dev.20241121) lodash.isplainobject: 4.0.6 lodash.merge: 4.6.2 lodash.uniq: 4.5.0 @@ -19605,6 +19701,8 @@ snapshots: '@types/koa-compose': 3.2.8 '@types/node': 22.9.0 + '@types/linkify-it@5.0.0': {} + '@types/locutus@0.0.8': {} '@types/lodash@4.17.13': {} @@ -19613,8 +19711,15 @@ snapshots: '@types/luxon@3.4.2': {} + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/md5@2.3.5': {} + '@types/mdurl@2.0.0': {} + '@types/memcached@2.2.10': dependencies: '@types/node': 22.9.0 @@ -19691,7 +19796,13 @@ snapshots: '@types/pg-pool@2.0.6': dependencies: - '@types/pg': 8.6.1 + '@types/pg': 8.11.10 + + '@types/pg@8.11.10': + dependencies: + '@types/node': 22.9.0 + pg-protocol: 1.7.0 + pg-types: 4.0.2 '@types/pg@8.6.1': dependencies: @@ -21053,12 +21164,12 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 - cosmiconfig-typescript-loader@5.1.0(@types/node@22.9.0)(cosmiconfig@9.0.0(typescript@5.8.0-dev.20241119))(typescript@5.8.0-dev.20241119): + cosmiconfig-typescript-loader@5.1.0(@types/node@22.9.0)(cosmiconfig@9.0.0(typescript@5.8.0-dev.20241121))(typescript@5.8.0-dev.20241121): dependencies: '@types/node': 22.9.0 - cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241119) + cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241121) jiti: 1.21.6 - typescript: 5.8.0-dev.20241119 + typescript: 5.8.0-dev.20241121 cosmiconfig@8.3.6(typescript@5.5.4): dependencies: @@ -21078,14 +21189,14 @@ snapshots: optionalDependencies: typescript: 5.5.4 - cosmiconfig@9.0.0(typescript@5.8.0-dev.20241119): + cosmiconfig@9.0.0(typescript@5.8.0-dev.20241121): dependencies: env-paths: 2.2.1 import-fresh: 3.3.0 js-yaml: 4.1.0 parse-json: 5.2.0 optionalDependencies: - typescript: 5.8.0-dev.20241119 + typescript: 5.8.0-dev.20241121 cpu-features@0.0.2: dependencies: @@ -21149,6 +21260,12 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + crypt@0.0.2: {} crypto-random-string@2.0.0: {} @@ -21386,9 +21503,9 @@ snapshots: dependencies: is-obj: 2.0.0 - dotenv-cli@7.4.3: + dotenv-cli@7.4.4: dependencies: - cross-spawn: 7.0.5 + cross-spawn: 7.0.6 dotenv: 16.4.5 dotenv-expand: 10.0.0 minimist: 1.2.8 @@ -21409,7 +21526,7 @@ snapshots: dependencies: semver: 7.6.3 shelljs: 0.8.5 - typescript: 5.8.0-dev.20241119 + typescript: 5.8.0-dev.20241121 dreamopt@0.8.0: dependencies: @@ -23917,6 +24034,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + listr2@4.0.5(enquirer@2.4.1): dependencies: cli-truncate: 2.1.0 @@ -24162,6 +24283,15 @@ snapshots: map-cache@0.2.2: {} + markdown-it@14.1.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-to-txt@2.0.1: dependencies: lodash.escape: 4.0.1 @@ -24190,6 +24320,8 @@ snapshots: mdurl@1.0.1: {} + mdurl@2.0.0: {} + media-typer@0.3.0: {} memoizee@0.4.17: @@ -24578,6 +24710,8 @@ snapshots: obliterator@2.0.4: {} + obuf@1.1.2: {} + on-finished@2.3.0: dependencies: ee-first: 1.1.1 @@ -24661,6 +24795,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.0 + orderedmap@2.1.1: {} + os-tmpdir@1.0.2: {} outvariant@1.4.3: {} @@ -24911,6 +25047,8 @@ snapshots: pg-int8@1.0.1: {} + pg-numeric@1.0.2: {} + pg-pool@3.7.0(pg@8.13.1): dependencies: pg: 8.13.1 @@ -24925,6 +25063,16 @@ snapshots: postgres-date: 1.0.7 postgres-interval: 1.2.0 + pg-types@4.0.2: + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + pg@8.13.1: dependencies: pg-connection-string: 2.7.0 @@ -24992,14 +25140,26 @@ snapshots: postgres-array@2.0.0: {} + postgres-array@3.0.2: {} + postgres-bytea@1.0.0: {} + postgres-bytea@3.0.0: + dependencies: + obuf: 1.1.2 + postgres-date@1.0.7: {} + postgres-date@2.1.0: {} + postgres-interval@1.2.0: dependencies: xtend: 4.0.2 + postgres-interval@3.0.0: {} + + postgres-range@1.1.4: {} + prebuild-install@7.1.2: dependencies: detect-libc: 2.0.3 @@ -25094,6 +25254,16 @@ snapshots: propagate@2.0.1: {} + prosemirror-markdown@1.13.1: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.0 + prosemirror-model: 1.23.0 + + prosemirror-model@1.23.0: + dependencies: + orderedmap: 2.1.1 + proto-list@1.2.4: {} proto3-json-serializer@2.0.1: @@ -25150,6 +25320,8 @@ snapshots: end-of-stream: 1.4.4 once: 1.4.0 + punycode.js@2.3.1: {} + punycode@1.3.2: {} punycode@2.3.1: {} @@ -26133,13 +26305,13 @@ snapshots: '@pkgr/core': 0.1.1 tslib: 2.8.0 - syncpack@13.0.0(typescript@5.8.0-dev.20241119): + syncpack@13.0.0(typescript@5.8.0-dev.20241121): dependencies: '@effect/schema': 0.71.1(effect@3.6.5) chalk: 5.3.0 chalk-template: 1.1.0 commander: 12.1.0 - cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241119) + cosmiconfig: 9.0.0(typescript@5.8.0-dev.20241121) effect: 3.6.5 enquirer: 2.4.1 fast-check: 3.21.0 @@ -26517,10 +26689,12 @@ snapshots: typescript@5.5.4: {} - typescript@5.8.0-dev.20241119: {} + typescript@5.8.0-dev.20241121: {} ua-parser-js@1.0.39: {} + uc.micro@2.1.0: {} + uglify-js@3.19.3: optional: true diff --git a/servers/notes-api/codegen.ts b/servers/notes-api/codegen.ts index 4e9824cce..3b31bf214 100644 --- a/servers/notes-api/codegen.ts +++ b/servers/notes-api/codegen.ts @@ -3,7 +3,7 @@ import { CodegenConfig } from '@graphql-codegen/cli'; const config: CodegenConfig = { schema: './schema.graphql', generates: { - './src/__generated__/types.ts': { + './src/__generated__/graphql.d.ts': { config: { federation: true, useIndexSignature: true, diff --git a/servers/notes-api/jest.setup.js b/servers/notes-api/jest.setup.js index bde5f5631..c29f678e0 100644 --- a/servers/notes-api/jest.setup.js +++ b/servers/notes-api/jest.setup.js @@ -5,7 +5,7 @@ process.env.AWS_DEFAULT_REGION = 'us-east-1'; process.env.DATABASE_URL = 'postgresql://pocket:password@localhost:5432/pocketnotes'; process.env.DATABASE_NAME = 'pocketnotes'; -process.env.DATABASE_USER = 'pkt_notes'; +process.env.DATABASE_USER = 'pocket'; process.env.DATABASE_PASSWORD = 'password'; process.env.DATABASE_HOST = 'localhost'; process.env.DATABASE_PORT = '5432'; diff --git a/servers/notes-api/package.json b/servers/notes-api/package.json index 87373d67e..f2e5337d5 100644 --- a/servers/notes-api/package.json +++ b/servers/notes-api/package.json @@ -16,18 +16,19 @@ "build-schema": "node dist/apollo/schema/buildSchema.js", "db:generate": "prisma generate", "db:push": "prisma db push --skip-generate", - "migrate:create": "dotenv -e .env.ci -- prisma migrate dev --create-only", "dev": "dotenv -e .env.ci -- pnpm run migrate:deploy && pnpm run build && pnpm run watch", - "migrate:deploy": "prisma migrate deploy", - "prisma:pull": "dotenv -e .env.ci -- prisma db pull", - "prisma:generate": "dotenv -e .env.ci -- prisma generate", - "migrate:dev": "dotenv -e .env.ci -- prisma migrate dev", - "migrate:reset": "dotenv -e .env.ci -- prisma migrate reset", "format": "eslint --fix", + "kysely:generate": "dotenv -e .env.ci -- kysely-codegen --out-file ./src/__generated__/db.d.ts", "lint": "eslint --fix-dry-run", - "prebuild": "graphql-codegen && dotenv -e .env.ci -- prisma generate", + "migrate:create": "dotenv -e .env.ci -- prisma migrate dev --create-only", + "migrate:deploy": "prisma migrate deploy", + "migrate:dev": "dotenv -e .env.ci -- prisma migrate dev && pnpm run kysely:generate", + "migrate:reset": "dotenv -e .env.ci -- prisma migrate reset", + "prebuild": "graphql-codegen && pnpm run prisma:generate", "pretest": "pnpm run prisma:generate", "pretest-integrations": "dotenv -e .env.ci -- prisma migrate reset --skip-seed --force", + "prisma:generate": "dotenv -e .env.ci -- prisma generate && pnpm run kysely:generate", + "prisma:pull": "dotenv -e .env.ci -- prisma db pull", "start": "pnpm run migrate:deploy && node dist/main.js", "test": "jest \"\\.spec\\.ts\"", "test-integrations": "jest \"\\.integration\\.ts\" --runInBand", @@ -45,12 +46,16 @@ "@pocket-tools/ts-logger": "workspace:*", "@sentry/node": "8.38.0", "cors": "2.8.5", + "dataloader": "2.2.2", "express": "4.20.0", "graphql": "16.9.0", "graphql-constraint-directive": "5.4.2", "graphql-scalars": "^1.23.0", "graphql-tag": "2.12.6", "kysely": "0.27.4", + "pg": "^8.13.1", + "prosemirror-markdown": "^1.13.1", + "prosemirror-model": "^1.23.0", "tslib": "2.8.0", "uuid": "^10.0.0" }, @@ -60,14 +65,18 @@ "@graphql-codegen/typescript-resolvers": "^4.1.0", "@parcel/watcher": "^2.4.1", "@pocket-tools/eslint-config": "workspace:*", + "@types/chance": "1.1.6", "@types/cors": "^2.8.17", "@types/express": "4.17.21", "@types/jest": "29.5.14", "@types/node": "^22.8.2", + "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", + "chance": "1.1.12", "concurrently": "^8.2.2", "jest": "29.7.0", "jest-extended": "4.0.2", + "kysely-codegen": "^0.16.4", "nock": "14.0.0-beta.11", "nodemon": "3.1.7", "prisma": "5.21.1", diff --git a/servers/notes-api/prisma/migrations/20241121200307_note_tweaks/migration.sql b/servers/notes-api/prisma/migrations/20241121200307_note_tweaks/migration.sql new file mode 100644 index 000000000..9b3f4b32b --- /dev/null +++ b/servers/notes-api/prisma/migrations/20241121200307_note_tweaks/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - Made the column `clippingId` on table `Clipping` required. This step will fail if there are existing NULL values in that column. + - Made the column `noteId` on table `Note` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Clipping" ALTER COLUMN "clippingId" SET NOT NULL; + +-- AlterTable +ALTER TABLE "Note" ADD COLUMN "archived" BOOLEAN NOT NULL DEFAULT false, +ALTER COLUMN "noteId" SET NOT NULL, +ALTER COLUMN "title" DROP NOT NULL; diff --git a/servers/notes-api/prisma/schema.prisma b/servers/notes-api/prisma/schema.prisma index 673765c6a..f1e939b3f 100644 --- a/servers/notes-api/prisma/schema.prisma +++ b/servers/notes-api/prisma/schema.prisma @@ -1,9 +1,3 @@ -generator kysely { - provider = "prisma-kysely" - output = "../node_modules/.kysely/client" - fileName = "types.ts" -} - datasource db { provider = "postgresql" url = env("DATABASE_URL") @@ -11,7 +5,7 @@ datasource db { model Clipping { id Int @id @default(autoincrement()) - clippingId String? @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid + clippingId String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid userId String @db.VarChar(300) noteId String @unique @db.Uuid sourceUrl String @@ -28,14 +22,15 @@ model Clipping { model Note { id Int @id @default(autoincrement()) - noteId String? @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid + noteId String @unique @default(dbgenerated("gen_random_uuid()")) @db.Uuid userId String @db.VarChar(300) - title String @db.VarChar(300) + title String? @db.VarChar(300) sourceUrl String? createdAt DateTime @default(dbgenerated("CURRENT_TIMESTAMP(0)")) @db.Timestamptz(0) updatedAt DateTime @db.Timestamptz(3) docContent Json? deleted Boolean @default(false) + archived Boolean @default(false) Clipping Clipping? @@index([updatedAt], map: "NoteUpdated") diff --git a/servers/notes-api/schema.graphql b/servers/notes-api/schema.graphql index c2c14a7ce..e6053ca4c 100644 --- a/servers/notes-api/schema.graphql +++ b/servers/notes-api/schema.graphql @@ -4,15 +4,17 @@ extend schema @composeDirective(name: "@constraint") scalar ISOString +scalar ValidUrl +scalar Markdown """ A Note is an entity which may contain extracted components from websites (clippings/snippets), user-generated rich text content, and may be linked to a source url. """ -type Note @key(fields: "id") { +type Note { """ - The Note's ID + This Note's identifier """ id: ID! """ @@ -26,7 +28,7 @@ type Note @key(fields: "id") { """ Markdown preview of the note content for summary view. """ - contentPreview: String + contentPreview: Markdown """ When this note was created """ @@ -40,6 +42,21 @@ type Note @key(fields: "id") { or via a Clipping, if applicable) """ savedItem: SavedItem + """ + The URL this entity was created from (either directly or via + a Clipping, if applicable). + """ + source: ValidUrl + """ + Whether this Note has been marked as archived (hide from default view). + """ + archived: Boolean! + """ + Whether this Note has been marked for deletion (will be eventually + removed from the server). Clients should delete Notes from their local + storage if this value is true. + """ + deleted: Boolean! } type SavedItem @key(fields: "url") { diff --git a/servers/notes-api/src/__generated__/db.d.ts b/servers/notes-api/src/__generated__/db.d.ts new file mode 100644 index 000000000..0dd9243e6 --- /dev/null +++ b/servers/notes-api/src/__generated__/db.d.ts @@ -0,0 +1,66 @@ +/** + * This file was generated by kysely-codegen. + * Please do not edit it manually. + */ + +import type { ColumnType } from "kysely"; + +export type Generated = T extends ColumnType + ? ColumnType + : ColumnType; + +export type Json = JsonValue; + +export type JsonArray = JsonValue[]; + +export type JsonObject = { + [x: string]: JsonValue | undefined; +}; + +export type JsonPrimitive = boolean | number | string | null; + +export type JsonValue = JsonArray | JsonObject | JsonPrimitive; + +export type Timestamp = ColumnType; + +export interface _PrismaMigrations { + applied_steps_count: Generated; + checksum: string; + finished_at: Timestamp | null; + id: string; + logs: string | null; + migration_name: string; + rolled_back_at: Timestamp | null; + started_at: Generated; +} + +export interface Clipping { + anchors: Json | null; + clippingId: Generated; + createdAt: Generated; + deleted: Generated; + html: string; + id: Generated; + noteId: string; + sourceUrl: string; + userId: string; +} + +export interface Note { + archived: Generated; + createdAt: Generated; + deleted: Generated; + docContent: Json | null; + id: Generated; + noteId: Generated; + sourceUrl: string | null; + title: string | null; + updatedAt: Timestamp; + userId: string; +} + +export interface DB { + _prisma_migrations: _PrismaMigrations; + Clipping: Clipping; + Note: Note; +} diff --git a/servers/notes-api/src/__generated__/types.ts b/servers/notes-api/src/__generated__/graphql.d.ts similarity index 84% rename from servers/notes-api/src/__generated__/types.ts rename to servers/notes-api/src/__generated__/graphql.d.ts index 9ecf81949..bede8967c 100644 --- a/servers/notes-api/src/__generated__/types.ts +++ b/servers/notes-api/src/__generated__/graphql.d.ts @@ -19,6 +19,8 @@ export type Scalars = { Int: { input: number; output: number; } Float: { input: number; output: number; } ISOString: { input: any; output: any; } + Markdown: { input: any; output: any; } + ValidUrl: { input: any; output: any; } _FieldSet: { input: any; output: any; } }; @@ -29,19 +31,32 @@ export type Scalars = { */ export type Note = { __typename?: 'Note'; + /** Whether this Note has been marked as archived (hide from default view). */ + archived: Scalars['Boolean']['output']; /** Markdown preview of the note content for summary view. */ - contentPreview?: Maybe; + contentPreview?: Maybe; /** When this note was created */ createdAt: Scalars['ISOString']['output']; + /** + * Whether this Note has been marked for deletion (will be eventually + * removed from the server). Clients should delete Notes from their local + * storage if this value is true. + */ + deleted: Scalars['Boolean']['output']; /** JSON representation of a ProseMirror document */ docContent?: Maybe; - /** The Note's ID */ + /** This Note's identifier */ id: Scalars['ID']['output']; /** * The SavedItem entity this note is attached to (either directly * or via a Clipping, if applicable) */ savedItem?: Maybe; + /** + * The URL this entity was created from (either directly or via + * a Clipping, if applicable). + */ + source?: Maybe; /** Title of this note */ title?: Maybe; /** When this note was last updated */ @@ -149,36 +164,46 @@ export type DirectiveResolverFn; + Markdown: ResolverTypeWrapper; Note: ResolverTypeWrapper; + Boolean: ResolverTypeWrapper; String: ResolverTypeWrapper; ID: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; SavedItem: ResolverTypeWrapper; - Boolean: ResolverTypeWrapper; + ValidUrl: ResolverTypeWrapper; }>; /** Mapping between all available schema types and the resolvers parents */ export type ResolversParentTypes = ResolversObject<{ ISOString: Scalars['ISOString']['output']; + Markdown: Scalars['Markdown']['output']; Note: Note; + Boolean: Scalars['Boolean']['output']; String: Scalars['String']['output']; ID: Scalars['ID']['output']; Query: {}; SavedItem: SavedItem; - Boolean: Scalars['Boolean']['output']; + ValidUrl: Scalars['ValidUrl']['output']; }>; export interface IsoStringScalarConfig extends GraphQLScalarTypeConfig { name: 'ISOString'; } +export interface MarkdownScalarConfig extends GraphQLScalarTypeConfig { + name: 'Markdown'; +} + export type NoteResolvers = ResolversObject<{ - __resolveReference?: ReferenceResolver, { __typename: 'Note' } & GraphQLRecursivePick, ContextType>; - contentPreview?: Resolver, ParentType, ContextType>; + archived?: Resolver; + contentPreview?: Resolver, ParentType, ContextType>; createdAt?: Resolver; + deleted?: Resolver; docContent?: Resolver, ParentType, ContextType>; id?: Resolver; savedItem?: Resolver, ParentType, ContextType>; + source?: Resolver, ParentType, ContextType>; title?: Resolver, ParentType, ContextType>; updatedAt?: Resolver; __isTypeOf?: IsTypeOfResolverFn; @@ -194,10 +219,16 @@ export type SavedItemResolvers; }>; +export interface ValidUrlScalarConfig extends GraphQLScalarTypeConfig { + name: 'ValidUrl'; +} + export type Resolvers = ResolversObject<{ ISOString?: GraphQLScalarType; + Markdown?: GraphQLScalarType; Note?: NoteResolvers; Query?: QueryResolvers; SavedItem?: SavedItemResolvers; + ValidUrl?: GraphQLScalarType; }>; diff --git a/servers/notes-api/src/apollo/context.ts b/servers/notes-api/src/apollo/context.ts index 5b7816b68..77fe688fa 100644 --- a/servers/notes-api/src/apollo/context.ts +++ b/servers/notes-api/src/apollo/context.ts @@ -1,9 +1,15 @@ import { Request } from 'express'; import { + AuthenticationError, PocketContext, PocketContextManager, } from '@pocket-tools/apollo-utils'; +import { Kysely } from 'kysely'; +import { DB } from '../__generated__/db'; +import { db } from '../datasources/db'; +import { NoteModel } from '../models/Note'; +import { Note } from '../test/fakes/Note'; /** * Context factory function. Creates a new context upon @@ -22,12 +28,31 @@ export async function getContext({ } export interface IContext extends PocketContext { - hello: string; + db: Kysely; + userId: string; + NoteModel: NoteModel; } export class ContextManager extends PocketContextManager implements IContext { + db: Kysely; + _userId: string; + NoteModel: NoteModel; constructor(options: { request: Request }) { super(options.request.headers); + // This should never happen due to constraints on the schema + // But we will include it for type safety + if (super.userId == null) { + throw new AuthenticationError( + 'Must be authenticated to use this service', + ); + } else { + this._userId = super.userId; + } + this.db = db; + this.NoteModel = new NoteModel(this); + } + /** Override because userId is guaranteed in this Context */ + override get userId(): string { + return this._userId; } - hello = 'hello'; } diff --git a/servers/notes-api/src/apollo/index.ts b/servers/notes-api/src/apollo/index.ts index 848085b8d..c21431059 100644 --- a/servers/notes-api/src/apollo/index.ts +++ b/servers/notes-api/src/apollo/index.ts @@ -1,2 +1,3 @@ export * from './server'; export * from './schema'; +export * from './context'; diff --git a/servers/notes-api/src/apollo/resolvers.ts b/servers/notes-api/src/apollo/resolvers.ts index 4d6abc4be..ba853b447 100644 --- a/servers/notes-api/src/apollo/resolvers.ts +++ b/servers/notes-api/src/apollo/resolvers.ts @@ -1,5 +1,11 @@ import { PocketDefaultScalars } from '@pocket-tools/apollo-utils'; +import { Resolvers } from '../__generated__/graphql'; -export const resolvers = { +export const resolvers: Resolvers = { ...PocketDefaultScalars, + Query: { + note(root, { id }, context) { + return context.NoteModel.load(id); + }, + }, }; diff --git a/servers/notes-api/src/config/index.ts b/servers/notes-api/src/config/index.ts index dd89bc7fa..3cc5a6337 100644 --- a/servers/notes-api/src/config/index.ts +++ b/servers/notes-api/src/config/index.ts @@ -42,9 +42,10 @@ export const config = { }, database: { host: process.env.DATABASE_HOST || 'localhost', - username: process.env.DATABASE_USER || 'pkt_notes', + username: process.env.DATABASE_USER || 'pocket', password: process.env.DATABASE_PASSWORD || 'password', dbname: process.env.DATABASE_NAME || 'pocketnotes', port: parseInt(process.env.DATABASE_PORT || '5432') || 5432, + maxPool: 10, }, }; diff --git a/servers/notes-api/src/datasources/NoteService.ts b/servers/notes-api/src/datasources/NoteService.ts new file mode 100644 index 000000000..1e217eb4e --- /dev/null +++ b/servers/notes-api/src/datasources/NoteService.ts @@ -0,0 +1,39 @@ +import { IContext } from '../apollo/context'; + +/** + * Database methods for retrieving and creating Notes + */ +export class NotesService { + constructor(public context: IContext) {} + /** + * Get one Note by ID (or undefined if it does + * not exist). + * @param noteId + * @returns + */ + async get(noteId: string) { + const result = await this.context.db + .selectFrom('Note') + .selectAll() + .where('noteId', '=', noteId) + .where('userId', '=', this.context.userId) + .executeTakeFirst(); + return result; + } + /** + * Get many Notes by their IDs + * If a Note ID does not exist, it will not be + * included. The result can possibly be an empty array. + * @param noteIds + * @returns + */ + async getMany(noteIds: readonly string[]) { + const result = await this.context.db + .selectFrom('Note') + .selectAll() + .where('userId', '=', this.context.userId) + .where('noteId', 'in', noteIds) + .execute(); + return result; + } +} diff --git a/servers/notes-api/src/datasources/db.ts b/servers/notes-api/src/datasources/db.ts new file mode 100644 index 000000000..35cd1fda6 --- /dev/null +++ b/servers/notes-api/src/datasources/db.ts @@ -0,0 +1,29 @@ +import { DB } from '../__generated__/db'; +import { Pool } from 'pg'; +import { Kysely, PostgresDialect } from 'kysely'; +import { config } from '../config'; + +const dialect = new PostgresDialect({ + pool: new Pool({ + database: config.database.dbname, + host: config.database.host, + user: config.database.username, + password: config.database.password, + port: config.database.port, + max: config.database.maxPool, + }), +}); + +let _db: Kysely; +const lazyDb = (): Kysely => { + if (_db != null) { + return _db; + } else { + _db = new Kysely({ + dialect, + }); + return _db; + } +}; + +export const db = lazyDb(); diff --git a/servers/notes-api/src/models/Note.ts b/servers/notes-api/src/models/Note.ts new file mode 100644 index 000000000..7643d31d4 --- /dev/null +++ b/servers/notes-api/src/models/Note.ts @@ -0,0 +1,80 @@ +import DataLoader from 'dataloader'; +import { Note } from '../__generated__/graphql'; +import { Note as NoteEntity } from '../__generated__/db'; +import { Selectable } from 'kysely'; +import { orderAndMap } from '../utils/dataloader'; +import { IContext } from '../apollo/context'; +import { NotesService } from '../datasources/NoteService'; +import { ProseMirrorDoc } from './ProseMirrorDoc'; + +/** + * Model for retrieving and creating Notes + */ +export class NoteModel { + loader: DataLoader | null>; + service: NotesService; + constructor(context: IContext) { + this.service = new NotesService(context); + this.loader = new DataLoader | null>( + async (keys: readonly string[]) => { + const notes = await this.service.getMany(keys); + return orderAndMap(keys, notes, 'noteId'); + }, + ); + } + /** + * Convert a Note response from the database into + * the desired GraphQL object. + * @param note + * @returns + */ + toGraphql(note: Selectable): Note { + const savedItem = note.sourceUrl != null ? { url: note.sourceUrl } : null; + return { + createdAt: note.createdAt, + docContent: + note.docContent != null ? JSON.stringify(note.docContent) : null, + id: note.noteId, + savedItem, + title: note.title, + updatedAt: note.updatedAt, + source: note.sourceUrl, + // TODO - Non-default schema + contentPreview: + note.docContent != null + ? new ProseMirrorDoc(note.docContent).preview + : null, + archived: note.archived, + deleted: note.deleted, + }; + } + /** + * Get multiple Notes by IDs. Prefer using `load` + * unless you need to bypass cache behavior. + */ + async getMany(ids: readonly string[]): Promise { + const notes = await this.service.getMany(ids); + return notes != null && notes.length > 0 + ? notes.map((note) => this.toGraphql(note)) + : []; + } + /** + * Get a single note by its id. + * Prefer using `load` unless you need to bypass cache + * behavior. Will return null if ID does not exist + * or is inaccessible for the user. + */ + async getOne(id: string): Promise { + const note = await this.service.get(id); + return note != null ? this.toGraphql(note) : null; + } + /** + * Get a single note by its id (using dataloader to batch load). + * Will return null if ID does not exist or is inaccessible + * for the user. + */ + async load(id: string): Promise { + const note = await this.loader.load(id); + return note != null ? this.toGraphql(note) : null; + } +} diff --git a/servers/notes-api/src/models/ProseMirrorDoc.spec.ts b/servers/notes-api/src/models/ProseMirrorDoc.spec.ts new file mode 100644 index 000000000..8057ebc62 --- /dev/null +++ b/servers/notes-api/src/models/ProseMirrorDoc.spec.ts @@ -0,0 +1,11 @@ +import basicText from '../test/documents/basicText.json'; +import { ProseMirrorDoc } from './ProseMirrorDoc'; +import { schema } from 'prosemirror-markdown'; + +describe('ProseMirrorDoc', () => { + // TODO - Improve specificity when preview format is decided + it('converts a multi-paragraph input to a string', () => { + const doc = new ProseMirrorDoc(basicText, schema); + expect(doc.preview).toBeString(); + }); +}); diff --git a/servers/notes-api/src/models/ProseMirrorDoc.ts b/servers/notes-api/src/models/ProseMirrorDoc.ts new file mode 100644 index 000000000..3a9b2dd98 --- /dev/null +++ b/servers/notes-api/src/models/ProseMirrorDoc.ts @@ -0,0 +1,26 @@ +import { Node, type Schema } from 'prosemirror-model'; +import { + defaultMarkdownSerializer, + schema as commonMarkSchema, +} from 'prosemirror-markdown'; + +/** + * Class for handling ProseMirror documents + */ +export class ProseMirrorDoc { + public readonly document: Node; + public readonly schema: Schema; + constructor(jsonDoc: any, schema?: Schema) { + this.schema = schema ?? commonMarkSchema; + this.document = Node.fromJSON(this.schema, jsonDoc); + } + /** + * Markdown preview of the document content. + * For now, returns the entire document serialized + * to [CommonMark](http://commonmark.org/), but will + * truncate/edit the content in the future. + */ + get preview() { + return defaultMarkdownSerializer.serialize(this.document); + } +} diff --git a/servers/notes-api/src/test/documents/basicText.json b/servers/notes-api/src/test/documents/basicText.json new file mode 100644 index 000000000..22d13d32a --- /dev/null +++ b/servers/notes-api/src/test/documents/basicText.json @@ -0,0 +1,42 @@ +{ + "type": "doc", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { "type": "text", "text": "“We should rewrite it all,” said Pham." } + ] + }, + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "“It’s been done,” said Sura, not looking up. She was preparing to go off-Watch, and had spent the last four days trying to root a problem out of the coldsleep automation." + } + ] + }, + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "“It’s been tried,” corrected Bret, just back from the freezers. “But even the top levels of fleet system code are enormous. You and a thousand of your friends would have to work for a century or so to reproduce it.” Trinli grinned evilly. “And guess what—even if you did, by the time you finished, you’d have your own set of inconsistencies. And you still wouldn’t be consistent with all the applications that might be needed now and then.”" + } + ] + }, + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "Sura gave up on her debugging for the moment. “The word for all this is ‘mature programming environment.’ Basically, when hardware performance has been pushed to its final limit, and programmers have had several centuries to code, you reach a point where there is far more signicant code than can be rationalized. The best you can do is understand the overall layering, and know how to search for the oddball tool that may come in handy—take the situation I have here.” She waved at the dependency chart she had been working on. “We are low on working fluid for the coffins. Like a million other things, there was none for sale on dear old Canberra. Well, the obvious thing is to move the coffins near the aft hull, and cool by direct radiation. We don’t have the proper equipment to support this—so lately, I’ve been doing my share of archeology. It seems that five hundred years ago, a similar thing happened after an in-system war at Torma. They hacked together a temperature maintenance package that is precisely what we need.”" + } + ] + } + ] +} diff --git a/servers/notes-api/src/test/documents/complexFormat.json b/servers/notes-api/src/test/documents/complexFormat.json new file mode 100644 index 000000000..03590e7e1 --- /dev/null +++ b/servers/notes-api/src/test/documents/complexFormat.json @@ -0,0 +1,249 @@ +{ + "type": "doc", + "content": [ + { + "type": "heading", + "attrs": { "textAlign": "left", "level": 1 }, + "content": [{ "type": "text", "text": "DAG" }] + }, + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "Dynamic programming compilation dog-piling Hacker News a place for everything yarn class. Service worker stateless domain backend streams consensus webpack quick sort. Machine learning streams npm JQuery class Hacker News duck typing k. Document object model bubble sort tl;dr fault tolerant modern bundle " + }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://en.wikipedia.org/wiki/Edsger_W._Dijkstra", + "target": "_blank" + } + } + ], + "text": "Dijkstra" + }, + { "type": "text", "text": " blockchain CSV ping." } + ] + }, + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { "type": "text", "text": "Source: " }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://coder-ipsum.tech/", + "target": "_blank" + } + } + ], + "text": "Coder Ipsum" + } + ] + }, + { + "type": "heading", + "attrs": { "textAlign": "left", "level": 1 }, + "content": [{ "type": "text", "text": "imagemagick" }] + }, + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "Documentation driven Dijkstra parent shareware " + }, + { "type": "text", "marks": [{ "type": "italic" }], "text": "uglify" }, + { + "type": "text", + "text": " yarn parameter data store static. Module XML virtual DOM concurrent MacBook flexbox imagemagick class bootcamp. Module Cloudfront j contribute coding +1 bitwise operator reflog test double distributed systems." + } + ] + }, + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "marks": [{ "type": "code" }], + "text": "assert true == true" + } + ] + }, + { + "type": "heading", + "attrs": { "textAlign": "left", "level": 2 }, + "content": [{ "type": "text", "text": "concurrent" }] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "If we sudo the code, we can get to the LIFO engineer through the behavior-driven PWA developer " + }, + { + "type": "text", + "marks": [ + { + "type": "link", + "attrs": { + "href": "https://en.wikipedia.org/wiki/Avocado", + "target": "_blank" + } + } + ], + "text": "avocado" + }, + { "type": "text", "text": "!" } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "If we grep the state, we can get to the LLVM child through the frontend CLI callback hell!" + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "So tree-shaking the progressive web app won't do anything, we need to sort the ecommerce IoT algorithm!" + } + ] + }, + { + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "I'll transact the progressive CLI browser, that should Linux the YAML Netscape!" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "listItem", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "I'll uglify the atomic OTP shareware, that should production the CSS array!" + } + ] + } + ] + } + ] + }, + { "type": "paragraph", "attrs": { "textAlign": "left" } }, + { + "type": "image-renderer", + "attrs": { + "src": "https://pocket-image-cache.com/648x/filters:format(png):extract_focal()/https%3A%2F%2Fs3.amazonaws.com%2Fpocket-collectionapi-prod-images%2F9a7ec937-018d-487a-afc8-c0120d43c9ae.jpeg", + "alt": "clocks screaming" + } + }, + { + "type": "paragraph", + "attrs": { "textAlign": "center" }, + "content": [ + { "type": "text", "text": "l(a" }, + { "type": "hardBreak" }, + { "type": "hardBreak" }, + { "type": "text", "text": "le" }, + { "type": "hardBreak" }, + { "type": "text", "text": "af" }, + { "type": "hardBreak" }, + { "type": "text", "text": "fa" }, + { "type": "hardBreak" }, + { "type": "text", "text": "ll" }, + { "type": "hardBreak" }, + { "type": "hardBreak" }, + { "type": "text", "text": "s)" }, + { "type": "hardBreak" }, + { "type": "text", "text": "one" }, + { "type": "hardBreak" }, + { "type": "text", "text": "l" }, + { "type": "hardBreak" }, + { "type": "hardBreak" }, + { "type": "text", "text": "iness" } + ] + }, + { + "type": "blockquote", + "content": [ + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "Linux Ubuntu callback UI lazy cowboy coding backend cross-post LIFO RPC. Maintainable quick sort contributor idiosyncratic contexts branch rm -rf *. Angular Safari idiosyncratic contexts node_modules diversity and inclusion machine learning compile infrastructure big O parameter. Animation scalable FP stack controller MIT license senior dynamic program shadow DOM." + } + ] + } + ] + }, + { + "type": "paragraph", + "attrs": { "textAlign": "left" }, + "content": [ + { + "type": "text", + "text": "Remote program bitcoin DevTools LIFO promise. Bitwise operator DAG compile Safari lang minimum viable product font parameter duck typing architecture. Neck SRE dynamic CSV callback Kubernetes TL. Optimize incognito resource Slack frame rate yarn. State React child array dynamic XML." + } + ] + }, + { "type": "paragraph", "attrs": { "textAlign": "left" } } + ] +} diff --git a/servers/notes-api/src/test/example.integration.ts b/servers/notes-api/src/test/example.integration.ts deleted file mode 100644 index fda593f9a..000000000 --- a/servers/notes-api/src/test/example.integration.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('integration test', () => { - it('needs a placeholder', () => { - expect(true).toBeTruthy(); - }); -}); diff --git a/servers/notes-api/src/test/example.spec.ts b/servers/notes-api/src/test/example.spec.ts deleted file mode 100644 index e4a0ddd75..000000000 --- a/servers/notes-api/src/test/example.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('spec test', () => { - it('needs a placeholder', () => { - expect(true).toBeTruthy(); - }); -}); diff --git a/servers/notes-api/src/test/fakes/Note.ts b/servers/notes-api/src/test/fakes/Note.ts new file mode 100644 index 000000000..f50019cf2 --- /dev/null +++ b/servers/notes-api/src/test/fakes/Note.ts @@ -0,0 +1,37 @@ +export const Note = (chance: Chance.Chance) => { + const timestamp = chance.hammertime(); + return { + id: chance.integer({ min: 0, max: 2000000 }), + noteId: chance.guid({ version: 4 }), + userId: chance.integer({ min: 1, max: 2000000 }).toString(), + title: chance.bool() ? chance.sentence({ words: 5 }) : undefined, + sourceUrl: chance.bool() ? chance.url() : undefined, + createdAt: new Date(timestamp), + updatedAt: new Date( + timestamp + chance.integer({ min: 0, max: 259200 * 1000 }), + ), + deleted: chance.bool({ likelihood: 10 }), + archived: chance.bool({ likelihood: 10 }), + docContent: docContent(chance), + }; +}; + +/** + * Generator for doc of basic plaintext paragraphs + * (configurable size) + */ +export const docContent = ( + chance: Chance.Chance, + options?: { paragraphs: number }, +) => { + const n = options?.paragraphs ?? chance.natural({ max: 5 }); + const paragraphs = [...Array(n).keys()].map((_) => ({ + type: 'paragraph', + attrs: { textAlign: 'left' }, + content: [{ type: 'text', text: chance.paragraph() }], + })); + return { + type: 'doc', + content: paragraphs, + }; +}; diff --git a/servers/notes-api/src/test/operations/index.ts b/servers/notes-api/src/test/operations/index.ts new file mode 100644 index 000000000..3cf1ef310 --- /dev/null +++ b/servers/notes-api/src/test/operations/index.ts @@ -0,0 +1 @@ +export * from './queries'; diff --git a/servers/notes-api/src/test/operations/queries.ts b/servers/notes-api/src/test/operations/queries.ts new file mode 100644 index 000000000..debb88efa --- /dev/null +++ b/servers/notes-api/src/test/operations/queries.ts @@ -0,0 +1,28 @@ +import { gql } from 'graphql-tag'; +import { print } from 'graphql'; + +const NoteFragment = gql` + fragment NoteFields on Note { + id + title + docContent + contentPreview + createdAt + updatedAt + savedItem { + url + } + source + archived + deleted + } +`; + +export const GET_NOTE = print(gql` + ${NoteFragment} + query GetNote($id: ID!) { + note(id: $id) { + ...NoteFields + } + } +`); diff --git a/servers/notes-api/src/test/queries/note.integration.ts b/servers/notes-api/src/test/queries/note.integration.ts new file mode 100644 index 000000000..74ad2e682 --- /dev/null +++ b/servers/notes-api/src/test/queries/note.integration.ts @@ -0,0 +1,66 @@ +import { type ApolloServer } from '@apollo/server'; +import request from 'supertest'; +import { IContext, startServer } from '../../apollo'; +import { type Application } from 'express'; +import { GET_NOTE } from '../operations'; +import { db } from '../../datasources/db'; +import { Note as NoteFaker } from '../fakes/Note'; +import { Chance } from 'chance'; +import { sql } from 'kysely'; +let app: Application; +let server: ApolloServer; +let graphQLUrl: string; +const chance = new Chance(); +const notes = [...Array(4).keys()].map((_) => NoteFaker(chance)); + +beforeAll(async () => { + // port 0 tells express to dynamically assign an available port + ({ app, server, url: graphQLUrl } = await startServer(0)); + await db + .insertInto('Note') + .values(notes) + .returning(['noteId', 'userId']) + .execute(); +}); +afterAll(async () => { + await sql`truncate table ${sql.table('Note')} CASCADE`.execute(db); + await server.stop(); + await db.destroy(); +}); + +describe('note', () => { + it.each(notes)('returns note data', async (noteSeed) => { + const { userId, noteId } = noteSeed; + const variables = { id: noteId }; + const res = await request(app) + .post(graphQLUrl) + .set({ userid: userId }) + .send({ query: GET_NOTE, variables }); + expect(res.body.errors).toBeUndefined(); + const note = res.body.data?.note; + // Matchers + expect(note).not.toBeUndefined(); + expect(note.createdAt).toBeDateString(); + expect(note.updatedAt).toBeDateString(); + expect(note.id).toEqual(noteId); + expect(note.deleted).toEqual(noteSeed.deleted); + expect(note.archived).toEqual(noteSeed.archived); + // Convert undefined to null for comparator + expect(note.title).toEqual(noteSeed.title ?? null); + expect(note.source).toEqual(noteSeed.sourceUrl ?? null); + if (noteSeed.docContent != null) { + expect(JSON.parse(note.docContent)).toEqual(noteSeed.docContent); + expect(note.contentPreview).toBeString(); + } else { + expect(note.docContent).toBeNull(); + expect(note.contentPreview).toBeNull(); + } + if (noteSeed.sourceUrl != null) { + expect(note.savedItem).toEqual({ + url: noteSeed.sourceUrl, + }); + } else { + expect(note.savedItem).toBeNull(); + } + }); +}); diff --git a/servers/notes-api/src/utils/dataloader.ts b/servers/notes-api/src/utils/dataloader.ts new file mode 100644 index 000000000..89e8b4812 --- /dev/null +++ b/servers/notes-api/src/utils/dataloader.ts @@ -0,0 +1,21 @@ +/** + * Function for reordering keys in case the order is not preserved when loading, + * or some keys are missing. + * @param keys keys passed to the dataloader + * @param notesResponse the response from the server/cache containing the data + * @returns an array of notes (or undefined) that match the shape of the keys input + */ +export function orderAndMap< + T extends number | string, + C extends keyof R, + R extends { [key in C]: T }, +>(keys: readonly T[], results: R[], keyColumn: C): Array { + const resultMapping = results.reduce( + (keyMap, currentNote) => { + keyMap[currentNote[keyColumn]] = currentNote; + return keyMap; + }, + {} as Record, + ); + return keys.map((key) => resultMapping[key]); +} diff --git a/servers/notes-api/tsconfig.json b/servers/notes-api/tsconfig.json index a77876bee..04ce6d66d 100644 --- a/servers/notes-api/tsconfig.json +++ b/servers/notes-api/tsconfig.json @@ -4,8 +4,10 @@ "outDir": "./dist", "rootDir": "./src", "sourceRoot": "", - "strict": true + "strict": true, + "resolveJsonModule": true }, "exclude": ["node_modules/", "dist/"], - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "files": ["node_modules/jest-extended/types/index.d.ts"] }