Skip to content
Open
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules
pocketbase-types.ts
pocketbase-metadata.ts
coverage
*.db-shm
*.db-wal
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
fromURLWithToken,
} from "./schema"

import { generate } from "./lib"
import { generate, generateMetadata } from "./lib"
import { saveFile } from "./utils"

export async function main(options: Options) {
Expand Down Expand Up @@ -60,5 +60,13 @@ export async function main(options: Options) {
sdk: options.sdk ?? true,
})
await saveFile(options.out, typeString)

// Generate and save metadata if requested
if (options.includeMetadata) {
const metadataString = generateMetadata(schema)
const metadataOutPath = options.metadataOut ?? "pocketbase-metadata.ts"
await saveFile(metadataOutPath, metadataString)
}

return typeString
}
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ program
"--no-sdk",
"Removes the pocketbase package dependency. A typed version of the SDK will not be generated."
)
.option(
"--includeMetadata",
"Generates a secondary file containing JavaScript objects that represent your schema's metadata, such as constraints and enums."
)
.option(
"--metadata-out <path>",
"Output path for metadata file",
"pocketbase-metadata.ts"
)

program.parse(process.argv)
const options = program.opts<Options>()
Expand Down
30 changes: 28 additions & 2 deletions src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
createCollectionEnum,
createCollectionRecords,
createCollectionResponses
createCollectionResponses,
} from "./collections"
import {
ALIAS_TYPE_DEFINITIONS,
Expand All @@ -16,13 +16,14 @@ import {
RECORD_TYPE_COMMENT,
RESPONSE_TYPE_COMMENT,
TYPED_POCKETBASE_TYPE,
UTILITY_TYPES
UTILITY_TYPES,
} from "./constants"
import { createSelectOptions, createTypeField } from "./fields"
import {
getGenericArgStringForRecord,
getGenericArgStringWithDefault,
} from "./generics"
import { createFieldMetadata } from "./metadata"
import { CollectionRecord, FieldSchema } from "./types"
import { containsGeoPoint, getSystemFields, toPascalCase } from "./utils"

Expand All @@ -37,6 +38,7 @@ export function generate(
const collectionNames: Array<string> = []
const recordTypes: Array<string> = []
const responseTypes: Array<string> = [RESPONSE_TYPE_COMMENT]
const fieldMetadata: Array<string> = []

results
.sort((a, b) => (a.name <= b.name ? -1 : 1))
Expand All @@ -45,6 +47,8 @@ export function generate(
if (row.fields) {
recordTypes.push(createRecordType(row.name, row.fields))
responseTypes.push(createResponseType(row))
const metadata = createFieldMetadata(row.name, row.fields)
if (metadata) fieldMetadata.push(metadata)
}
})
const sortedCollectionNames = collectionNames
Expand Down Expand Up @@ -109,3 +113,25 @@ export function createResponseType(

return `export type ${pascaleName}Response${genericArgsWithDefaults} = Required<${pascaleName}Record${genericArgsForRecord}> & ${systemFields}${expandArgString}`
}

export function generateMetadata(results: Array<CollectionRecord>): string {
const fieldMetadata: Array<string> = []

results
.sort((a, b) => (a.name <= b.name ? -1 : 1))
.forEach((row) => {
if (row.name && row.fields) {
const metadata = createFieldMetadata(row.name, row.fields)
if (metadata) fieldMetadata.push(metadata)
}
})

const fileParts = [
EXPORT_COMMENT,
fieldMetadata.length > 0
? fieldMetadata.join("\n")
: "// No field metadata found",
]

return fileParts.filter(Boolean).join("\n\n") + "\n"
}
65 changes: 65 additions & 0 deletions src/metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { FieldSchema } from "./types"
import { toPascalCase } from "./utils"

export function createFieldMetadata(
name: string,
schema: Array<FieldSchema>
): string {

const metadata = schema
.map((field) => {
const parts: string[] = []

// numeric range
if (typeof field.min === "number" && field.min !== 0) {
parts.push(`\t\tmin: ${field.min},`)
}
if (typeof field.max === "number" && field.max !== 0) {
parts.push(`\t\tmax: ${field.max},`)
}

// selection and file-related
if (typeof (field as any).maxSelect === "number" && (field as any).maxSelect !== 0) {
parts.push(`\t\tmaxSelect: ${(field as any).maxSelect},`)
}
if (typeof (field as any).maxSize === "number" && (field as any).maxSize !== 0) {
parts.push(`\t\tmaxSize: ${(field as any).maxSize},`)
}
if (Array.isArray((field as any).mimeTypes) && (field as any).mimeTypes.length > 0) {
parts.push(`\t\tmimeTypes: ${JSON.stringify((field as any).mimeTypes)},`)
}

// presence / uniqueness
if (typeof field.required === "boolean") {
parts.push(`\t\trequired: ${field.required},`)
}
if (typeof field.unique === "boolean") {
parts.push(`\t\tunique: ${field.unique},`)
}

// pattern / values
if (typeof (field as any).pattern === "string" && (field as any).pattern) {
parts.push(`\t\tpattern: ${JSON.stringify((field as any).pattern)},`)
}
if (Array.isArray((field as any).values) && (field as any).values.length > 0) {
parts.push(`\t\tvalues: ${JSON.stringify((field as any).values)},`)
}

// lifecycle flags
if (typeof (field as any).onCreate === "boolean") {
parts.push(`\t\tonCreate: ${(field as any).onCreate},`)
}
if (typeof (field as any).onUpdate === "boolean") {
parts.push(`\t\tonUpdate: ${(field as any).onUpdate},`)
}

if (parts.length === 0) return null

return `\t${field.name}: {\n${parts.join("\n")}\n\t},`
})
.filter(Boolean)
if (metadata.length === 0) return ""

const typeName = toPascalCase(name)
return `export const ${typeName}FieldMetadata = {\n${metadata.join("\n")}\n} as const\n`
}
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export type Options = {
token?: string
sdk?: boolean
env?: boolean | string
includeMetadata?: boolean // New option
// Output path for metadata file. Default provided by CLI: "pocketbase-metadata.ts"
metadataOut?: string // New option for output file
}

export type FieldSchema = {
Expand Down Expand Up @@ -50,6 +53,8 @@ export type CollectionRecord = {
// Every field is optional
export type RecordOptions = {
maxSelect?: number | null
maxSize?: number | null
mimeTypes?: string[] | null
min?: number | null
max?: number | null
pattern?: string
Expand Down
76 changes: 76 additions & 0 deletions test/__snapshots__/metadata.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`createFieldMetadata creates min/max metadata 1`] = `
"export const UsersFieldMetadata = {
id: {
min: 15,
max: 15,
required: true,
unique: false,
},
} as const
"
`;

exports[`createFieldMetadata generates metadata for the everything collection 1`] = `
"export const EverythingFieldMetadata = {
id: {
min: 15,
max: 15,
required: true,
unique: false,
pattern: "^[a-z0-9]+$",
},
select_field: {
maxSelect: 1,
required: false,
unique: false,
values: ["optionA","OptionA","optionB"],
},
json_field: {
maxSize: 2000000,
required: false,
unique: false,
},
three_files_field: {
maxSelect: 99,
required: false,
unique: false,
},
custom_relation_field: {
maxSelect: 999,
required: false,
unique: false,
},
created: {
required: false,
unique: false,
onCreate: true,
onUpdate: false,
},
updated: {
required: false,
unique: false,
onCreate: false,
onUpdate: true,
},
} as const
"
`;

exports[`createFieldMetadata includes other metadata fields when present 1`] = `
"export const MediaFieldMetadata = {
avatar: {
maxSelect: 1,
maxSize: 1024,
mimeTypes: ["image/png"],
required: false,
unique: true,
pattern: "^abc$",
values: ["a","b"],
onCreate: true,
onUpdate: false,
},
} as const
"
`;
Loading