Skip to content

Commit

Permalink
Support PocketBase v0.8 (#19)
Browse files Browse the repository at this point in the history
* add v0.8 schema

* update system fields to handle base and auth collection types

* handle relation fields with multiple items

* create enums for select fields that have values

* additional typecheck

* add user field for backwards compatability

* refactor strings

* refactor system types

* readme

* Add lint and formatting (#17)

* add linting and formatting

* lint codebase

* prettier

* update test workflow

* update test workflow again

* fix: url login for pocketbase 0.8.0-rc2 servers (#16)

Co-authored-by: Ethan Olsen <[email protected]>

* e2e integration test (#18)

* add dockerfile to run e2e tests

* add db typegen

* cleanup

* add test

* add github workflow

* remove interactive flag

* intentionally fail integration test

* save artifacts in case of failing tests

* fix output dir

* ignore files

Co-authored-by: Ethan Olsen <[email protected]>
Co-authored-by: Ethan Olsen <[email protected]>
  • Loading branch information
3 people authored Nov 19, 2022
1 parent a9c8156 commit d04a445
Show file tree
Hide file tree
Showing 26 changed files with 2,950 additions and 418 deletions.
22 changes: 22 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"eslint-config-prettier"
],
"overrides": [],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off"
}
}
32 changes: 32 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Integration Test

on:
push:
branches: [main, rc]
pull_request:
branches: "**"

jobs:
test:
timeout-minutes: 5
runs-on: ubuntu-latest
env:
CI: true

steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Run in docker
run: |
docker build . -t pocketbase-typegen:latest
docker run --name integration_test pocketbase-typegen:latest
mkdir -p output
docker cp integration_test:/app/output output
- name: Archive generated type results
uses: actions/upload-artifact@v3
with:
name: generated-types
path: output/*
retention-days: 5
20 changes: 10 additions & 10 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: Test

on:
push:
branches: [ main ]
branches: [main, rc]
pull_request:
branches: [ main ]
branches: "**"

jobs:
test:
Expand All @@ -17,11 +17,11 @@ jobs:
node-version: [16.x]

steps:
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build
- run: npm test
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build
- run: npm test
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
dist
coverage
test/pocketbase-types-example.ts
pocketbase-types.ts
33 changes: 33 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Dockerfile to run e2e integration tests against a test PocketBase server
FROM node:16-alpine3.16

ARG POCKETBASE_VERSION=0.8.0-rc2

WORKDIR /app/output/
WORKDIR /app/

# Install the dependencies
RUN apk add --no-cache \
ca-certificates \
unzip \
wget \
zip \
zlib-dev

# Download Pocketbase and install it
ADD https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}/pocketbase_${POCKETBASE_VERSION}_linux_amd64.zip /tmp/pocketbase.zip
RUN unzip /tmp/pocketbase.zip -d /app/

COPY package.json package-lock.json ./
RUN npm ci

# Copy test files
COPY test/integration ./
COPY test/pocketbase-types-example.ts ./
COPY dist/index.js ./dist/index.js

RUN chmod +x ./pocketbase
RUN chmod +x ./run.sh
EXPOSE 8090

CMD [ "./run.sh" ]
23 changes: 18 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Generate typescript definitions from your [pocketbase.io](https://pocketbase.io/

This will produce types for all your PocketBase collections to use in your frontend typescript codebase.

## Versions

When using PocketBase v0.8.x, use `pocketbase-typegen` v1.1.x

Users of PocketBase v0.7.x should use `pocketbase-typegen` v1.0.x

## Usage

```
Expand Down Expand Up @@ -38,11 +44,18 @@ URL example:

The output is a typescript file `pocketbase-types.ts` ([example](./test/pocketbase-types-example.ts)) which will contain:

- An enum of all collections
- One type for each collection (eg `ProfilesRecord`)
- One response type for each collection (eg `ProfilesResponse`) which includes base fields like id, updated, created
- A type `CollectionRecords` mapping each collection name to the record type
- `Collections` An enum of all collections/
- `[CollectionName]Record` One type for each collection (eg ProfilesRecord)/
- `[CollectionName]Response` One response type for each collection (eg ProfilesResponse) which includes system fields. This is what is returned from the PocketBase API.
- `[CollectionName][FieldName]Options` If the collection contains a select field with set values, an enum of the options will be generated.
- `CollectionRecords` A type mapping each collection name to the record type.

## Example usage

In the upcoming [PocketBase SDK](https://github.com/pocketbase/js-sdk) v0.8 you will be able to use generic types when fetching records, eg:

`pb.collection('tasks').getOne<Task>("RECORD_ID") // -> results in Promise<Task>`
```typescript
import { Collections, TasksResponse } from "./pocketbase-types"

pb.collection(Collections.Tasks).getOne<TasksResponse>("RECORD_ID") // -> results in Promise<TaskResponse>
```
148 changes: 90 additions & 58 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,36 +23,46 @@ async function fromJSON(path) {
}
async function fromURL(url, email = "", password = "") {
const formData = new FormData();
formData.append("email", email);
formData.append("identity", email);
formData.append("password", password);
const { token } = await fetch(`${url}/api/admins/auth-via-email`, {
const { token } = await fetch(`${url}/api/admins/auth-with-password`, {
method: "post",
body: formData
}).then((res) => res.json());
const result = await fetch(`${url}/api/collections?perPage=200`, {
headers: {
Authorization: `Admin ${token}`
Authorization: token
}
}).then((res) => res.json());
return result.items;
}

// src/constants.ts
var EXPORT_COMMENT = `// This file was @generated using pocketbase-typegen`;
var EXPORT_COMMENT = `/**
* This file was @generated using pocketbase-typegen
*/`;
var RECORD_TYPE_COMMENT = `// Record types for each collection`;
var RESPONSE_TYPE_COMMENT = `// Response types include system fields and match responses from the PocketBase API`;
var DATE_STRING_TYPE_NAME = `IsoDateString`;
var DATE_STRING_TYPE_DEFINITION = `export type ${DATE_STRING_TYPE_NAME} = string`;
var RECORD_ID_STRING_NAME = `RecordIdString`;
var RECORD_ID_STRING_DEFINITION = `export type ${RECORD_ID_STRING_NAME} = string`;
var USER_ID_STRING_NAME = `UserIdString`;
var USER_ID_STRING_DEFINITION = `export type ${USER_ID_STRING_NAME} = string`;
var BASE_RECORD_DEFINITION = `export type BaseRecord = {
var ALIAS_TYPE_DEFINITIONS = `// Alias types for improved usability
export type ${DATE_STRING_TYPE_NAME} = string
export type ${RECORD_ID_STRING_NAME} = string`;
var BASE_SYSTEM_FIELDS_DEFINITION = `// System fields
export type BaseSystemFields = {
id: ${RECORD_ID_STRING_NAME}
created: ${DATE_STRING_TYPE_NAME}
updated: ${DATE_STRING_TYPE_NAME}
"@collectionId": string
"@collectionName": string
"@expand"?: { [key: string]: any }
collectionId: string
collectionName: Collections
expand?: { [key: string]: any }
}`;
var AUTH_SYSTEM_FIELDS_DEFINITION = `export type AuthSystemFields = {
email: string
emailVisibility: boolean
username: string
verified: boolean
} & BaseSystemFields`;

// src/generics.ts
function fieldNameToGeneric(name) {
Expand Down Expand Up @@ -93,6 +103,12 @@ async function saveFile(outPath, typeString) {
await fs2.writeFile(outPath, typeString, "utf8");
console.log(`Created typescript definitions at ${outPath}`);
}
function getSystemFields(type) {
return type === "auth" ? "AuthSystemFields" : "BaseSystemFields";
}
function getOptionEnumName(recordName, fieldName) {
return `${toPascalCase(recordName)}${toPascalCase(fieldName)}Options`;
}

// src/lib.ts
var pbSchemaTypescriptMap = {
Expand All @@ -102,82 +118,98 @@ var pbSchemaTypescriptMap = {
email: "string",
url: "string",
date: DATE_STRING_TYPE_NAME,
select: (fieldSchema) => fieldSchema.options.values ? fieldSchema.options.values.map((val) => `"${val}"`).join(" | ") : "string",
select: (fieldSchema, collectionName) => fieldSchema.options.values ? getOptionEnumName(collectionName, fieldSchema.name) : "string",
json: (fieldSchema) => `null | ${fieldNameToGeneric(fieldSchema.name)}`,
file: (fieldSchema) => fieldSchema.options.maxSelect && fieldSchema.options.maxSelect > 1 ? "string[]" : "string",
relation: RECORD_ID_STRING_NAME,
user: USER_ID_STRING_NAME
relation: (fieldSchema) => fieldSchema.options.maxSelect && fieldSchema.options.maxSelect > 1 ? `${RECORD_ID_STRING_NAME}[]` : RECORD_ID_STRING_NAME,
user: (fieldSchema) => fieldSchema.options.maxSelect && fieldSchema.options.maxSelect > 1 ? `${RECORD_ID_STRING_NAME}[]` : RECORD_ID_STRING_NAME
};
function generate(results) {
const collectionNames = [];
const recordTypes = [];
results.forEach((row) => {
const responseTypes = [RESPONSE_TYPE_COMMENT];
results.sort((a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
}).forEach((row) => {
if (row.name)
collectionNames.push(row.name);
if (row.schema) {
recordTypes.push(createRecordType(row.name, row.schema));
recordTypes.push(createResponseType(row.name, row.schema));
responseTypes.push(createResponseType(row));
}
});
const sortedCollectionNames = collectionNames.sort();
const sortedCollectionNames = collectionNames;
const fileParts = [
EXPORT_COMMENT,
DATE_STRING_TYPE_DEFINITION,
RECORD_ID_STRING_DEFINITION,
USER_ID_STRING_DEFINITION,
BASE_RECORD_DEFINITION,
createCollectionEnum(sortedCollectionNames),
...recordTypes.sort(),
createCollectionRecord(sortedCollectionNames)
ALIAS_TYPE_DEFINITIONS,
BASE_SYSTEM_FIELDS_DEFINITION,
AUTH_SYSTEM_FIELDS_DEFINITION,
RECORD_TYPE_COMMENT,
...recordTypes,
responseTypes.join("\n"),
createCollectionRecords(sortedCollectionNames)
];
return fileParts.join("\n\n");
}
function createCollectionEnum(collectionNames) {
let typeString = `export enum Collections {
`;
collectionNames.forEach((name) => {
typeString += ` ${toPascalCase(name)} = "${name}",
`;
});
typeString += `}`;
const collections = collectionNames.map((name) => ` ${toPascalCase(name)} = "${name}",`).join("\n");
const typeString = `export enum Collections {
${collections}
}`;
return typeString;
}
function createCollectionRecord(collectionNames) {
let typeString = `export type CollectionRecords = {
`;
collectionNames.forEach((name) => {
typeString += ` ${name}: ${toPascalCase(name)}Record
`;
});
typeString += `}`;
return typeString;
function createCollectionRecords(collectionNames) {
const nameRecordMap = collectionNames.map((name) => ` ${name}: ${toPascalCase(name)}Record`).join("\n");
return `export type CollectionRecords = {
${nameRecordMap}
}`;
}
function createRecordType(name, schema) {
let typeString = `export type ${toPascalCase(
name
)}Record${getGenericArgStringWithDefault(schema)} = {
`;
schema.forEach((fieldSchema) => {
typeString += createTypeField(fieldSchema);
});
typeString += `}`;
return typeString;
const selectOptionEnums = createSelectOptions(name, schema);
const typeName = toPascalCase(name);
const genericArgs = getGenericArgStringWithDefault(schema);
const fields = schema.map((fieldSchema) => createTypeField(name, fieldSchema)).join("\n");
return `${selectOptionEnums}export type ${typeName}Record${genericArgs} = {
${fields}
}`;
}
function createResponseType(name, schema) {
function createResponseType(collectionSchemaEntry) {
const { name, schema, type } = collectionSchemaEntry;
const pascaleName = toPascalCase(name);
let typeString = `export type ${pascaleName}Response${getGenericArgStringWithDefault(
schema
)} = ${pascaleName}Record${getGenericArgString(schema)} & BaseRecord`;
return typeString;
const genericArgsWithDefaults = getGenericArgStringWithDefault(schema);
const genericArgs = getGenericArgString(schema);
const systemFields = getSystemFields(type);
return `export type ${pascaleName}Response${genericArgsWithDefaults} = ${pascaleName}Record${genericArgs} & ${systemFields}`;
}
function createTypeField(fieldSchema) {
function createTypeField(collectionName, fieldSchema) {
if (!(fieldSchema.type in pbSchemaTypescriptMap)) {
throw new Error(`unknown type ${fieldSchema.type} found in schema`);
}
const typeStringOrFunc = pbSchemaTypescriptMap[fieldSchema.type];
const typeString = typeof typeStringOrFunc === "function" ? typeStringOrFunc(fieldSchema) : typeStringOrFunc;
return ` ${sanitizeFieldName(fieldSchema.name)}${fieldSchema.required ? "" : "?"}: ${typeString}
const typeString = typeof typeStringOrFunc === "function" ? typeStringOrFunc(fieldSchema, collectionName) : typeStringOrFunc;
const fieldName = sanitizeFieldName(fieldSchema.name);
const required = fieldSchema.required ? "" : "?";
return ` ${fieldName}${required}: ${typeString}`;
}
function createSelectOptions(recordName, schema) {
const selectFields = schema.filter((field) => field.type === "select");
const typestring = selectFields.map(
(field) => {
var _a;
return `export enum ${getOptionEnumName(recordName, field.name)} {
${(_a = field.options.values) == null ? void 0 : _a.map((val) => ` ${val} = "${val}",`).join("\n")}
}
`;
}
).join("\n");
return typestring;
}

// src/cli.ts
Expand All @@ -203,7 +235,7 @@ async function main(options2) {
import { program } from "commander";

// package.json
var version = "1.0.12";
var version = "1.1.0";

// src/index.ts
program.name("Pocketbase Typegen").version(version).description(
Expand Down
Loading

0 comments on commit d04a445

Please sign in to comment.