Skip to content

Commit

Permalink
feat: add createWriteClient and createMigration
Browse files Browse the repository at this point in the history
  • Loading branch information
angeloashmore committed Sep 13, 2024
1 parent 601edd5 commit 58c29b2
Show file tree
Hide file tree
Showing 3 changed files with 303 additions and 0 deletions.
188 changes: 188 additions & 0 deletions src/createMigration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import type {
ContentRelationshipField,
FilledContentRelationshipField,
} from "./types/value/contentRelationship"
import type { PrismicDocument } from "./types/value/document"
import type { LinkField } from "./types/value/link"

type PendingPrismicDocument<TDocument extends PrismicDocument> =
InjectMigrationSpecificTypes<
Pick<TDocument, "type" | "uid" | "lang" | "tags" | "data">
>

type ExistingPrismicDocument<TDocument extends PrismicDocument> =
PendingPrismicDocument<TDocument> & Pick<TDocument, "id">

type MigrationDocument<TDocument extends PrismicDocument> =
| PendingPrismicDocument<TDocument>
| ExistingPrismicDocument<TDocument>

type MigrationContentRelationship =
| PrismicDocument
| PrismicMigrationDocument
| undefined
| (() => PrismicDocument | PrismicMigrationDocument | undefined)
| (() => Promise<PrismicDocument | PrismicMigrationDocument | undefined>)

type MigrationFilledContentRelationshipField = Pick<
FilledContentRelationshipField,
"link_type" | "id"
>

type InjectMigrationSpecificTypes<T> = T extends null
? undefined
: T extends LinkField | ContentRelationshipField
? T | MigrationContentRelationship
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends Record<any, any>
? { [P in keyof T]: InjectMigrationSpecificTypes<T[P]> }
: T extends Array<infer U>
? Array<InjectMigrationSpecificTypes<U>>
: T

export const createMigration = <
TDocument extends PrismicDocument,
>(): Migration<TDocument> => new Migration<TDocument>()

export class Migration<TDocument extends PrismicDocument> {
documents: PrismicMigrationDocument<TDocument>[] = []

createDocument(
document: PendingPrismicDocument<TDocument>,
title: string,
options?: {
masterLanguageDocument?: MigrationContentRelationship
},
): PrismicMigrationDocument<TDocument> {
const migrationDocument = new PrismicMigrationDocument(
document,
title,
options,
)

this.documents.push(migrationDocument)

return migrationDocument
}

updateDocument(
document: ExistingPrismicDocument<TDocument>,
title?: string,
options?: {
masterLanguageDocument?: MigrationContentRelationship
},
): PrismicMigrationDocument<TDocument> {
const migrationDocument = new PrismicMigrationDocument(
document,
title,
options,
)

this.documents.push(migrationDocument)

return migrationDocument
}

getByUID<TType extends TDocument["type"]>(
type: TType,
uid: string,
): PrismicMigrationDocument<Extract<TDocument, { type: TType }>> | undefined {
return this.documents.find(
(
doc,
): doc is PrismicMigrationDocument<Extract<TDocument, { type: TType }>> =>
doc.document.type === type && doc.document.uid === uid,
)
}

getSingle(
type: TDocument["type"],
): PrismicMigrationDocument<TDocument> | undefined {
return this.documents.find((doc) => doc.document.type === type)
}
}

export class PrismicMigrationDocument<
TDocument extends PrismicDocument = PrismicDocument,
> {
document: MigrationDocument<TDocument> & Partial<Pick<TDocument, "id">>
title?: string
masterLanguageDocument?: MigrationContentRelationship

constructor(
document: MigrationDocument<TDocument>,
title?: string,
options?: {
masterLanguageDocument?: MigrationContentRelationship
},
) {
this.document = document
this.title = title
this.masterLanguageDocument = options?.masterLanguageDocument
}
}

export async function resolveMigrationDocumentData(
input: unknown,
): Promise<unknown> {
if (input instanceof PrismicMigrationDocument || isPrismicDocument(input)) {
return resolveMigrationContentRelationship(input)
}

if (typeof input === "function") {
return await resolveMigrationDocumentData(await input())
}

if (Array.isArray(input)) {
return await Promise.all(
input.map(async (element) => await resolveMigrationDocumentData(element)),
)
}

if (typeof input === "object") {
const res: Record<PropertyKey, unknown> = {}

for (const key in input) {
res[key] = await resolveMigrationDocumentData(
input[key as keyof typeof input],
)
}

return res
}

return input
}

export async function resolveMigrationContentRelationship(
relation: MigrationContentRelationship,
): Promise<MigrationFilledContentRelationshipField | undefined> {
if (typeof relation === "function") {
return resolveMigrationContentRelationship(await relation())
}

if (relation instanceof PrismicMigrationDocument) {
return relation.document.id
? { link_type: "Document", id: relation.document.id }
: undefined
}

if (relation) {
return { link_type: "Document", id: relation.id }
}
}

function isPrismicDocument(input: unknown): input is PrismicDocument {
try {
return (
typeof input === "object" &&
input !== null &&
"id" in input &&
"href" in input &&
typeof input.href === "string" &&
new URL(input.href).host.endsWith(".prismic.io")
)
} catch {
return false
}
}
108 changes: 108 additions & 0 deletions src/createWriteClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { PrismicDocument } from "./types/value/document"

import { Client, type ClientConfig } from "./createClient"
import {
type Migration,
type PrismicMigrationDocument,
resolveMigrationContentRelationship,
resolveMigrationDocumentData,
} from "./createMigration"
import { getRepositoryName } from "./getRepositoryName"

export const createWriteClient = <TDocuments extends PrismicDocument>(
repositoryName: string,
config: ClientConfig & { writeToken: string },
): WriteClient<TDocuments> =>
new WriteClient<TDocuments>(repositoryName, config)

export class WriteClient<
TDocument extends PrismicDocument = PrismicDocument,
> extends Client<TDocument> {
writeToken: string
migrationAPIEndpoint = "https://migration.prismic.io"

constructor(
repositoryName: string,
config: ClientConfig & { writeToken: string },
) {
super(repositoryName)
this.writeToken = config.writeToken
}

async migrate(migration: Migration<TDocument>): Promise<void> {
const docsWithoutMasterLangDoc = migration.documents.filter(
(doc) => !doc.masterLanguageDocument,
)
const docsWithMasterLangDoc = migration.documents.filter(
(doc) => doc.masterLanguageDocument,
)

// 1. Upload all docs without a master lang with no data
for (const doc of docsWithoutMasterLangDoc) {
await this.#pushDocument(doc, { withData: false })
}

// 2. Upload all documents with a master lang with no data
for (const doc of docsWithMasterLangDoc) {
await this.#pushDocument(doc, { withData: false })
}

// 3. Upload all documents with resolved data
for (const doc of migration.documents) {
await this.#pushDocument(doc)
}
}

async #pushDocument(
doc: PrismicMigrationDocument<TDocument>,
options?: { withData?: boolean },
) {
const isUpdating = Boolean(doc.document.id)

const documentData =
(options?.withData ?? true)
? await resolveMigrationDocumentData(doc.document.data)
: {}
const alternateLanguageDocument = await resolveMigrationContentRelationship(
doc.masterLanguageDocument,
)

const body = {
...doc.document,
data: documentData,
title: doc.title,
alternate_language_id: alternateLanguageDocument?.id,
}

const response = await this.#fetch(
new URL(
isUpdating ? `documents/${doc.document.id}` : "documents",
this.migrationAPIEndpoint,
),
{ method: isUpdating ? "PUT" : "POST", body: JSON.stringify(body) },
)
if (!response.ok) {
throw new Error(`Failed to push document: ${await response.text()}`)
}

const responseBody = await response.json()
doc.document.id = responseBody.id

// Wait for API rate limiting
await new Promise((res) => setTimeout(res, 1500))

return responseBody
}

async #fetch(input: RequestInfo | URL, init?: RequestInit) {
const request = new Request(input, init)

const headers = new Headers(init?.headers)
headers.set("repository", getRepositoryName(this.endpoint))
headers.set("authorization", `Bearer ${this.writeToken}`)
headers.set("x-api-key", "g2DA3EKWvx8uxVYcNFrmT5nJpon1Vi9V4XcOibJD")
headers.set("content-type", "application/json")

return fetch(new Request(request, { headers }))
}
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export type {
Route,
} from "./buildQueryURL"

//=============================================================================
// WriteClient - Write content to Prismic.
//=============================================================================

export { createMigration, Migration } from "./createMigration"
export { createWriteClient, WriteClient } from "./createWriteClient"

//=============================================================================
// Helpers - Manipulate content from Prismic.
//=============================================================================
Expand Down

0 comments on commit 58c29b2

Please sign in to comment.