diff --git a/.changeset/content-resource-sync.md b/.changeset/content-resource-sync.md
new file mode 100644
index 0000000..7253606
--- /dev/null
+++ b/.changeset/content-resource-sync.md
@@ -0,0 +1,5 @@
+---
+"@quranjs/api": minor
+---
+
+Add content resource sync and snapshot SDK helpers.
diff --git a/apps/docs/content/docs/resources.mdx b/apps/docs/content/docs/resources.mdx
index 8726bc2..87b92f5 100644
--- a/apps/docs/content/docs/resources.mdx
+++ b/apps/docs/content/docs/resources.mdx
@@ -5,6 +5,67 @@ description: Access metadata about translations, tafsirs, reciters, and language
The Resources API provides metadata about translations, tafsirs, reciters, and other Quranic resources.
+## Content Resource Sync
+
+Use content sync when your app needs to cache public resources locally and keep them updated without refetching every resource list.
+
+### Bootstrap Resources
+
+```ts
+const page = await client.resources.sync({
+ bootstrap: true,
+ resources: "articles:*;tafsirs:151;translations:1,6",
+ perPage: 100,
+});
+
+for (const mutation of page.sync.mutations) {
+ if (mutation.snapshotUrl) {
+ const snapshot = await client.resources.findSnapshot(
+ mutation.resourceGroup,
+ mutation.resourceId,
+ );
+ console.log(snapshot.records);
+ }
+}
+```
+
+If `page.sync.hasMore` is true, fetch `page.sync.nextPageUrl` through your backend or call `sync` again with the cursor from that URL. Store `page.sync.nextSyncToken` from the final bootstrap page.
+
+### Poll Incremental Changes
+
+```ts
+const changes = await client.resources.sync({
+ resources: "articles:*;tafsirs:151;translations:1,6",
+ syncToken: savedSyncToken,
+});
+
+for (const mutation of changes.sync.mutations) {
+ if (
+ mutation.type === "ROW_UPDATE" &&
+ mutation.recordKey !== null &&
+ mutation.data !== null
+ ) {
+ updateLocalRow(mutation.recordKey, mutation.data);
+ }
+}
+```
+
+Use `client.content.v4.resources.sync(...)` and `client.content.v4.resources.findSnapshot(...)` when working through the namespaced v4 client.
+
+### ContentSyncResponse Type
+
+
+
+### ContentResourceSnapshot Type
+
+
+
## Recitations
### Get All Recitations
@@ -101,7 +162,7 @@ tafsirs.forEach((t) => {
```ts
const info = await client.resources.findTafsirInfo("171");
-console.log(info.name); // "Tafsir Ibn Kathir"
+console.log(info.name); // "Tafsir Ibn Kathir"
console.log(info.authorName); // "Ibn Kathir"
console.log(info.bio);
```
@@ -119,7 +180,9 @@ console.log(info.bio);
const languages = await client.resources.findAllLanguages();
languages.forEach((lang) => {
- console.log(`${lang.name} (${lang.iso}): ${lang.translationsCount} translations`);
+ console.log(
+ `${lang.name} (${lang.iso}): ${lang.translationsCount} translations`,
+ );
});
```
@@ -171,8 +234,8 @@ reciters.forEach((r) => {
```ts
const styles = await client.resources.findAllRecitationStyles();
-console.log(styles.murattal); // Murattal reciters
-console.log(styles.mujawwad); // Mujawwad reciters
+console.log(styles.murattal); // Murattal reciters
+console.log(styles.mujawwad); // Mujawwad reciters
```
### RecitationStylesResource Type
@@ -188,8 +251,8 @@ console.log(styles.mujawwad); // Mujawwad reciters
const media = await client.resources.findVerseMedia();
console.log(media.unicode); // Font URL
-console.log(media.image); // Images base URL
-console.log(media.audio); // Audio base URL
+console.log(media.image); // Images base URL
+console.log(media.audio); // Audio base URL
```
### VerseMediaResource Type
diff --git a/packages/api/mocks/handlers.ts b/packages/api/mocks/handlers.ts
index f29b051..8fc8f97 100644
--- a/packages/api/mocks/handlers.ts
+++ b/packages/api/mocks/handlers.ts
@@ -122,6 +122,70 @@ export const handlers = [
},
),
+ http.get(
+ "https://apis.quran.foundation/content/api/v4/resources/sync",
+ ({ request }) => {
+ try {
+ validateAuth(request);
+ return HttpResponse.json({
+ sync: {
+ sync_until_sequence: 98100,
+ has_more: false,
+ next_page_url: null,
+ next_sync_token: "sync-token-98100",
+ mutations: [
+ {
+ sequence: 98100,
+ type: "ROW_UPDATE",
+ resource_group: "translations",
+ resource_id: 19,
+ resource_content_id: 19,
+ record_type: "translation",
+ record_key: "85108",
+ source_record_id: 85108,
+ changed_at: "2026-05-05T10:00:00Z",
+ data: {
+ id: 85108,
+ verse_key: "26:153",
+ text: "They said: Thou art but one of the bewitched;",
+ },
+ snapshot_url: null,
+ unavailable_reason: null,
+ },
+ ],
+ },
+ });
+ } catch {
+ return HttpResponse.text("Unauthorized", { status: 401 });
+ }
+ },
+ ),
+
+ http.get(
+ "https://apis.quran.foundation/content/api/v4/resources/snapshots/:resource_group/:id",
+ ({ request, params }) => {
+ try {
+ validateAuth(request);
+ return HttpResponse.json({
+ resource_group: params.resource_group,
+ resource_id: Number(params.id),
+ resource_content_id: Number(params.id),
+ schema_version: 1,
+ sync_sequence: 98100,
+ records: [
+ {
+ id: 85108,
+ verse_key: "26:153",
+ text: "They said: Thou art but one of the bewitched;",
+ },
+ ],
+ });
+ } catch {
+ return HttpResponse.text("Unauthorized", { status: 401 });
+ }
+ },
+ ),
+
http.get("https://apis.quran.foundation/v1/search", ({ request }) => {
try {
validateAuth(request);
diff --git a/packages/api/src/generated/specs/operation-catalog.json b/packages/api/src/generated/specs/operation-catalog.json
index 4dabfac..0aa3f0d 100644
--- a/packages/api/src/generated/specs/operation-catalog.json
+++ b/packages/api/src/generated/specs/operation-catalog.json
@@ -250,6 +250,21 @@
"method": "get",
"path": "/hadith_references/count_within_range"
},
+ "listPages": {
+ "auth": "app",
+ "method": "get",
+ "path": "/pages"
+ },
+ "getPagesLookup": {
+ "auth": "app",
+ "method": "get",
+ "path": "/pages/lookup"
+ },
+ "getPagesPageNumber": {
+ "auth": "app",
+ "method": "get",
+ "path": "/pages/{page_number}"
+ },
"versesByChapterNumber": {
"auth": "app",
"method": "get",
@@ -659,6 +674,16 @@
"auth": "app",
"method": "get",
"path": "/answers/count_within_range"
+ },
+ "resourcesSync": {
+ "auth": "app",
+ "method": "get",
+ "path": "/resources/sync"
+ },
+ "resourcesSnapshot": {
+ "auth": "app",
+ "method": "get",
+ "path": "/resources/snapshots/{resource_group}/{id}"
}
},
"service": "content",
diff --git a/packages/api/src/runtime/create-client.ts b/packages/api/src/runtime/create-client.ts
index 21d583c..3489c45 100644
--- a/packages/api/src/runtime/create-client.ts
+++ b/packages/api/src/runtime/create-client.ts
@@ -2,6 +2,8 @@ import type { AuthService } from "@/generated/public-contracts";
import type {
ApiParams,
ChapterId,
+ ContentSyncOptions,
+ ContentSyncResourceGroup,
HizbNumber,
HTTPMethod,
JuzNumber,
@@ -393,6 +395,11 @@ const createContentFacade = (
},
raw,
resources: {
+ findSnapshot: (
+ resourceGroup: ContentSyncResourceGroup,
+ id: string | number,
+ query?: ApiParams,
+ ) => resources.findSnapshot(resourceGroup, id, query),
chapterInfos: {
list: (query?: ApiParams) => resources.findAllChapterInfos(query),
},
@@ -423,6 +430,7 @@ const createContentFacade = (
verseMedia: {
list: (query?: ApiParams) => resources.findVerseMedia(query),
},
+ sync: (query?: ContentSyncOptions) => resources.sync(query),
},
verses: {
byChapter: (id: ChapterId, query?: ApiParams) =>
diff --git a/packages/api/src/sdk/resources.ts b/packages/api/src/sdk/resources.ts
index 60a7ca5..78b56e0 100644
--- a/packages/api/src/sdk/resources.ts
+++ b/packages/api/src/sdk/resources.ts
@@ -2,6 +2,11 @@ import type {
BaseApiParams,
ChapterInfoResource,
ChapterReciterResource,
+ ContentResourceSnapshot,
+ ContentResourceSnapshotOptions,
+ ContentSyncOptions,
+ ContentSyncResourceGroup,
+ ContentSyncResponse,
LanguageResource,
QuranFetchClient,
RecitationInfoResource,
@@ -203,4 +208,40 @@ export class QuranResources {
return verseMedia;
}
+
+ /**
+ * Bootstrap or incrementally sync public content resources.
+ * @param {ContentSyncOptions} options
+ * @example
+ * client.resources.sync({ bootstrap: true, resources: "translations:19" })
+ */
+ async sync = Record>(
+ options?: ContentSyncOptions,
+ ): Promise> {
+ return this.fetcher.fetch>(
+ "/content/api/v4/resources/sync",
+ options,
+ );
+ }
+
+ /**
+ * Fetch the current full snapshot for a syncable public content resource.
+ * @param {ContentSyncResourceGroup} resourceGroup
+ * @param {string | number} id
+ * @param {ContentResourceSnapshotOptions} options
+ * @example
+ * client.resources.findSnapshot("translations", 19)
+ */
+ async findSnapshot<
+ TRecord extends Record = Record,
+ >(
+ resourceGroup: ContentSyncResourceGroup,
+ id: string | number,
+ options?: ContentResourceSnapshotOptions,
+ ): Promise> {
+ return this.fetcher.fetch>(
+ `/content/api/v4/resources/snapshots/${resourceGroup}/${id}`,
+ options,
+ );
+ }
}
diff --git a/packages/api/src/types/api/Resources.ts b/packages/api/src/types/api/Resources.ts
index c3de2a0..0ebd5cd 100644
--- a/packages/api/src/types/api/Resources.ts
+++ b/packages/api/src/types/api/Resources.ts
@@ -1,3 +1,4 @@
+import type { ApiParams } from "../BaseApiParams";
import type { TranslatedName } from "./TranslatedName";
export interface RecitationResource {
@@ -79,3 +80,73 @@ export interface ChapterReciterResource {
format?: string;
filesSize?: number; // in kb
}
+
+export type ContentSyncResourceGroup =
+ | "articles"
+ | "recitations"
+ | "tafsirs"
+ | "translations";
+
+export type ContentSyncMutationType =
+ | "RESOURCE_CREATE"
+ | "RESOURCE_UPDATE"
+ | "RESOURCE_DELETE"
+ | "ROW_CREATE"
+ | "ROW_UPDATE"
+ | "ROW_DELETE"
+ | "RESOURCE_INVALIDATE";
+
+export interface ContentSyncOptions extends ApiParams {
+ /** Resource filter, e.g. `articles:*;translations:1,6`. */
+ resources?: string;
+ /** Set to true for the initial sync. */
+ bootstrap?: boolean;
+ /** Token returned by the final bootstrap or incremental page. */
+ syncToken?: string;
+ /** Pagination cursor from `nextPageUrl`. */
+ cursor?: string;
+ /** Page size, up to the API maximum. */
+ perPage?: number;
+}
+
+export type ContentResourceSnapshotOptions = ApiParams;
+
+export interface ContentSyncMutation<
+ TData extends Record = Record,
+> {
+ sequence: number;
+ type: ContentSyncMutationType;
+ resourceGroup: ContentSyncResourceGroup;
+ resourceId: number;
+ resourceContentId: number | null;
+ recordType: string | null;
+ recordKey: string | null;
+ sourceRecordId: number | null;
+ changedAt: string;
+ data: TData | null;
+ snapshotUrl: string | null;
+ unavailableReason: string | null;
+}
+
+export interface ContentSyncResponse<
+ TData extends Record = Record,
+> {
+ sync: {
+ syncUntilSequence: number;
+ hasMore: boolean;
+ nextPageUrl: string | null;
+ nextSyncToken: string | null;
+ mutations: ContentSyncMutation[];
+ };
+}
+
+export interface ContentResourceSnapshot<
+ TRecord extends Record = Record,
+> {
+ resourceGroup: ContentSyncResourceGroup;
+ resourceId: number;
+ resourceContentId: number | null;
+ schemaVersion: number;
+ syncSequence: number;
+ records: TRecord[];
+}
diff --git a/packages/api/test/resources.test.ts b/packages/api/test/resources.test.ts
index 3e06763..d8e85c1 100644
--- a/packages/api/test/resources.test.ts
+++ b/packages/api/test/resources.test.ts
@@ -1,11 +1,18 @@
+import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest";
+import { server } from "../mocks/server";
import { testClient } from "./test-client";
const VALID_RECITATION_ID = "1";
const VALID_TRANSLATION_ID = "1";
const VALID_TAFSIR_ID = "169";
+const expectCapturedUrl = (url: URL | null): URL => {
+ expect(url).not.toBeNull();
+ return url!;
+};
+
describe("Resources API", () => {
describe("findAllChapterInfos()", () => {
it("should return an array of chapter infos", async () => {
@@ -94,4 +101,144 @@ describe("Resources API", () => {
expect(response).toBeDefined();
});
});
+
+ describe("sync()", () => {
+ it("serializes bootstrap sync request params", async () => {
+ let requestUrl: URL | null = null;
+
+ server.use(
+ http.get(
+ "https://apis.quran.foundation/content/api/v4/resources/sync",
+ ({ request }) => {
+ requestUrl = new URL(request.url);
+ return HttpResponse.json({
+ sync: {
+ sync_until_sequence: 1,
+ has_more: false,
+ next_page_url: null,
+ next_sync_token: "sync-token-1",
+ mutations: [],
+ },
+ });
+ },
+ ),
+ );
+
+ await testClient.resources.sync({
+ bootstrap: true,
+ resources: "articles:*;translations:1,6",
+ perPage: 100,
+ });
+
+ const url = expectCapturedUrl(requestUrl);
+ expect(url.pathname).toBe("/content/api/v4/resources/sync");
+ expect(url.searchParams.get("bootstrap")).toBe("true");
+ expect(url.searchParams.get("resources")).toBe(
+ "articles:*;translations:1,6",
+ );
+ expect(url.searchParams.get("per_page")).toBe("100");
+ });
+
+ it("serializes incremental sync request params", async () => {
+ let requestUrl: URL | null = null;
+
+ server.use(
+ http.get(
+ "https://apis.quran.foundation/content/api/v4/resources/sync",
+ ({ request }) => {
+ requestUrl = new URL(request.url);
+ return HttpResponse.json({
+ sync: {
+ sync_until_sequence: 2,
+ has_more: false,
+ next_page_url: null,
+ next_sync_token: "sync-token-2",
+ mutations: [],
+ },
+ });
+ },
+ ),
+ );
+
+ await testClient.resources.sync({
+ resources: "translations:19",
+ syncToken: "sync-token-1",
+ perPage: 50,
+ });
+
+ const url = expectCapturedUrl(requestUrl);
+ expect(url.searchParams.get("resources")).toBe("translations:19");
+ expect(url.searchParams.get("sync_token")).toBe("sync-token-1");
+ expect(url.searchParams.get("per_page")).toBe("50");
+ expect(url.searchParams.has("bootstrap")).toBe(false);
+ });
+
+ it("camel-cases sync mutation fields", async () => {
+ const response = await testClient.resources.sync({
+ resources: "translations:19",
+ bootstrap: true,
+ });
+
+ expect(response.sync.syncUntilSequence).toBe(98100);
+ expect(response.sync.nextSyncToken).toBe("sync-token-98100");
+ expect(response.sync.hasMore).toBe(false);
+
+ const mutation = response.sync.mutations[0];
+ expect(mutation?.resourceGroup).toBe("translations");
+ expect(mutation?.resourceContentId).toBe(19);
+ expect(mutation?.recordType).toBe("translation");
+ expect(mutation?.recordKey).toBe("85108");
+ expect(mutation?.sourceRecordId).toBe(85108);
+ expect(mutation?.changedAt).toBe("2026-05-05T10:00:00Z");
+ expect(mutation?.snapshotUrl).toBeNull();
+ expect(mutation?.unavailableReason).toBeNull();
+ expect(mutation?.data?.verseKey).toBe("26:153");
+ });
+
+ it("exposes sync through content.v4.resources", async () => {
+ const response = await testClient.content.v4.resources.sync({
+ resources: "translations:19",
+ bootstrap: true,
+ });
+
+ expect(response.sync.nextSyncToken).toBe("sync-token-98100");
+ });
+ });
+
+ describe("findSnapshot()", () => {
+ it("serializes snapshot path params", async () => {
+ let requestUrl: URL | null = null;
+
+ server.use(
+ http.get(
+ "https://apis.quran.foundation/content/api/v4/resources/snapshots/:resourceGroup/:id",
+ ({ request, params }) => {
+ requestUrl = new URL(request.url);
+ return HttpResponse.json({
+ resource_group: params.resourceGroup,
+ resource_id: Number(params.id),
+ resource_content_id: Number(params.id),
+ schema_version: 1,
+ sync_sequence: 98100,
+ records: [],
+ });
+ },
+ ),
+ );
+
+ const snapshot = await testClient.content.v4.resources.findSnapshot(
+ "translations",
+ 19,
+ );
+
+ const url = expectCapturedUrl(requestUrl);
+ expect(url.pathname).toBe(
+ "/content/api/v4/resources/snapshots/translations/19",
+ );
+ expect(snapshot.resourceGroup).toBe("translations");
+ expect(snapshot.resourceId).toBe(19);
+ expect(snapshot.resourceContentId).toBe(19);
+ expect(snapshot.syncSequence).toBe(98100);
+ });
+ });
});