├── .github ├── CODEOWNERS └── workflows │ └── main.yml ├── .eslintignore ├── .husky ├── pre-commit └── commit-msg ├── commitlint.config.js ├── .releaserc.json ├── .prettierrc ├── lint-staged.config.js ├── .gitignore ├── package.config.ts ├── src ├── utils.ts ├── node │ ├── support.ts │ └── getDocuments.ts ├── drafts.ts ├── browser │ ├── support.ts │ ├── index.ts │ └── getDocuments.ts ├── patch.ts ├── index.ts ├── exportUtils.ts ├── groqStore.ts ├── types.ts ├── listen.ts └── syncingDataset.ts ├── test ├── config.ts ├── allowList.test.ts ├── getDocuments.test.ts ├── limits.test.ts ├── query.test.ts └── subscribe.test.ts ├── tsconfig.json ├── tsconfig.dist.json ├── renovate.json ├── .editorconfig ├── example ├── package.json ├── index.html ├── example.css └── example.ts ├── types └── simple-get.d.ts ├── .eslintrc.js ├── vite.config.ts ├── LICENSE ├── tsconfig.settings.json ├── package.json ├── README.md └── CHANGELOG.md /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @sanity-io/ecosystem 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .idea/ 4 | *.yaml 5 | *.json 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | } 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "" 5 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@sanity/semantic-release-preset", 3 | "branches": ["main"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "printWidth": 100, 4 | "bracketSpacing": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '**/*.{js,jsx}': ['eslint'], 3 | '**/*.{ts,tsx}': ['eslint', () => 'tsc --noEmit'], 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | /dist 5 | /example/dist 6 | /example/.cache 7 | /coverage 8 | *.iml 9 | .idea/ 10 | -------------------------------------------------------------------------------- /package.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from '@sanity/pkg-utils' 2 | 3 | export default defineConfig({ 4 | tsconfig: 'tsconfig.dist.json', 5 | }) 6 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function compareString(a: string, b: string): number { 2 | if (a > b) return 1 3 | if (a < b) return -1 4 | return 0 5 | } 6 | -------------------------------------------------------------------------------- /test/config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | export const projectId = 'groqstore' 3 | export const dataset = 'fixture' 4 | export const token = process.env.GROQ_STORE_TEST_TOKEN 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./types/simple-get.d.ts", "./test", "./example"], 4 | "compilerOptions": { 5 | "noEmit": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.settings", 3 | "include": ["./src", "./types/simple-get.d.ts"], 4 | "compilerOptions": { 5 | "outDir": "./dist", 6 | "rootDir": "." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/node/support.ts: -------------------------------------------------------------------------------- 1 | export function assertEnvSupport(): void { 2 | const [major] = process.version.replace(/^v/, '').split('.', 1).map(Number) 3 | if (major < 14) { 4 | throw new Error('Node.js version 14 or higher required') 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>sanity-io/renovate-config", ":reviewer(team:ecosystem)"], 4 | "ignorePresets": ["github>sanity-io/renovate-config:group-non-major", ":ignoreModulesAndTests"] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/drafts.ts: -------------------------------------------------------------------------------- 1 | import {SanityDocument} from '@sanity/types' 2 | 3 | export function isDraft(doc: SanityDocument): boolean { 4 | return doc._id.startsWith('drafts.') 5 | } 6 | 7 | export function getPublishedId(document: SanityDocument): string { 8 | return isDraft(document) ? document._id.slice(7) : document._id 9 | } 10 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "groqstore-example", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "", 6 | "license": "MIT", 7 | "main": "src/example.ts", 8 | "scripts": { 9 | "start": "parcel index.html" 10 | }, 11 | "devDependencies": { 12 | "parcel-bundler": "^1.12.5" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/browser/support.ts: -------------------------------------------------------------------------------- 1 | export function assertEnvSupport(): void { 2 | const required = ['EventSource', 'ReadableStream', 'fetch'] 3 | const unsupported = required.filter((api) => !(api in window)) 4 | 5 | if (unsupported.length > 0) { 6 | throw new Error(`Browser not supported. Missing browser APIs: ${unsupported.join(', ')}`) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/patch.ts: -------------------------------------------------------------------------------- 1 | import {SanityDocument} from '@sanity/types' 2 | import {applyPatch} from 'mendoza' 3 | 4 | export function applyPatchWithoutRev( 5 | doc: SanityDocument | null, 6 | patch: unknown[], 7 | ): SanityDocument | null { 8 | const patchDoc = {...doc} as Omit 9 | delete patchDoc._rev 10 | return applyPatch(patchDoc, patch) 11 | } 12 | -------------------------------------------------------------------------------- /types/simple-get.d.ts: -------------------------------------------------------------------------------- 1 | // _Partial_ type definitions for simple-get 2 | 3 | /// 4 | declare module 'simple-get' { 5 | import {IncomingMessage} from 'http' 6 | 7 | interface Options { 8 | url: string 9 | headers?: Record 10 | } 11 | 12 | function simpleGet( 13 | options: Options, 14 | callback: (err: Error | undefined, res: IncomingMessage) => void, 15 | ): void 16 | 17 | export = simpleGet 18 | } 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['prettier', 'react', 'simple-import-sort'], 4 | extends: ['sanity', 'sanity/typescript', 'plugin:prettier/recommended'], 5 | env: { 6 | browser: true, 7 | node: true, 8 | }, 9 | parserOptions: { 10 | ecmaVersion: 2018, 11 | }, 12 | rules: { 13 | 'no-undef': 'off', 14 | 'simple-import-sort/imports': 'warn', 15 | 'simple-import-sort/exports': 'warn', 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config' 2 | import GithubActionsReporter from 'vitest-github-actions-reporter' 3 | 4 | export default defineConfig({ 5 | test: { 6 | // interopDefault is required for the CJS-only packages we still rely on, like eventsource 7 | deps: {interopDefault: true}, 8 | // Enable rich PR failed test annotation on the CI 9 | reporters: process.env.GITHUB_ACTIONS ? ['default', new GithubActionsReporter()] : 'default', 10 | }, 11 | }) 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Note: Entry point for _browser_ build is in browser/index.ts 3 | */ 4 | import EventSourcePolyfill from '@sanity/eventsource/node' 5 | 6 | import {groqStore as groqStoreApi} from './groqStore' 7 | import {getDocuments} from './node/getDocuments' 8 | import {assertEnvSupport} from './node/support' 9 | import {Config, GroqStore} from './types' 10 | 11 | /** @public */ 12 | export function groqStore(config: Config): GroqStore { 13 | assertEnvSupport() 14 | 15 | return groqStoreApi(config, { 16 | EventSource: config.EventSource ?? EventSourcePolyfill, 17 | getDocuments, 18 | }) 19 | } 20 | 21 | export type {Config, EnvImplementations, GroqStore, Subscription} from './types' 22 | export {default as groq} from 'groq' 23 | -------------------------------------------------------------------------------- /test/allowList.test.ts: -------------------------------------------------------------------------------- 1 | import {afterAll, beforeAll, describe, expect, test} from 'vitest' 2 | 3 | import {groq, groqStore} from '../src' 4 | import {GroqStore} from '../src/types' 5 | import * as config from './config' 6 | 7 | describe( 8 | 'allowList', 9 | () => { 10 | let store: GroqStore 11 | 12 | beforeAll(() => { 13 | store = groqStore({...config, listen: false, overlayDrafts: true, includeTypes: ['product']}) 14 | }) 15 | 16 | afterAll(async () => { 17 | await store.close() 18 | }) 19 | 20 | test('only allow product documents in store', async () => { 21 | expect(await store.query(groq`count(*[_type == "vendor"])`)).toEqual(0) 22 | expect(await store.query(groq`count(*[_type == "product"])`)).toEqual(11) 23 | }) 24 | }, 25 | {timeout: 30000}, 26 | ) 27 | -------------------------------------------------------------------------------- /test/getDocuments.test.ts: -------------------------------------------------------------------------------- 1 | import EventSource from '@sanity/eventsource' 2 | import {describe, expect, it, vi} from 'vitest' 3 | 4 | import {groqStore as groqStoreApi} from '../src/groqStore' 5 | import {Config} from '../src/types' 6 | import * as baseConfig from './config' 7 | 8 | describe('getDocuments', () => { 9 | it('calls it with the configured token', async () => { 10 | const config: Config = { 11 | ...baseConfig, 12 | token: 'my-token', 13 | } 14 | 15 | const getDocuments = vi.fn().mockResolvedValue([]) 16 | 17 | const store = groqStoreApi(config, { 18 | EventSource, 19 | getDocuments, 20 | }) 21 | 22 | await store.query('*') 23 | expect(getDocuments).toBeCalledWith( 24 | expect.objectContaining({ 25 | token: 'my-token', 26 | }), 27 | ) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | @sanity/groq-store demo 6 | 7 | 8 | 9 |
10 |
11 |

Query

12 | 13 |
14 | 15 | 16 |
17 |
18 | 19 |
20 |

Result

21 | 22 |
23 | 24 |
25 |
26 |
27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/exportUtils.ts: -------------------------------------------------------------------------------- 1 | import {SanityDocument} from '@sanity/types' 2 | 3 | import {ApiError, StreamError, StreamResult} from './types' 4 | 5 | export function isStreamError(result: StreamResult | undefined): result is StreamError { 6 | if (!result) { 7 | return false 8 | } 9 | 10 | if (!('error' in result) || typeof result.error !== 'object' || result.error === null) { 11 | return false 12 | } 13 | 14 | return ( 15 | 'description' in result.error && 16 | typeof (result as StreamError).error.description === 'string' && 17 | !('_id' in result) 18 | ) 19 | } 20 | 21 | export function getError(body: ApiError): string { 22 | if (typeof body === 'object' && 'error' in body && 'message' in body) { 23 | return body.message || body.error 24 | } 25 | 26 | return '' 27 | } 28 | 29 | export function isRelevantDocument(doc: SanityDocument): boolean { 30 | return !doc._id.startsWith('_.') 31 | } 32 | -------------------------------------------------------------------------------- /example/example.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body, 7 | #root { 8 | margin: 0; 9 | padding: 0; 10 | height: 100%; 11 | font-family: sans-serif; 12 | } 13 | 14 | #root { 15 | display: flex; 16 | font-size: 14px; 17 | } 18 | 19 | .actions { 20 | display: flex; 21 | } 22 | 23 | .actions button { 24 | flex: 1; 25 | margin-top: 10px; 26 | } 27 | 28 | .actions button:not(:last-child) { 29 | margin-right: 10px; 30 | } 31 | 32 | textarea { 33 | font-family: monospace; 34 | font-size: 1.2em; 35 | } 36 | 37 | section { 38 | padding: 10px; 39 | flex: 0.5; 40 | display: flex; 41 | flex-direction: column; 42 | } 43 | 44 | section > * { 45 | width: 100%; 46 | } 47 | 48 | section textarea { 49 | min-height: 300px; 50 | flex: 1; 51 | } 52 | 53 | textarea:disabled, 54 | textarea:read-only { 55 | background: #ddd; 56 | border: 1px solid #eee; 57 | } 58 | 59 | button { 60 | padding: 10px; 61 | font-size: 1.5em; 62 | } 63 | -------------------------------------------------------------------------------- /test/limits.test.ts: -------------------------------------------------------------------------------- 1 | import {afterAll, beforeAll, describe, expect, test} from 'vitest' 2 | 3 | import {groqStore} from '../src' 4 | import {GroqStore} from '../src/types' 5 | import * as config from './config' 6 | 7 | describe( 8 | 'limits', 9 | () => { 10 | let store: GroqStore 11 | 12 | beforeAll(() => { 13 | store = groqStore({...config, listen: false, overlayDrafts: true, documentLimit: 5}) 14 | }) 15 | 16 | afterAll(async () => { 17 | await store.close() 18 | }) 19 | 20 | test('limits number of documents, if specified', async () => { 21 | let error 22 | try { 23 | const result = await store.query('*[0]') 24 | expect(result).not.toBeTruthy() 25 | } catch (err) { 26 | error = err 27 | } 28 | 29 | expect(error).toBeInstanceOf(Error) 30 | if (error instanceof Error) { 31 | expect(error.message).toMatch(/limit/i) 32 | } 33 | }) 34 | }, 35 | {timeout: 30000}, 36 | ) 37 | -------------------------------------------------------------------------------- /src/browser/index.ts: -------------------------------------------------------------------------------- 1 | import {groqStore as groqStoreApi} from '../groqStore' 2 | import {Config, GroqStore} from '../types' 3 | import {getDocuments} from './getDocuments' 4 | import {assertEnvSupport} from './support' 5 | 6 | /** @public */ 7 | export function groqStore(config: Config): GroqStore { 8 | assertEnvSupport() 9 | 10 | const EventSource = config.EventSource ?? window.EventSource 11 | 12 | if (config.token) { 13 | if (!config.EventSource) { 14 | throw new Error( 15 | 'When the `token` option is used the `EventSource` option must also be provided.', 16 | ) 17 | } 18 | if (config.EventSource === window.EventSource) 19 | throw new Error( 20 | 'When the `token` option is used the `EventSource` option must also be provided. ' + 21 | 'EventSource cannot be `window.EventSource`, as it does not support passing a token.', 22 | ) 23 | } 24 | 25 | return groqStoreApi(config, { 26 | EventSource, 27 | getDocuments, 28 | }) 29 | } 30 | 31 | export type {EnvImplementations, GroqStore, Subscription} from '../types' 32 | export {default as groq} from 'groq' 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sanity.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "lib": ["dom", "esnext"], 5 | "importHelpers": true, 6 | // output .d.ts declaration files for consumers 7 | "declaration": true, 8 | // output .js.map sourcemap files for consumers 9 | "sourceMap": true, 10 | // . to support test compilation 11 | "rootDir": ".", 12 | // stricter type-checking for stronger correctness. Recommended by TS 13 | "strict": true, 14 | // linter checks for common issues 15 | "noImplicitReturns": true, 16 | "noFallthroughCasesInSwitch": true, 17 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | // use Node's module resolution algorithm, instead of the legacy TS one 21 | "moduleResolution": "node", 22 | // transpile JSX to React.createElement 23 | "jsx": "react", 24 | // interop between ESM and CJS modules. Recommended by TS 25 | "esModuleInterop": true, 26 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 27 | "skipLibCheck": true, 28 | // error out if import and file system have a casing mismatch. Recommended by TS 29 | "forceConsistentCasingInFileNames": true, 30 | "noEmit": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/example.ts: -------------------------------------------------------------------------------- 1 | import {groqStore, Subscription} from '../src/browser' 2 | 3 | // 4 | ;(function () { 5 | // Yep 6 | const queryEl = document.getElementById('query') as HTMLTextAreaElement 7 | const resultEl = document.getElementById('result') as HTMLTextAreaElement 8 | const clearBtnEl = document.getElementById('clear') as HTMLButtonElement 9 | const executeBtnEl = document.getElementById('execute') as HTMLButtonElement 10 | const subscribeBtnEl = document.getElementById('subscribe') as HTMLButtonElement 11 | 12 | attach() 13 | populate() 14 | 15 | let subscription: Subscription | null | undefined 16 | const dataset = groqStore({ 17 | projectId: 'groqstore', 18 | dataset: 'fixture', 19 | listen: true, 20 | overlayDrafts: true, 21 | }) 22 | 23 | function attach() { 24 | clearBtnEl.addEventListener('click', clear, false) 25 | executeBtnEl.addEventListener('click', execute, false) 26 | subscribeBtnEl.addEventListener('click', subscribe, false) 27 | queryEl.addEventListener('keyup', onKeyUp, false) 28 | } 29 | 30 | function populate() { 31 | if (!localStorage.groqStore) { 32 | return 33 | } 34 | 35 | queryEl.value = localStorage.groqStore 36 | } 37 | 38 | async function execute() { 39 | resultEl.value = '… querying …' 40 | localStorage.setItem('groqStore', queryEl.value) 41 | try { 42 | onResult(await dataset.query(queryEl.value)) 43 | } catch (err: any) { 44 | onError(err.message || 'Unknown error') 45 | } 46 | } 47 | 48 | function subscribe() { 49 | if (subscription) { 50 | subscription.unsubscribe() 51 | subscription = null 52 | subscribeBtnEl.textContent = 'Subscribe' 53 | executeBtnEl.disabled = false 54 | clear() 55 | } else { 56 | resultEl.value = '… querying …' 57 | executeBtnEl.disabled = true 58 | subscribeBtnEl.textContent = 'Unsubscribe' 59 | subscription = dataset.subscribe(queryEl.value, {}, onResult) 60 | } 61 | } 62 | 63 | function onResult(queryResult: any) { 64 | const json = JSON.stringify(queryResult, null, 2) 65 | resultEl.value = json 66 | } 67 | 68 | function onError(msg: string) { 69 | resultEl.value = `/*** ERROR ***/\n\n${msg}` 70 | } 71 | 72 | function onKeyUp(evt: KeyboardEvent) { 73 | if (evt.ctrlKey && evt.key === 'Enter') { 74 | execute() 75 | } 76 | } 77 | 78 | function clear() { 79 | resultEl.value = '' 80 | } 81 | })() 82 | -------------------------------------------------------------------------------- /test/query.test.ts: -------------------------------------------------------------------------------- 1 | import {afterAll, beforeAll, describe, expect, test} from 'vitest' 2 | 3 | import {groq, groqStore} from '../src' 4 | import {GroqStore} from '../src/types' 5 | import * as config from './config' 6 | 7 | describe( 8 | 'query with overlayDrafts', 9 | () => { 10 | let store: GroqStore 11 | 12 | beforeAll(() => { 13 | store = groqStore({...config, listen: false, overlayDrafts: true}) 14 | }) 15 | 16 | afterAll(async () => { 17 | await store.close() 18 | }) 19 | 20 | test('can query', async () => { 21 | expect(await store.query(groq`*[_type == "vendor"][].title | order(@ asc)`)).toEqual([ 22 | 'Cadbury', 23 | 'Chocolates Garoto', 24 | 'Ferrero', 25 | 'Freia', 26 | 'Katjes', 27 | 'Kracie', 28 | 'Malaco', 29 | 'Nestlè', 30 | 'Totte Gott', 31 | ]) 32 | 33 | expect(await store.query(groq`*[_type == "vendor"][].title | order(@ asc) [3]`)).toEqual( 34 | 'Freia', 35 | ) 36 | expect(new Set(await store.query(groq`array::unique(*._type)`))).toEqual( 37 | new Set(['category', 'product', 'sanity.imageAsset', 'vendor']), 38 | ) 39 | }) 40 | 41 | test('populates _originalId', async () => { 42 | const id = await store.query(groq`*[_type == "vendor"][0]._originalId`) 43 | expect(id).toBe('01ca40b6-e7fd-4676-af25-33f591de51c0') 44 | }) 45 | 46 | test('sorts based on _id', async () => { 47 | const titles = await store.query(groq`*[_type == "vendor"].title`) 48 | expect(titles).toEqual([ 49 | 'Kracie', 50 | 'Nestlè', 51 | 'Ferrero', 52 | 'Freia', 53 | 'Malaco', 54 | 'Chocolates Garoto', 55 | 'Katjes', 56 | 'Cadbury', 57 | 'Totte Gott', 58 | ]) 59 | }) 60 | }, 61 | {timeout: 30000}, 62 | ) 63 | 64 | describe('query without overlayDrafts', () => { 65 | let store: GroqStore 66 | 67 | beforeAll(() => { 68 | store = groqStore({...config, listen: false, overlayDrafts: false}) 69 | }) 70 | 71 | afterAll(async () => { 72 | await store.close() 73 | }) 74 | 75 | test('sorts based on _id', async () => { 76 | const titles = await store.query(groq`*[_type == "vendor"].title`) 77 | expect(titles).toEqual([ 78 | 'Kracie', 79 | 'Nestlè', 80 | 'Ferrero', 81 | 'Freia', 82 | 'Malaco', 83 | 'Chocolates Garoto', 84 | 'Katjes', 85 | 'Cadbury', 86 | 'Totte Gott', 87 | ]) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /src/node/getDocuments.ts: -------------------------------------------------------------------------------- 1 | import {SanityDocument} from '@sanity/types' 2 | import get from 'simple-get' 3 | import split from 'split2' 4 | 5 | import {getError, isRelevantDocument, isStreamError} from '../exportUtils' 6 | import {EnvImplementations, StreamResult} from '../types' 7 | 8 | export const getDocuments: EnvImplementations['getDocuments'] = function getDocuments({ 9 | projectId, 10 | dataset, 11 | token, 12 | documentLimit, 13 | includeTypes = [], 14 | requestTagPrefix, 15 | }: { 16 | projectId: string 17 | dataset: string 18 | token?: string 19 | documentLimit?: number 20 | includeTypes?: string[] 21 | requestTagPrefix?: string 22 | }): Promise { 23 | const baseUrl = new URL(`https://${projectId}.api.sanity.io/v1/data/export/${dataset}`) 24 | if (requestTagPrefix) { 25 | baseUrl.searchParams.set('tag', requestTagPrefix) 26 | } 27 | if (includeTypes.length > 0) { 28 | baseUrl.searchParams.set('types', includeTypes?.join(',')) 29 | } 30 | const url = baseUrl.toString() 31 | const headers = token ? {Authorization: `Bearer ${token}`} : undefined 32 | 33 | return new Promise((resolve, reject) => { 34 | get({url, headers}, (err, response) => { 35 | if (err) { 36 | reject(err) 37 | return 38 | } 39 | 40 | response.setEncoding('utf8') 41 | 42 | const chunks: Buffer[] = [] 43 | if (response.statusCode !== 200) { 44 | response 45 | .on('data', (chunk: Buffer) => chunks.push(chunk)) 46 | .on('end', () => { 47 | const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) 48 | reject(new Error(`Error streaming dataset: ${getError(body)}`)) 49 | }) 50 | return 51 | } 52 | 53 | const documents: SanityDocument[] = [] 54 | response 55 | .pipe(split(JSON.parse)) 56 | .on('data', (doc: StreamResult) => { 57 | if (isStreamError(doc)) { 58 | reject(new Error(`Error streaming dataset: ${doc.error}`)) 59 | return 60 | } 61 | 62 | if (doc && isRelevantDocument(doc)) { 63 | documents.push(doc) 64 | } 65 | 66 | if (documentLimit && documents.length > documentLimit) { 67 | reject( 68 | new Error(`Error streaming dataset: Reached limit of ${documentLimit} documents`), 69 | ) 70 | response.destroy() 71 | } 72 | }) 73 | .on('end', () => resolve(documents)) 74 | }) 75 | }) 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sanity/groq-store", 3 | "version": "4.1.3", 4 | "description": "Stream dataset to memory for in-memory querying", 5 | "keywords": [ 6 | "sanity", 7 | "memory", 8 | "query", 9 | "groq" 10 | ], 11 | "homepage": "https://github.com/sanity-io/groq-store#readme", 12 | "bugs": { 13 | "url": "https://github.com/sanity-io/groq-store/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+ssh://git@github.com/sanity-io/groq-store.git" 18 | }, 19 | "license": "MIT", 20 | "author": "Sanity.io ", 21 | "sideEffects": false, 22 | "type": "commonjs", 23 | "exports": { 24 | ".": { 25 | "types": "./dist/index.d.ts", 26 | "browser": { 27 | "source": "./src/browser/index.ts", 28 | "require": "./dist/index.browser.js", 29 | "import": "./dist/index.browser.mjs" 30 | }, 31 | "deno": "./dist/index.browser.mjs", 32 | "worker": "./dist/index.browser.mjs", 33 | "source": "./src/index.ts", 34 | "require": "./dist/index.js", 35 | "node": { 36 | "module": "./dist/index.mjs", 37 | "import": "./dist/index.cjs.mjs" 38 | }, 39 | "import": "./dist/index.mjs", 40 | "default": "./dist/index.mjs" 41 | }, 42 | "./package.json": "./package.json" 43 | }, 44 | "main": "./dist/index.js", 45 | "module": "./dist/index.mjs", 46 | "source": "./src/index.ts", 47 | "browser": { 48 | "./dist/index.js": "./dist/index.browser.js", 49 | "./dist/index.mjs": "./dist/index.browser.mjs" 50 | }, 51 | "types": "./dist/index.d.ts", 52 | "files": [ 53 | "dist", 54 | "src" 55 | ], 56 | "scripts": { 57 | "prebuild": "rimraf dist", 58 | "build": "pkg build --strict && pkg --strict", 59 | "lint": "eslint .", 60 | "prepublishOnly": "npm run build", 61 | "start": "cd example && npm start", 62 | "test": "vitest" 63 | }, 64 | "browserslist": [ 65 | "> 0.2% and supports es6-module and supports es6-module-dynamic-import and not dead and not IE 11", 66 | "maintained node versions" 67 | ], 68 | "dependencies": { 69 | "@sanity/eventsource": "^5.0.0", 70 | "@sanity/types": "^3.14.5", 71 | "fast-deep-equal": "3.1.3", 72 | "groq": "^3.14.5", 73 | "groq-js": "1.4.3", 74 | "mendoza": "3.0.5", 75 | "simple-get": "4.0.1", 76 | "split2": "4.2.0", 77 | "throttle-debounce": "5.0.0" 78 | }, 79 | "devDependencies": { 80 | "@commitlint/cli": "^19.2.0", 81 | "@commitlint/config-conventional": "^19.1.0", 82 | "@sanity/client": "^6.15.5", 83 | "@sanity/pkg-utils": "^3.0.0", 84 | "@sanity/semantic-release-preset": "^4.1.7", 85 | "@types/node": "^18.18.4", 86 | "@types/split2": "^4.2.3", 87 | "@types/throttle-debounce": "^5.0.2", 88 | "@typescript-eslint/eslint-plugin": "^6.7.5", 89 | "@typescript-eslint/parser": "^6.7.5", 90 | "@vitest/coverage-v8": "^0.34.6", 91 | "eslint": "^8.51.0", 92 | "eslint-config-prettier": "^9.0.0", 93 | "eslint-config-sanity": "^7.0.1", 94 | "eslint-plugin-prettier": "^5.0.1", 95 | "eslint-plugin-react": "^7.33.2", 96 | "eslint-plugin-simple-import-sort": "^12.0.0", 97 | "husky": "^8.0.3", 98 | "lint-staged": "^14.0.1", 99 | "ls-engines": "^0.9.1", 100 | "prettier": "^3.0.3", 101 | "prettier-plugin-packagejson": "^2.4.12", 102 | "rimraf": "^5.0.0", 103 | "typescript": "^5.2.2", 104 | "vitest": "^0.34.6", 105 | "vitest-github-actions-reporter": "^0.11.1" 106 | }, 107 | "engines": { 108 | "node": ">= 18" 109 | }, 110 | "publishConfig": { 111 | "access": "public", 112 | "provenance": true 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/groqStore.ts: -------------------------------------------------------------------------------- 1 | import type {SanityDocument} from '@sanity/types' 2 | import deepEqual from 'fast-deep-equal' 3 | import groq from 'groq' 4 | import {type DereferenceFunction, evaluate, parse} from 'groq-js' 5 | import {throttle} from 'throttle-debounce' 6 | 7 | import {getSyncingDataset} from './syncingDataset' 8 | import type {Config, EnvImplementations, GroqStore, GroqSubscription, Subscription} from './types' 9 | 10 | export function groqStore(config: Config, envImplementations: EnvImplementations): GroqStore { 11 | let documents: SanityDocument[] = [] 12 | const executeThrottled = throttle(config.subscriptionThrottleMs || 50, executeAllSubscriptions) 13 | const activeSubscriptions: GroqSubscription[] = [] 14 | 15 | let dataset: Subscription & {loaded: Promise; dereference: DereferenceFunction} 16 | 17 | async function loadDataset() { 18 | if (!dataset) { 19 | dataset = getSyncingDataset( 20 | config, 21 | (docs) => { 22 | documents = docs 23 | executeThrottled() 24 | }, 25 | envImplementations, 26 | ) 27 | } 28 | 29 | await dataset.loaded 30 | } 31 | 32 | async function query(groqQuery: string, params?: Record): Promise { 33 | await loadDataset() 34 | const tree = parse(groqQuery, {params}) 35 | const result = await evaluate(tree as any, { 36 | dataset: documents, 37 | params, 38 | dereference: dataset.dereference, 39 | }) 40 | return result.get() 41 | } 42 | 43 | async function getDocument(documentId: string): Promise { 44 | await loadDataset() 45 | return query(groq`*[_id == $id][0]`, {id: documentId}) 46 | } 47 | 48 | async function getDocuments(documentIds: string[]): Promise<(SanityDocument | null)[]> { 49 | await loadDataset() 50 | const subQueries = documentIds.map((id) => `*[_id == "${id}"][0]`).join(',\n') 51 | return query(`[${subQueries}]`) 52 | } 53 | 54 | function subscribe( 55 | groqQuery: string, 56 | params: Record, 57 | callback: (error: Error | undefined, result?: R) => void, 58 | ): Subscription { 59 | if (!config.listen) { 60 | throw new Error('Cannot use `subscribe()` without `listen: true`') 61 | } 62 | 63 | // @todo Execute the query against an empty dataset for validation purposes 64 | 65 | // Store the subscription so we can re-run the query on new data 66 | const subscription = {query: groqQuery, params, callback} 67 | activeSubscriptions.push(subscription) 68 | 69 | let unsubscribed = false 70 | const unsubscribe = () => { 71 | if (unsubscribed) { 72 | return Promise.resolve() 73 | } 74 | 75 | unsubscribed = true 76 | activeSubscriptions.splice(activeSubscriptions.indexOf(subscription), 1) 77 | return Promise.resolve() 78 | } 79 | 80 | executeQuerySubscription(subscription) 81 | return {unsubscribe} 82 | } 83 | 84 | function executeQuerySubscription(subscription: GroqSubscription) { 85 | return query(subscription.query, subscription.params) 86 | .then((res) => { 87 | if ('previousResult' in subscription && deepEqual(subscription.previousResult, res)) { 88 | return 89 | } 90 | 91 | subscription.previousResult = res 92 | subscription.callback(undefined, res) 93 | }) 94 | .catch((err) => { 95 | subscription.callback(err) 96 | }) 97 | } 98 | 99 | function executeAllSubscriptions() { 100 | activeSubscriptions.forEach(executeQuerySubscription) 101 | } 102 | 103 | function close() { 104 | executeThrottled.cancel() 105 | return dataset ? dataset.unsubscribe() : Promise.resolve() 106 | } 107 | 108 | return {query, getDocument, getDocuments, subscribe, close} 109 | } 110 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type BrowserEventSource from '@sanity/eventsource/browser' 2 | import type NodeEventSource from '@sanity/eventsource/node' 3 | import type {SanityDocument} from '@sanity/types' 4 | 5 | /** @public */ 6 | export interface Subscription { 7 | unsubscribe: () => Promise 8 | } 9 | 10 | export type MutationEvent = { 11 | type: 'mutation' 12 | documentId: string 13 | eventId: string 14 | identity: string 15 | mutations: unknown[] 16 | previousRev?: string 17 | resultRev?: string 18 | result?: SanityDocument | null 19 | previous?: SanityDocument | null 20 | effects?: {apply: unknown[]; revert: unknown[]} 21 | timestamp: string 22 | transactionId: string 23 | transition: 'update' | 'appear' | 'disappear' 24 | } 25 | 26 | export interface GroqSubscription { 27 | query: string 28 | params: Record 29 | previousResult?: any 30 | callback: (err: Error | undefined, result?: any) => void 31 | } 32 | 33 | /** @public */ 34 | export interface EnvImplementations { 35 | EventSource: typeof NodeEventSource | typeof BrowserEventSource | typeof window.EventSource 36 | getDocuments: ( 37 | options: Pick< 38 | Config, 39 | 'projectId' | 'dataset' | 'token' | 'documentLimit' | 'includeTypes' | 'requestTagPrefix' 40 | >, 41 | ) => Promise 42 | } 43 | 44 | /** @public */ 45 | export interface Config { 46 | projectId: string 47 | dataset: string 48 | /** 49 | * Keep dataset up to date with remote changes. 50 | * @defaultValue false 51 | */ 52 | listen?: boolean 53 | /** 54 | * Optional token, if you want to receive drafts, or read data from private datasets 55 | * NOTE: Needs custom EventSource to work in browsers 56 | */ 57 | token?: string 58 | /** 59 | * Optional limit on number of documents, to prevent using too much memory unexpectedly 60 | * Throws on the first operation (query, retrieval, subscription) if reaching this limit. 61 | */ 62 | documentLimit?: number 63 | /** 64 | * "Replaces" published documents with drafts, if available. 65 | * Note that document IDs will not reflect draft status, currently 66 | */ 67 | overlayDrafts?: boolean 68 | /** 69 | * Throttle the event emits to batch updates. 70 | * @defaultValue 50 71 | */ 72 | subscriptionThrottleMs?: number 73 | /** 74 | * Optional EventSource. Necessary to authorize using token in the browser, since 75 | * the native window.EventSource does not accept headers. 76 | */ 77 | EventSource?: EnvImplementations['EventSource'] 78 | /** 79 | * Optional allow list filter for document types. You can use this to limit the amount of documents by declaring the types you want to sync. Note that since you're fetching a subset of your dataset, queries that works against your Content Lake might not work against the local groq-store. 80 | * @example ['page', 'product', 'sanity.imageAsset'] 81 | */ 82 | includeTypes?: string[] 83 | requestTagPrefix?: string 84 | } 85 | 86 | /** @public */ 87 | export interface GroqStore { 88 | query: (groqQuery: string, params?: Record | undefined) => Promise 89 | getDocument: (documentId: string) => Promise 90 | getDocuments: (documentIds: string[]) => Promise<(SanityDocument | null)[]> 91 | subscribe: ( 92 | groqQuery: string, 93 | params: Record, 94 | callback: (err: Error | undefined, result?: R) => void, 95 | ) => Subscription 96 | close: () => Promise 97 | } 98 | 99 | export interface ApiError { 100 | statusCode: number 101 | error: string 102 | message: string 103 | } 104 | 105 | export interface StreamError { 106 | error: { 107 | description?: string 108 | type: string 109 | } 110 | } 111 | 112 | export type StreamResult = SanityDocument | StreamError 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @sanity/groq-store 2 | 3 | [![npm stat](https://img.shields.io/npm/dm/@sanity/groq-store.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@sanity/groq-store) 4 | [![npm version](https://img.shields.io/npm/v/@sanity/groq-store.svg?style=flat-square)](https://www.npmjs.com/package/@sanity/groq-store) 5 | [![gzip size][gzip-badge]][bundlephobia] 6 | [![size][size-badge]][bundlephobia] 7 | 8 | In-memory GROQ store. Streams all available documents from Sanity into an in-memory database and allows you to query them there. 9 | 10 | ## Targets 11 | 12 | - Node.js >= 14 13 | - Modern browsers (Edge >= 14, Chrome, Safari, Firefox etc) 14 | 15 | ## Caveats 16 | 17 | - Streams _entire_ dataset to memory, so generally not recommended for large datasets 18 | - Needs custom event source to work with tokens in browser 19 | 20 | ## Installation 21 | 22 | ```bash 23 | npm i @sanity/groq-store 24 | ``` 25 | 26 | ## Usage 27 | 28 | ```js 29 | import {groqStore, groq} from '@sanity/groq-store' 30 | // import SanityEventSource from '@sanity/eventsource' 31 | 32 | const store = groqStore({ 33 | projectId: 'abc123', 34 | dataset: 'blog', 35 | 36 | // Keep dataset up to date with remote changes. Default: false 37 | listen: true, 38 | 39 | // "Replaces" published documents with drafts, if available. 40 | // Note that document IDs will not reflect draft status, currently 41 | overlayDrafts: true, 42 | 43 | // Optional token, if you want to receive drafts, or read data from private datasets 44 | // NOTE: Needs custom EventSource to work in browsers 45 | token: 'someAuthToken', 46 | 47 | // Optional limit on number of documents, to prevent using too much memory unexpectedly 48 | // Throws on the first operation (query, retrieval, subscription) if reaching this limit. 49 | documentLimit: 10000, 50 | 51 | // Optional EventSource. Necessary to authorize using token in the browser, since 52 | // the native window.EventSource does not accept headers. 53 | // EventSource: SanityEventSource, 54 | 55 | // Optional allow list filter for document types. You can use this to limit the amount of documents by declaring the types you want to sync. Note that since you're fetching a subset of your dataset, queries that works against your Content Lake might not work against the local groq-store. 56 | // You can quickly list all your types using this query: `array::unique(*[]._type)` 57 | includeTypes: ['post', 'page', 'product', 'sanity.imageAsset'], 58 | }) 59 | 60 | store.query(groq`*[_type == "author"]`).then((docs) => { 61 | console.log(docs) 62 | }) 63 | 64 | store.getDocument('grrm').then((grrm) => { 65 | console.log(grrm) 66 | }) 67 | 68 | store.getDocuments(['grrm', 'jrrt']).then(([grrm, jrrt]) => { 69 | console.log(grrm, jrrt) 70 | }) 71 | 72 | const sub = store.subscribe( 73 | groq`*[_type == $type][] {name}`, // Query 74 | {type: 'author'}, // Params 75 | (err, result) => { 76 | if (err) { 77 | console.error('Oh no, an error:', err) 78 | return 79 | } 80 | 81 | console.log('Result:', result) 82 | }, 83 | ) 84 | 85 | // Later, to close subscription: 86 | sub.unsubscribe() 87 | 88 | // Later, to close listener: 89 | store.close() 90 | ``` 91 | 92 | ## License 93 | 94 | MIT © [Sanity.io](https://www.sanity.io/) 95 | 96 | ## Release new version 97 | 98 | Run ["CI & Release" workflow](https://github.com/sanity-io/groq-store/actions). 99 | Make sure to select the main branch and check "Release new version". 100 | 101 | Version will be automatically bumped based on [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) since the last release. 102 | 103 | Semantic release will only release on configured branches, so it is safe to run release on any branch. 104 | 105 | Note: commits with `chore:` will be ignored. If you want updated dependencies to trigger 106 | a new version, use `fix(deps):` instead. 107 | 108 | [gzip-badge]: https://img.shields.io/bundlephobia/minzip/@sanity/groq-store?label=gzip%20size&style=flat-square 109 | [size-badge]: https://img.shields.io/bundlephobia/min/@sanity/groq-store?label=size&style=flat-square 110 | [bundlephobia]: https://bundlephobia.com/package/@sanity/groq-store 111 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI & Release 2 | 3 | # Workflow name based on selected inputs. Fallback to default Github naming when expression evaluates to empty string 4 | run-name: >- 5 | ${{ 6 | inputs.release && inputs.test && 'Build ➤ Test ➤ Publish to NPM' || 7 | inputs.release && !inputs.test && 'Build ➤ Skip Tests ➤ Publish to NPM' || 8 | github.event_name == 'workflow_dispatch' && inputs.test && 'Build ➤ Test' || 9 | github.event_name == 'workflow_dispatch' && !inputs.test && 'Build ➤ Skip Tests' || 10 | '' 11 | }} 12 | 13 | on: 14 | # Build on pushes to release branches 15 | push: 16 | branches: [main] 17 | pull_request: 18 | workflow_dispatch: 19 | inputs: 20 | test: 21 | description: 'Run tests' 22 | required: true 23 | default: true 24 | type: boolean 25 | release: 26 | description: 'Publish new release' 27 | required: true 28 | default: false 29 | type: boolean 30 | 31 | concurrency: 32 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 33 | cancel-in-progress: true 34 | 35 | permissions: 36 | contents: read # for checkout 37 | 38 | jobs: 39 | build: 40 | name: Lint & Build 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-node@v4 45 | with: 46 | node-version: lts/* 47 | cache: npm 48 | - run: npm ci 49 | - run: npm run lint 50 | if: github.event.inputs.test != 'false' 51 | - run: npx ls-engines 52 | if: github.event.inputs.test != 'false' 53 | - run: npm run prepublishOnly 54 | if: github.event.inputs.test != 'false' 55 | - run: npm test -- --coverage --no-threads 56 | env: 57 | GROQ_STORE_TEST_TOKEN: ${{ secrets.GROQ_STORE_TEST_TOKEN }} 58 | 59 | test: 60 | name: Test 61 | needs: build 62 | if: github.event.inputs.test != 'false' 63 | strategy: 64 | fail-fast: false 65 | matrix: 66 | os: [macos-latest, ubuntu-latest, windows-latest] 67 | node: [lts/*] 68 | include: 69 | - os: ubuntu-latest 70 | node: current 71 | exclude: 72 | - os: ubuntu-latest 73 | node: lts/* 74 | runs-on: ${{ matrix.os }} 75 | steps: 76 | - name: Set git to use LF 77 | if: matrix.os == 'windows-latest' 78 | run: | 79 | git config --global core.autocrlf false 80 | git config --global core.eol lf 81 | - uses: actions/checkout@v4 82 | - uses: actions/setup-node@v4 83 | with: 84 | node-version: ${{ matrix.node }} 85 | cache: npm 86 | - run: npm install 87 | - run: npm test -- --coverage 88 | 89 | release: 90 | permissions: 91 | contents: write # to be able to publish a GitHub release 92 | issues: write # to be able to comment on released issues 93 | pull-requests: write # to be able to comment on released pull requests 94 | id-token: write # to enable use of OIDC for npm provenance 95 | name: Semantic release 96 | needs: [build, test] 97 | # only run if opt-in during workflow_dispatch 98 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled' 99 | runs-on: ubuntu-latest 100 | steps: 101 | - uses: actions/checkout@v4 102 | with: 103 | # Need to fetch entire commit history to 104 | # analyze every commit since last release 105 | fetch-depth: 0 106 | - uses: actions/setup-node@v4 107 | with: 108 | node-version: lts/* 109 | cache: npm 110 | - run: npm ci 111 | # Branches that will release new versions are defined in .releaserc.json 112 | - run: npx semantic-release 113 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 114 | # e.g. git tags were pushed but it exited before `npm publish` 115 | if: always() 116 | env: 117 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 118 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 119 | -------------------------------------------------------------------------------- /src/browser/getDocuments.ts: -------------------------------------------------------------------------------- 1 | import {SanityDocument} from '@sanity/types' 2 | 3 | import {EnvImplementations} from '../types' 4 | 5 | type StreamError = {error: {description?: string; type: string}} 6 | type StreamResult = SanityDocument | StreamError 7 | 8 | export const getDocuments: EnvImplementations['getDocuments'] = async function getDocuments({ 9 | projectId, 10 | dataset, 11 | token, 12 | documentLimit, 13 | includeTypes = [], 14 | requestTagPrefix, 15 | }: { 16 | projectId: string 17 | dataset: string 18 | token?: string 19 | documentLimit?: number 20 | includeTypes?: string[] 21 | requestTagPrefix?: string 22 | }): Promise { 23 | const url = new URL(`https://${projectId}.api.sanity.io/v1/data/export/${dataset}`) 24 | if (requestTagPrefix) { 25 | url.searchParams.set('tag', requestTagPrefix) 26 | } 27 | if (includeTypes.length > 0) { 28 | url.searchParams.set('types', includeTypes?.join(',')) 29 | } 30 | const headers = token ? {Authorization: `Bearer ${token}`} : undefined 31 | const response = await fetch(url, {credentials: 'include', headers}) 32 | 33 | if (response.status !== 200) { 34 | throw new Error(`Error streaming dataset: ${getError(await response.json())}`) 35 | } 36 | 37 | const stream = getDocumentStream(response.body) 38 | const reader = stream.getReader() 39 | 40 | const documents: SanityDocument[] = [] 41 | let result 42 | let document 43 | do { 44 | result = await reader.read() 45 | document = result.value 46 | 47 | if (isStreamError(document)) { 48 | throw new Error(`Error streaming dataset: ${document.error}`) 49 | } else if (document && isRelevantDocument(document)) { 50 | documents.push(document) 51 | } 52 | 53 | if (documentLimit && documents.length > documentLimit) { 54 | reader.cancel('Reached document limit') 55 | throw new Error( 56 | `Error streaming dataset: Reached limit of ${documentLimit} documents. Try using the includeTypes option to reduce the amount of documents, or increase the limit.`, 57 | ) 58 | } 59 | } while (!result.done) 60 | 61 | return documents 62 | } 63 | 64 | function getDocumentStream(body: Response['body']): ReadableStream { 65 | if (!body) { 66 | throw new Error('Failed to read body from response') 67 | } 68 | 69 | let reader: ReadableStreamDefaultReader | undefined 70 | let cancelled = false 71 | 72 | function cancel() { 73 | cancelled = true 74 | if (reader) { 75 | reader.cancel() 76 | } 77 | } 78 | 79 | return new ReadableStream({ 80 | start(controller): void | PromiseLike { 81 | reader = body.getReader() 82 | const decoder = new TextDecoder() 83 | let buffer = '' 84 | 85 | reader 86 | .read() 87 | .then(processResult) 88 | .catch((err) => controller.error(err)) 89 | 90 | async function processResult(result: ReadableStreamReadResult): Promise { 91 | if (result.done) { 92 | if (cancelled) { 93 | return 94 | } 95 | 96 | buffer = buffer.trim() 97 | if (buffer.length === 0) { 98 | controller.close() 99 | return 100 | } 101 | 102 | controller.enqueue(JSON.parse(buffer)) 103 | controller.close() 104 | return 105 | } 106 | 107 | buffer += decoder.decode(result.value, {stream: true}) 108 | const lines = buffer.split('\n') 109 | 110 | for (let i = 0; i < lines.length - 1; ++i) { 111 | const line = lines[i].trim() 112 | if (line.length === 0) { 113 | continue 114 | } 115 | 116 | try { 117 | controller.enqueue(JSON.parse(line)) 118 | } catch (err) { 119 | controller.error(err) 120 | cancel() 121 | return 122 | } 123 | } 124 | 125 | buffer = lines[lines.length - 1] 126 | 127 | if (!reader) { 128 | return 129 | } 130 | 131 | try { 132 | processResult(await reader.read()) 133 | } catch (err) { 134 | controller.error(err) 135 | } 136 | } 137 | }, 138 | 139 | cancel, 140 | }) 141 | } 142 | 143 | function isStreamError(result: StreamResult | undefined): result is StreamError { 144 | if (!result) { 145 | return false 146 | } 147 | 148 | if (!('error' in result) || typeof result.error !== 'object' || result.error === null) { 149 | return false 150 | } 151 | 152 | return ( 153 | 'description' in result.error && 154 | typeof (result as StreamError).error.description === 'string' && 155 | !('_id' in result) 156 | ) 157 | } 158 | 159 | function getError(body: any): string { 160 | if (typeof body === 'object' && 'error' in body && 'message' in body) { 161 | return body.message || body.error 162 | } 163 | 164 | return '' 165 | } 166 | 167 | function isRelevantDocument(doc: SanityDocument): boolean { 168 | return !doc._id.startsWith('_.') 169 | } 170 | -------------------------------------------------------------------------------- /src/listen.ts: -------------------------------------------------------------------------------- 1 | import type BrowserEventSource from '@sanity/eventsource/browser' 2 | import type NodeEventSource from '@sanity/eventsource/node' 3 | 4 | import {ApiError, Config, EnvImplementations, MutationEvent, Subscription} from './types' 5 | 6 | type EventSourceInstance = InstanceType 7 | 8 | // The events used by Content Lake: https://www.sanity.io/docs/listening 9 | export interface SharedEventSourceEventMap { 10 | welcome: MessageEvent 11 | mutation: MessageEvent 12 | channelError: MessageEvent 13 | disconnect: MessageEvent 14 | error: Event 15 | } 16 | declare module 'event-source-polyfill' { 17 | export interface EventSourceEventMap extends SharedEventSourceEventMap {} 18 | } 19 | 20 | const isNativeBrowserEventSource = ( 21 | eventSource: EventSourceInstance, 22 | ): eventSource is InstanceType => 23 | typeof window !== 'undefined' && 24 | eventSource.addEventListener === window.EventSource.prototype.addEventListener 25 | 26 | const isPolyfillEventSource = ( 27 | eventSource: EventSourceInstance, 28 | ): eventSource is InstanceType => 29 | !isNativeBrowserEventSource(eventSource) 30 | 31 | const addEventSourceListener = ( 32 | eventSource: EventSourceInstance, 33 | type: keyof SharedEventSourceEventMap, 34 | listener: EventListener, 35 | ): void => { 36 | if (isPolyfillEventSource(eventSource)) { 37 | // Polyfilled event source does not accept option parameter 38 | eventSource.addEventListener(type, listener as any) 39 | } else { 40 | eventSource.addEventListener(type, listener, false) 41 | } 42 | } 43 | 44 | const encodeQueryString = ({ 45 | query, 46 | params = {}, 47 | options = {}, 48 | }: { 49 | query: string 50 | params?: Record 51 | options?: Record 52 | }) => { 53 | const searchParams = new URLSearchParams() 54 | // We generally want tag at the start of the query string 55 | const {tag, ...opts} = options 56 | if (tag) searchParams.set('tag', tag as string) 57 | searchParams.set('query', query) 58 | 59 | // Iterate params, the keys are prefixed with `$` and their values JSON stringified 60 | for (const [key, value] of Object.entries(params)) { 61 | searchParams.set(`$${key}`, JSON.stringify(value)) 62 | } 63 | // Options are passed as-is 64 | for (const [key, value] of Object.entries(opts)) { 65 | // Skip falsy values 66 | if (value) searchParams.set(key, `${value}`) 67 | } 68 | 69 | return `?${searchParams}` 70 | } 71 | 72 | export function listen( 73 | EventSourceImpl: EnvImplementations['EventSource'], 74 | config: Config, 75 | handlers: { 76 | open: () => void 77 | error: (err: Error) => void 78 | next: (event: MutationEvent) => void 79 | }, 80 | ): Subscription { 81 | const {projectId, dataset, token, includeTypes, requestTagPrefix} = config 82 | const headers = token ? {Authorization: `Bearer ${token}`} : undefined 83 | 84 | // Make sure we only listen to mutations on documents part of the `includeTypes` allowlist, if provided 85 | const options = requestTagPrefix 86 | ? {tag: requestTagPrefix, effectFormat: 'mendoza'} 87 | : {effectFormat: 'mendoza'} 88 | const searchParams = encodeQueryString( 89 | Array.isArray(includeTypes) && includeTypes.length > 0 90 | ? { 91 | query: `*[_type in $includeTypes]`, 92 | params: {includeTypes}, 93 | options, 94 | } 95 | : {query: '*', options}, 96 | ) 97 | const url = `https://${projectId}.api.sanity.io/v1/data/listen/${dataset}${searchParams}` 98 | const es = new EventSourceImpl(url, {withCredentials: true, headers}) 99 | 100 | addEventSourceListener(es, 'welcome', handlers.open) 101 | 102 | addEventSourceListener(es, 'mutation', getMutationParser(handlers.next)) 103 | 104 | addEventSourceListener(es, 'channelError', (msg: any) => { 105 | es.close() 106 | 107 | let data 108 | try { 109 | data = JSON.parse(msg.data) as ApiError 110 | } catch (err) { 111 | handlers.error(new Error('Unknown error parsing listener message')) 112 | return 113 | } 114 | 115 | handlers.error( 116 | new Error(data.message || data.error || `Listener returned HTTP ${data.statusCode}`), 117 | ) 118 | }) 119 | 120 | addEventSourceListener(es, 'error', (err: Event) => { 121 | const origin = typeof window !== 'undefined' && window.location.origin 122 | const hintSuffix = origin ? `, and that the CORS-origin (${origin}) is allowed` : '' 123 | const errorMessage = isErrorLike(err) ? ` (${err.message})` : '' 124 | handlers.error( 125 | new Error( 126 | `Error establishing listener - check that the project ID and dataset are correct${hintSuffix}${errorMessage}`, 127 | ), 128 | ) 129 | }) 130 | 131 | return { 132 | unsubscribe: (): Promise => Promise.resolve(es.close()), 133 | } 134 | } 135 | 136 | function getMutationParser(cb: (event: MutationEvent) => void): (msg: any) => void { 137 | return (msg: any) => { 138 | let data 139 | try { 140 | data = JSON.parse(msg.data) 141 | } catch (err) { 142 | // intentional noop 143 | return 144 | } 145 | 146 | cb(data) 147 | } 148 | } 149 | 150 | function isErrorLike(err: unknown): err is {message: string} { 151 | return typeof err === 'object' && err !== null && 'message' in err 152 | } 153 | -------------------------------------------------------------------------------- /test/subscribe.test.ts: -------------------------------------------------------------------------------- 1 | import {createClient, SanityClient} from '@sanity/client' 2 | import {afterAll, beforeAll, describe, expect, test} from 'vitest' 3 | 4 | import {groq, GroqStore, groqStore} from '../src' 5 | import * as config from './config' 6 | 7 | describe.runIf(config.token)( 8 | 'subscribe', 9 | () => { 10 | let client: SanityClient 11 | let store: GroqStore 12 | let errStore: GroqStore 13 | 14 | function deleteFixtureDocs() { 15 | return ( 16 | client 17 | .transaction() 18 | .delete('fox') 19 | .delete('drafts.fox') 20 | .delete('category-awesome') 21 | // Not expecting to see this draft, but in case someone messes up the dataset 22 | .delete('drafts.category-awesome') 23 | .commit({visibility: 'async'}) 24 | ) 25 | } 26 | 27 | beforeAll(async () => { 28 | // Make sure we don't have any old fixtures laying around 29 | client = createClient({ 30 | ...config, 31 | useCdn: false, 32 | apiVersion: '2021-06-07', 33 | requestTagPrefix: 'sanity.groq-store.integration-test', 34 | }) 35 | await deleteFixtureDocs() 36 | 37 | store = groqStore({...config, listen: true, overlayDrafts: true}) 38 | errStore = groqStore({...config, dataset: 'n-o-p-e', listen: true, overlayDrafts: true}) 39 | }) 40 | 41 | afterAll(async () => { 42 | await store.close() 43 | await errStore.close() 44 | await deleteFixtureDocs() 45 | }) 46 | 47 | test('integration: callback gets error on listener failure', async () => { 48 | expect.hasAssertions() 49 | await new Promise((resolve) => { 50 | errStore.subscribe('*[0]', {}, (err, result) => { 51 | expect(err).toBeDefined() 52 | expect(err?.message).toMatch(/dataset.*?not found for project/i) 53 | expect(result).toBeUndefined() 54 | 55 | errStore.close().then(resolve) 56 | }) 57 | }) 58 | }) 59 | 60 | test('integration: can subscribe', async () => { 61 | expect.hasAssertions() 62 | let done: () => void 63 | let error: (err: Error) => void 64 | const waiter = new Promise((resolve, reject) => { 65 | done = resolve 66 | error = reject 67 | }) 68 | 69 | const initial = { 70 | _id: 'fox', 71 | _type: 'product', 72 | title: 'Fox', 73 | slug: {_type: 'slug', current: 'fox'}, 74 | } 75 | 76 | await client.createOrReplace(initial) 77 | 78 | const updates: unknown[] = [] 79 | const sub = store.subscribe( 80 | groq`*[_type == "product" && slug.current == $slug][0] { 81 | _id, 82 | _type, 83 | title, 84 | "categories": categories[]->title, 85 | "slug": slug.current 86 | }`, 87 | {slug: 'fox'}, 88 | async (err: Error | undefined, fox: unknown) => { 89 | if (err) { 90 | throw err 91 | } 92 | 93 | const numUpdates = updates.push(fox) 94 | switch (numUpdates) { 95 | case 1: 96 | // Should get an initial flush, since no previous result was present 97 | expect(fox).toMatchObject({ 98 | ...initial, 99 | slug: 'fox', 100 | }) 101 | 102 | // Create a draft with a new title 103 | await client 104 | .createOrReplace({...initial, _id: 'drafts.fox', title: 'Fox 2-bit'}) 105 | .catch(error) 106 | break 107 | case 2: 108 | // Overlayed draft, with new title 109 | expect(fox).toMatchObject({ 110 | ...initial, 111 | slug: 'fox', 112 | title: 'Fox 2-bit', 113 | }) 114 | 115 | // Create a reference to a new category. This will emit two listener events 116 | // (one for each document), allowing us to see that the throttling works, 117 | // as well as the query resolving the reference correctly 118 | await client 119 | .transaction() 120 | .createOrReplace({ 121 | _id: 'category-awesome', 122 | _type: 'category', 123 | title: 'Awesome category being awesome', 124 | }) 125 | .patch('drafts.fox', (p) => p.set({categories: [{_ref: 'category-awesome'}]})) 126 | .commit({visibility: 'async'}) 127 | .catch(error) 128 | break 129 | case 3: 130 | // Overlayed draft, with typoed category 131 | expect(fox).toMatchObject({ 132 | ...initial, 133 | slug: 'fox', 134 | title: 'Fox 2-bit', 135 | categories: ['Awesome category being awesome'], 136 | }) 137 | 138 | // Fix a typo in the category, ensuring updates to referenced documents are working 139 | await client 140 | .patch('category-awesome') 141 | .set({title: 'Awesome'}) 142 | .commit({visibility: 'async'}) 143 | .catch(error) 144 | break 145 | case 4: 146 | // Overlayed draft, with fixed category title 147 | expect(fox).toMatchObject({ 148 | ...initial, 149 | slug: 'fox', 150 | title: 'Fox 2-bit', 151 | categories: ['Awesome'], 152 | }) 153 | 154 | // "Publish" the document, then immediately patch the published with a new title 155 | await client 156 | .transaction() 157 | .createOrReplace({ 158 | ...initial, 159 | title: 'Fox 2-bit', 160 | categories: [{_ref: 'category-awesome'}], 161 | }) 162 | .delete('drafts.fox') 163 | .commit({visibility: 'async'}) 164 | .catch(error) 165 | 166 | await client.patch('fox').set({title: 'Fox 8-bit'}).commit({visibility: 'async'}) 167 | break 168 | case 5: 169 | // Draft deleted, published document updated with new title 170 | expect(fox).toMatchObject({ 171 | ...initial, 172 | slug: 'fox', 173 | title: 'Fox 8-bit', 174 | categories: ['Awesome'], 175 | }) 176 | 177 | // Done. Close subscription and fire off another mutation, 178 | // checking that we do not receive results from it 179 | sub.unsubscribe() 180 | 181 | await client 182 | .patch('fox') 183 | .set({title: 'You shouldnt see this'}) 184 | .commit({visibility: 'async'}) 185 | .catch(error) 186 | 187 | // Wait for a bit, make sure throttling isnt whats causing the event not to be delivered 188 | await new Promise((resolve) => setTimeout(resolve, 1000)) 189 | 190 | // Run assertions and ensure query subscription updates were what we expected 191 | runAssertions() 192 | break 193 | default: 194 | throw new Error(`Encountered more updates than expected (${numUpdates})`) 195 | } 196 | }, 197 | ) 198 | 199 | function runAssertions() { 200 | expect(updates).toHaveLength(5) 201 | done() 202 | } 203 | 204 | return waiter 205 | }) 206 | }, 207 | {timeout: 15000}, 208 | ) 209 | -------------------------------------------------------------------------------- /src/syncingDataset.ts: -------------------------------------------------------------------------------- 1 | import {SanityDocument} from '@sanity/types' 2 | import type {DereferenceFunction} from 'groq-js' 3 | 4 | import {getPublishedId, isDraft} from './drafts' 5 | import {listen} from './listen' 6 | import {applyPatchWithoutRev} from './patch' 7 | import {Config, EnvImplementations, MutationEvent, Subscription} from './types' 8 | import {compareString} from './utils' 9 | 10 | const DEBOUNCE_MS = 25 11 | 12 | function noop() { 13 | return Promise.resolve() 14 | } 15 | 16 | export function getSyncingDataset( 17 | config: Config, 18 | onNotifyUpdate: (docs: SanityDocument[]) => void, 19 | {getDocuments, EventSource}: EnvImplementations, 20 | ): Subscription & {loaded: Promise; dereference: DereferenceFunction} { 21 | const { 22 | projectId, 23 | dataset, 24 | listen: useListener, 25 | overlayDrafts, 26 | documentLimit, 27 | token, 28 | includeTypes, 29 | requestTagPrefix, 30 | } = config 31 | 32 | // We don't want to flush updates while we're in the same transaction, so a normal 33 | // throttle/debounce wouldn't do it. We need to wait and see if the next mutation is 34 | // within the same transaction as the previous, and if not we can flush. Of course, 35 | // we can't wait forever, so an upper threshold of X ms should be counted as "ok to flush" 36 | let stagedDocs: SanityDocument[] | undefined 37 | let previousTrx: string | undefined 38 | let flushTimeout: NodeJS.Timer | undefined 39 | 40 | const onUpdate = (docs: SanityDocument[]) => { 41 | stagedDocs = undefined 42 | flushTimeout = undefined 43 | previousTrx = undefined 44 | const finalDocs = overlayDrafts ? overlay(docs) : docs 45 | finalDocs.sort((a, b) => compareString(a._id, b._id)) 46 | onNotifyUpdate(finalDocs) 47 | } 48 | const dereference: DereferenceFunction = overlayDrafts 49 | ? ({_ref}) => { 50 | const doc = indexedDocuments.get(`drafts.${_ref}`) || indexedDocuments.get(_ref) 51 | if (!doc) { 52 | return Promise.resolve(doc) 53 | } 54 | if (isDraft(doc)) { 55 | return Promise.resolve(pretendThatItsPublished(doc)) 56 | } 57 | return Promise.resolve({...doc, _originalId: doc._id}) 58 | } 59 | : ({_ref}) => Promise.resolve(indexedDocuments.get(_ref)) 60 | 61 | if (!useListener) { 62 | const loaded = getDocuments({ 63 | projectId, 64 | dataset, 65 | documentLimit, 66 | token, 67 | includeTypes, 68 | requestTagPrefix, 69 | }) 70 | .then(onUpdate) 71 | .then(noop) 72 | return {unsubscribe: noop, loaded, dereference} 73 | } 74 | 75 | const indexedDocuments = new Map() 76 | 77 | // undefined until the listener has been set up and the initial export is done 78 | let documents: SanityDocument[] | undefined 79 | 80 | // holds any mutations that happen while fetching documents so they can be applied after updates 81 | const buffer: MutationEvent[] = [] 82 | 83 | // Return a promise we can resolve once we've established a listener and reconciled any mutations 84 | let onDoneLoading: () => void 85 | let onLoadError: (error: Error) => void 86 | const loaded = new Promise((resolve, reject) => { 87 | onDoneLoading = resolve 88 | onLoadError = reject 89 | }) 90 | 91 | const onOpen = async () => { 92 | const initial = await getDocuments({ 93 | projectId, 94 | dataset, 95 | documentLimit, 96 | token, 97 | includeTypes, 98 | requestTagPrefix, 99 | }) 100 | documents = applyBufferedMutations(initial, buffer) 101 | documents.forEach((doc) => indexedDocuments.set(doc._id, doc)) 102 | onUpdate(documents) 103 | onDoneLoading() 104 | } 105 | 106 | const onMutationReceived = (msg: MutationEvent) => { 107 | if (documents) { 108 | applyMutation(msg) 109 | scheduleUpdate(documents, msg) 110 | } else { 111 | buffer.push(msg) 112 | } 113 | } 114 | 115 | const listener = listen(EventSource, config, { 116 | next: onMutationReceived, 117 | open: onOpen, 118 | error: (error: Error) => onLoadError(error), 119 | }) 120 | 121 | const scheduleUpdate = (docs: SanityDocument[], msg: MutationEvent) => { 122 | clearTimeout(flushTimeout) 123 | 124 | if (previousTrx !== msg.transactionId && stagedDocs) { 125 | // This is a new transaction, meaning we can immediately flush any pending 126 | // doc updates if there are any 127 | onUpdate(stagedDocs) 128 | previousTrx = undefined 129 | } else { 130 | previousTrx = msg.transactionId 131 | stagedDocs = docs.slice() 132 | } 133 | 134 | flushTimeout = setTimeout(onUpdate, DEBOUNCE_MS, docs.slice()) 135 | } 136 | 137 | const applyMutation = (msg: MutationEvent) => { 138 | if (!msg.effects || msg.documentId.startsWith('_.')) { 139 | return 140 | } 141 | 142 | const document = indexedDocuments.get(msg.documentId) || null 143 | replaceDocument(msg.documentId, applyPatchWithoutRev(document, msg.effects.apply)) 144 | } 145 | 146 | const replaceDocument = (id: string, document: SanityDocument | null) => { 147 | const current = indexedDocuments.get(id) 148 | const docs = documents || [] 149 | const position = current ? docs.indexOf(current) : -1 150 | 151 | if (position === -1 && document) { 152 | // Didn't exist previously, but was now created. Add it. 153 | docs.push(document) 154 | indexedDocuments.set(id, document) 155 | } else if (document) { 156 | // Existed previously and still does. Replace it. 157 | docs.splice(position, 1, document) 158 | indexedDocuments.set(id, document) 159 | } else { 160 | // Existed previously, but is now deleted. Remove it. 161 | docs.splice(position, 1) 162 | indexedDocuments.delete(id) 163 | } 164 | } 165 | 166 | return {unsubscribe: listener.unsubscribe, loaded, dereference} 167 | } 168 | 169 | function applyBufferedMutations( 170 | documents: SanityDocument[], 171 | mutations: MutationEvent[], 172 | ): SanityDocument[] { 173 | // Group by document ID 174 | const groups = new Map() 175 | mutations.forEach((mutation) => { 176 | const group = groups.get(mutation.documentId) || [] 177 | group.push(mutation) 178 | groups.set(mutation.documentId, group) 179 | }) 180 | 181 | // Discard all mutations that happened before our current document 182 | groups.forEach((group, id) => { 183 | const document = documents.find((doc) => doc._id === id) 184 | if (!document) { 185 | // @todo handle 186 | // eslint-disable-next-line no-console 187 | console.warn('Received mutation for missing document %s', id) 188 | return 189 | } 190 | 191 | // Mutations are sorted by timestamp, apply any that arrived after 192 | // we fetched the initial documents 193 | let hasFoundRevision = false 194 | let current: SanityDocument | null = document 195 | group.forEach((mutation) => { 196 | hasFoundRevision = hasFoundRevision || mutation.previousRev === document._rev 197 | if (!hasFoundRevision) { 198 | return 199 | } 200 | 201 | if (mutation.effects) { 202 | current = applyPatchWithoutRev(current, mutation.effects.apply) 203 | } 204 | }) 205 | 206 | // Replace the indexed documents 207 | documents.splice(documents.indexOf(document), 1, current) 208 | }) 209 | 210 | return documents 211 | } 212 | 213 | function overlay(documents: SanityDocument[]): SanityDocument[] { 214 | const overlayed = new Map() 215 | 216 | documents.forEach((doc) => { 217 | const existing = overlayed.get(getPublishedId(doc)) 218 | if (doc._id.startsWith('drafts.')) { 219 | // Drafts always overlay 220 | overlayed.set(getPublishedId(doc), pretendThatItsPublished(doc)) 221 | } else if (!existing) { 222 | // Published documents only override if draft doesn't exist 223 | overlayed.set(doc._id, {...doc, _originalId: doc._id}) 224 | } 225 | }) 226 | 227 | return Array.from(overlayed.values()) 228 | } 229 | 230 | // Strictly speaking it would be better to allow groq-js to resolve `drafts.`, 231 | // but for now this will have to do 232 | function pretendThatItsPublished(doc: SanityDocument): SanityDocument { 233 | return {...doc, _id: getPublishedId(doc), _originalId: doc._id} 234 | } 235 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 📓 Changelog 4 | 5 | All notable changes to this project will be documented in this file. See 6 | [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 7 | 8 | ## [4.1.3](https://github.com/sanity-io/groq-store/compare/v4.1.2...v4.1.3) (2023-10-12) 9 | 10 | ### Bug Fixes 11 | 12 | - bump node engines to 18 ([bd8e9c0](https://github.com/sanity-io/groq-store/commit/bd8e9c09bde4ff85c178f025e335023ff481e02c)) 13 | 14 | ## [4.1.2](https://github.com/sanity-io/groq-store/compare/v4.1.1...v4.1.2) (2023-09-19) 15 | 16 | ### Bug Fixes 17 | 18 | - **deps:** Update dependency groq-js to v1.3.0 ([#143](https://github.com/sanity-io/groq-store/issues/143)) ([b5a6c9c](https://github.com/sanity-io/groq-store/commit/b5a6c9c7e3ab469644377464b1c5315698ab7d5e)) 19 | 20 | ## [4.1.1](https://github.com/sanity-io/groq-store/compare/v4.1.0...v4.1.1) (2023-08-23) 21 | 22 | ### Bug Fixes 23 | 24 | - better previewDrafts support in dereference ([469798b](https://github.com/sanity-io/groq-store/commit/469798b06a8cc295c8679daeaa3310e0048725db)) 25 | 26 | ## [4.1.0](https://github.com/sanity-io/groq-store/compare/v4.0.4...v4.1.0) (2023-08-23) 27 | 28 | ### Features 29 | 30 | - implement `dereference` optimization ([acd526a](https://github.com/sanity-io/groq-store/commit/acd526ae3e552503558cde884ae934be391b54dd)) 31 | 32 | ## [4.0.4](https://github.com/sanity-io/groq-store/compare/v4.0.3...v4.0.4) (2023-08-10) 33 | 34 | ### Bug Fixes 35 | 36 | - **deps:** Update dependency groq-js to v1.1.12 ([#119](https://github.com/sanity-io/groq-store/issues/119)) ([86cceff](https://github.com/sanity-io/groq-store/commit/86cceffdd169340c565c8e67d6d236ace35f9835)) 37 | 38 | ## [4.0.3](https://github.com/sanity-io/groq-store/compare/v4.0.2...v4.0.3) (2023-08-07) 39 | 40 | ### Bug Fixes 41 | 42 | - add `node.module` export condition ([b7ec069](https://github.com/sanity-io/groq-store/commit/b7ec06943c6af995997918daa5abb95a5fa65fb7)) 43 | - **deps:** Update dependency groq-js to v1.1.11 ([#110](https://github.com/sanity-io/groq-store/issues/110)) ([2b8f065](https://github.com/sanity-io/groq-store/commit/2b8f06566b7c17b4d6bef959d090f07de163b949)) 44 | 45 | ## [4.0.2](https://github.com/sanity-io/groq-store/compare/v4.0.1...v4.0.2) (2023-08-04) 46 | 47 | ### Bug Fixes 48 | 49 | - **deps:** update dependency mendoza to v3.0.3 ([0f18346](https://github.com/sanity-io/groq-store/commit/0f18346b782657af70c724221c7190a26e5579d2)) 50 | 51 | ## [4.0.1](https://github.com/sanity-io/groq-store/compare/v4.0.0...v4.0.1) (2023-08-04) 52 | 53 | ### Bug Fixes 54 | 55 | - **deps:** update dependency mendoza to v3.0.2 ([#106](https://github.com/sanity-io/groq-store/issues/106)) ([a409702](https://github.com/sanity-io/groq-store/commit/a409702d588ffcf11ee1ed94afeb287927ad5472)) 56 | 57 | ## [4.0.0](https://github.com/sanity-io/groq-store/compare/v3.0.0...v4.0.0) (2023-08-04) 58 | 59 | ### ⚠ BREAKING CHANGES 60 | 61 | - **deps:** update dependency `mendoza` to 3.0.1 62 | 63 | ### Bug Fixes 64 | 65 | - **deps:** update dependency `mendoza` to 3.0.1 ([154f0b0](https://github.com/sanity-io/groq-store/commit/154f0b079bb70cb191a05db10d16ebe9e2a23aa6)) 66 | 67 | ## [3.0.0](https://github.com/sanity-io/groq-store/compare/v2.3.4...v3.0.0) (2023-08-04) 68 | 69 | ### ⚠ BREAKING CHANGES 70 | 71 | - **deps:** update dependency mendoza to v3 (#105) 72 | 73 | ### Bug Fixes 74 | 75 | - **deps:** update dependency mendoza to v3 ([#105](https://github.com/sanity-io/groq-store/issues/105)) ([f1f608f](https://github.com/sanity-io/groq-store/commit/f1f608f6b493758d183b0dc31bd42d03740a5ef9)) 76 | 77 | ## [2.3.4](https://github.com/sanity-io/groq-store/compare/v2.3.3...v2.3.4) (2023-08-04) 78 | 79 | ### Bug Fixes 80 | 81 | - **deps:** Update dependency mendoza to v2.1.2 ([#104](https://github.com/sanity-io/groq-store/issues/104)) ([1906d51](https://github.com/sanity-io/groq-store/commit/1906d51d7eaf13048b96d8e4d6160d876543c284)) 82 | 83 | ## [2.3.3](https://github.com/sanity-io/groq-store/compare/v2.3.2...v2.3.3) (2023-08-04) 84 | 85 | ### Bug Fixes 86 | 87 | - **deps:** Update dependency groq-js to v1.1.10 ([#103](https://github.com/sanity-io/groq-store/issues/103)) ([51133be](https://github.com/sanity-io/groq-store/commit/51133bedca4319120ab1032c36813b4b4c191398)) 88 | 89 | ## [2.3.2](https://github.com/sanity-io/groq-store/compare/v2.3.1...v2.3.2) (2023-08-04) 90 | 91 | ### Bug Fixes 92 | 93 | - **deps:** narrow semver ranges for better resilience against wild userland node_modules ([363ba3c](https://github.com/sanity-io/groq-store/commit/363ba3ca6bf4e70bbdc7d45273d53708d8e50056)) 94 | 95 | ## [2.3.1](https://github.com/sanity-io/groq-store/compare/v2.3.0...v2.3.1) (2023-07-26) 96 | 97 | ### Bug Fixes 98 | 99 | - add provenance ([024cb5d](https://github.com/sanity-io/groq-store/commit/024cb5d68e15111e7896801f4bcf4cb8367c76ef)) 100 | 101 | ## [2.3.0](https://github.com/sanity-io/groq-store/compare/v2.2.2...v2.3.0) (2023-06-28) 102 | 103 | ### Features 104 | 105 | - add support for `requestTagPrefix ` ([508639e](https://github.com/sanity-io/groq-store/commit/508639e64de2216dde7eba57c09667e83d231760)) 106 | 107 | ## [2.2.2](https://github.com/sanity-io/groq-store/compare/v2.2.1...v2.2.2) (2023-06-28) 108 | 109 | ### Bug Fixes 110 | 111 | - **deps:** update non-major ([#94](https://github.com/sanity-io/groq-store/issues/94)) ([fb9ba30](https://github.com/sanity-io/groq-store/commit/fb9ba303c43b972dab4b10479cf56ec1ea2a7c96)) 112 | 113 | ## [2.2.1](https://github.com/sanity-io/groq-store/compare/v2.2.0...v2.2.1) (2023-06-28) 114 | 115 | ### Bug Fixes 116 | 117 | - apply `includeTypes` to the `listen` query ([#91](https://github.com/sanity-io/groq-store/issues/91)) ([2fed96b](https://github.com/sanity-io/groq-store/commit/2fed96baa570a2608c891e3100fade7aef8215c1)) 118 | 119 | ## [2.2.0](https://github.com/sanity-io/groq-store/compare/v2.1.0...v2.2.0) (2023-06-27) 120 | 121 | ### Features 122 | 123 | - parity between overlayDrafts and perspectives=previewDrafts ([#92](https://github.com/sanity-io/groq-store/issues/92)) ([4e85b4f](https://github.com/sanity-io/groq-store/commit/4e85b4f656a2cf774d8d50f9d7b407e720bed022)) 124 | 125 | ### Bug Fixes 126 | 127 | - **docs:** the default `listen` value is `false` ([7264c50](https://github.com/sanity-io/groq-store/commit/7264c509c33e3d7d495f2072f7900d7d89a2e343)) 128 | 129 | ## [2.1.0](https://github.com/sanity-io/groq-store/compare/v2.0.9...v2.1.0) (2023-03-22) 130 | 131 | ### Features 132 | 133 | - use `@sanity/eventsource` instead of `eventsource` ([7656c97](https://github.com/sanity-io/groq-store/commit/7656c974a1f79f5772cb848612e86664071fdef3)) 134 | 135 | ## [2.0.9](https://github.com/sanity-io/groq-store/compare/v2.0.8...v2.0.9) (2023-03-22) 136 | 137 | ### Bug Fixes 138 | 139 | - **deps:** dedupe `groq-js` ([9b471ff](https://github.com/sanity-io/groq-store/commit/9b471ff980f089e095a15f400f17923f3743bc24)) 140 | - **deps:** lock file maintenance ([#82](https://github.com/sanity-io/groq-store/issues/82)) ([ba1a086](https://github.com/sanity-io/groq-store/commit/ba1a0869ff6811e630c0af7bb22b036ed1a78ad9)) 141 | 142 | ## [2.0.8](https://github.com/sanity-io/groq-store/compare/v2.0.7...v2.0.8) (2023-03-08) 143 | 144 | ### Bug Fixes 145 | 146 | - **deps:** update dependency groq-js to ^1.1.8 ([#68](https://github.com/sanity-io/groq-store/issues/68)) ([c5739f5](https://github.com/sanity-io/groq-store/commit/c5739f5b742db75036ba3ddcda5d43a67eee12c8)) 147 | 148 | ## [2.0.7](https://github.com/sanity-io/groq-store/compare/v2.0.6...v2.0.7) (2023-02-14) 149 | 150 | ### Bug Fixes 151 | 152 | - bump `groq-js` to `1.1.7` ([57ea046](https://github.com/sanity-io/groq-store/commit/57ea046ed323129f7eaf3fb7ed8c2c6283e4c3ab)) 153 | 154 | ## [2.0.6](https://github.com/sanity-io/groq-store/compare/v2.0.5...v2.0.6) (2023-02-01) 155 | 156 | ### Bug Fixes 157 | 158 | - **deps:** lock file maintenance ([#57](https://github.com/sanity-io/groq-store/issues/57)) ([8f2d0c2](https://github.com/sanity-io/groq-store/commit/8f2d0c291634c9cd8a84f753c727b5cc030f9a0a)) 159 | 160 | ## [2.0.5](https://github.com/sanity-io/groq-store/compare/v2.0.4...v2.0.5) (2023-01-23) 161 | 162 | ### Bug Fixes 163 | 164 | - **deps:** update dependency @sanity/semantic-release-preset to v4 ([#54](https://github.com/sanity-io/groq-store/issues/54)) ([b218e3f](https://github.com/sanity-io/groq-store/commit/b218e3f35b0e89a1820fa64898a0355d9c8aaeaf)) 165 | 166 | ## [2.0.4](https://github.com/sanity-io/groq-store/compare/v2.0.3...v2.0.4) (2023-01-14) 167 | 168 | ### Bug Fixes 169 | 170 | - **deps:** update devdependencies (non-major) ([#47](https://github.com/sanity-io/groq-store/issues/47)) ([8025c37](https://github.com/sanity-io/groq-store/commit/8025c372962077f1ab7830a9b8fea2dbc152c521)) 171 | 172 | ## [2.0.3](https://github.com/sanity-io/groq-store/compare/v2.0.2...v2.0.3) (2023-01-12) 173 | 174 | ### Bug Fixes 175 | 176 | - **deps:** update `groq` ([#44](https://github.com/sanity-io/groq-store/issues/44)) ([36367e0](https://github.com/sanity-io/groq-store/commit/36367e0d709649f83a9c283e2703ae1f603ef669)) 177 | 178 | ## [2.0.2](https://github.com/sanity-io/groq-store/compare/v2.0.1...v2.0.2) (2023-01-12) 179 | 180 | ### Bug Fixes 181 | 182 | - use the beta of `groq-js` ([22acdfe](https://github.com/sanity-io/groq-store/commit/22acdfed0b54f2ed281c68f76d085f1364696c08)) 183 | 184 | ## [2.0.1](https://github.com/sanity-io/groq-store/compare/v2.0.0...v2.0.1) (2023-01-12) 185 | 186 | ### Bug Fixes 187 | 188 | - use correct `pkg.typings` path ([b73ebe8](https://github.com/sanity-io/groq-store/commit/b73ebe899466ff0219a72477e434fcd7779441b2)) 189 | 190 | ## [2.0.0](https://github.com/sanity-io/groq-store/compare/v1.1.5...v2.0.0) (2023-01-12) 191 | 192 | ### ⚠ BREAKING CHANGES 193 | 194 | - no longer shipping ES5 syntax, the new compile target 195 | is browsers capable of running ESM natively. The Node.js baseline is 196 | still v14. 197 | 198 | ### Features 199 | 200 | - add 100% ESM support ([e2bb872](https://github.com/sanity-io/groq-store/commit/e2bb872b9e056d2d1aa1a2e1c604f4b74a49d3bd)) 201 | 202 | ### Bug Fixes 203 | 204 | - only specify the major version for dependencies that should dedupe well ([e970b07](https://github.com/sanity-io/groq-store/commit/e970b079af4eab8a35dec25d8de311dd228a79a9)) 205 | 206 | ## [1.1.5](https://github.com/sanity-io/groq-store/compare/v1.1.4...v1.1.5) (2023-01-12) 207 | 208 | ### Bug Fixes 209 | 210 | - **deps:** update sanity monorepo to v3 (major) ([#36](https://github.com/sanity-io/groq-store/issues/36)) ([4b3f284](https://github.com/sanity-io/groq-store/commit/4b3f2842c7661085bd54d1d66d47382c6a172f5f)) 211 | 212 | ## [1.1.4](https://github.com/sanity-io/groq-store/compare/v1.1.3...v1.1.4) (2022-11-18) 213 | 214 | ### Bug Fixes 215 | 216 | - support `swcMinify` in NextJS 13 ([f3063a4](https://github.com/sanity-io/groq-store/commit/f3063a423ca91697118ff6b8a16cbce0fe678869)) 217 | 218 | ## [1.1.3](https://github.com/sanity-io/groq-store/compare/v1.1.2...v1.1.3) (2022-11-16) 219 | 220 | ### Bug Fixes 221 | 222 | - bump `groq-js` ([8aad5a4](https://github.com/sanity-io/groq-store/commit/8aad5a424264d51eabce512c5e0182fa00ca878f)) 223 | 224 | ## [1.1.2](https://github.com/sanity-io/groq-store/compare/v1.1.1...v1.1.2) (2022-11-16) 225 | 226 | ### Bug Fixes 227 | 228 | - use tsdoc `@defaultValue` ([5c207d5](https://github.com/sanity-io/groq-store/commit/5c207d5bf7ddbf65b5f58575b4363b3b8b7baee5)) 229 | 230 | ## [1.1.1](https://github.com/sanity-io/groq-store/compare/v1.1.0...v1.1.1) (2022-11-15) 231 | 232 | ### Bug Fixes 233 | 234 | - improve error messages for invalid `token` and `EventSource` configs ([51aeb75](https://github.com/sanity-io/groq-store/commit/51aeb75394a3eb28287ede69f944aefc0ce86a95)) 235 | 236 | ## [1.1.0](https://github.com/sanity-io/groq-store/compare/v1.0.4...v1.1.0) (2022-11-15) 237 | 238 | ### Features 239 | 240 | - add support for fetching subset of dataset by type ([#2](https://github.com/sanity-io/groq-store/issues/2)) ([0171a06](https://github.com/sanity-io/groq-store/commit/0171a0668ad89a0d9e4098e11bbf84d7b9b93633)) 241 | 242 | ## [1.1.0-add-type-allowlist.1](https://github.com/sanity-io/groq-store/compare/v1.0.4...v1.1.0-add-type-allowlist.1) (2022-11-14) 243 | 244 | ### Features 245 | 246 | - add support for fetching subset of dataset by type ([0e06463](https://github.com/sanity-io/groq-store/commit/0e06463cb9b79669c541186f379ab2d7beccbcad)) 247 | 248 | ## [1.0.4](https://github.com/sanity-io/groq-store/compare/v1.0.3...v1.0.4) (2022-11-10) 249 | 250 | ### Bug Fixes 251 | 252 | - export `Config` typings ([70e8fcf](https://github.com/sanity-io/groq-store/commit/70e8fcf6c144796a7047b0fa0610c73497085f11)) 253 | 254 | ## [1.0.3](https://github.com/sanity-io/groq-store/compare/v1.0.2...v1.0.3) (2022-10-28) 255 | 256 | ### Bug Fixes 257 | 258 | - **deps:** update dependency groq-js to ^1.1.1 ([#25](https://github.com/sanity-io/groq-store/issues/25)) ([9c61b10](https://github.com/sanity-io/groq-store/commit/9c61b10ff1b8b7db3edaa008d6a1bc6dd8b50513)) 259 | 260 | ## [1.0.2](https://github.com/sanity-io/groq-store/compare/v1.0.1...v1.0.2) (2022-10-27) 261 | 262 | ### Bug Fixes 263 | 264 | - **README:** the min supported Node version is 14 ([0b717b2](https://github.com/sanity-io/groq-store/commit/0b717b232d3f769cf70946a735806eb47eaa378d)) 265 | 266 | ### [1.0.1](https://github.com/sanity-io/groq-store/compare/v1.0.0...v1.0.1) (2022-10-27) 267 | 268 | ### Bug Fixes 269 | 270 | - **deps:** update dependencies (non-major) ([#17](https://github.com/sanity-io/groq-store/issues/17)) ([2f1c7f6](https://github.com/sanity-io/groq-store/commit/2f1c7f61846eace223f8a2b69179919841d5f570)) 271 | - **deps:** update dependency eventsource to v2 ([#22](https://github.com/sanity-io/groq-store/issues/22)) ([c12c92d](https://github.com/sanity-io/groq-store/commit/c12c92da7a1a0599e4a032b15b8c669d01ba45df)) 272 | 273 | ## [1.0.0](https://github.com/sanity-io/groq-store/compare/v0.4.1...v1.0.0) (2022-10-27) 274 | 275 | ### ⚠ BREAKING CHANGES 276 | 277 | - adding `pkg.exports` can sometimes require changes in your build system. It should work exactly like before but we're making it a major release to ensure nobody pulls in this version accidentally 278 | 279 | ### Bug Fixes 280 | 281 | - add `pkg.exports` ([c261392](https://github.com/sanity-io/groq-store/commit/c261392db7de9e0e18d23efddbdab0112534ba5f)) 282 | 283 | ### [0.4.1](https://github.com/sanity-io/groq-store/compare/v0.4.0...v0.4.1) (2022-08-19) 284 | 285 | ### Bug Fixes 286 | 287 | - **deps:** previous commit updated groq-js ([14cdf8c](https://github.com/sanity-io/groq-store/commit/14cdf8c439deda9f24298930de012875c0547bf2)) 288 | --------------------------------------------------------------------------------