diff --git a/.prettierrc b/.prettierrc index decd7ec..62fdbfe 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,6 +2,6 @@ "useTabs": true, "singleQuote": true, "trailingComma": "none", - "printWidth": 100, + "printWidth": 80, "tabWidth": 4 } diff --git a/CHANGELOG.md b/CHANGELOG.md index bce4c65..3540bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # typed-pocketbase +## 0.0.3 + +### Patch Changes + +- dd52f65: rework codegen cli and support all field types and modifiers +- 4201a28: export TypedRecord +- 5b60b0c: fix entrypoints and file thumbs type when none are specified +- 376dc88: Add expand and nested filter support + +## 0.0.3-next.3 + +### Patch Changes + +- rework codegen cli and support all field types and modifiers + +## 0.0.3-next.2 + +### Patch Changes + +- export TypedRecord + +## 0.0.3-next.1 + +### Patch Changes + +- fix entrypoints and file thumbs type when none are specified + +## 0.0.3-next.0 + +### Patch Changes + +- Add expand and nested filter support + ## 0.0.2 ### Patch Changes diff --git a/README.md b/README.md index 65aba39..1cb22b0 100644 --- a/README.md +++ b/README.md @@ -10,31 +10,33 @@ Add types to the [PocketBase JavaScript SDK](https://github.com/pocketbase/js-sd ```bash # npm -npm i typed-pocketbase +npm i typed-pocketbase@next # pnpm -pnpm i typed-pocketbase +pnpm i typed-pocketbase@next # yarn -yarn add typed-pocketbase +yarn add typed-pocketbase@next ``` ## Usage -Generate the PocketBase types using [pocketbase-typegen](https://github.com/patmood/pocketbase-typegen): +Generate the types: ```bash -npx pocketbase-typegen --db ./pb_data/data.db --out pocketbase-types.ts +npx typed-pocketbase --email admin@mail.com --password supersecretpassword -o Database.d.ts ``` -Create a PocketBase client and add types: +The codegen tool will look for `POCKETBASE_EMAIL` and `POCKETBASE_PASSWORD` environment variables if the email or password are not passed using cli options. + +Create a PocketBase client: ```ts import PocketBase from 'pocketbase'; import { TypedPocketBase } from 'typed-pocketbase'; -import { CollectionRecords } from './pocketbase-types'; +import { Schema } from './Database'; -const db: TypedPocketBase = new PocketBase('http://localhost:8090'); +const db = new PocketBase('http://localhost:8090') as TypedPocketBase; ``` Enjoy full type-safety: @@ -63,6 +65,8 @@ Supported methods Use the `fields` function to select the properties: +**Note:** Don´t use `expand` when selecting fields + ```ts import { fields } from 'typed-pocketbase'; @@ -79,13 +83,14 @@ db.collection('posts').getFullList({ ## Filtering columns -Use the `and`, `or` and some other utility function to filter rows: +Use the `and`, `or` and other utility functions to filter rows: ```ts import { and, or, eq } from 'typed-pocketbase'; // get all posts created in 2022 db.collection('posts').getFullList({ + // a "manual" filter is a tuple of length 3 filter: and(['date', '<', '2023-01-01'], ['data', '>=', '2022-01-01']) }); @@ -117,19 +122,30 @@ db.collection('posts').getFullList({ !untilNow && lt('date', '2023-01-01') ) }); + +// filter for columns in relations +// works up to 6 levels deep, including the top level +db.collection('posts').getFullList({ + filter: eq('owner.name', 'me') +}); ``` -Most filter operators are available as a short hand. +Most filter operators are available as short hand function. Visit the [pocketbase documentation](https://pocketbase.io/docs/api-records/) to find out about all filters in the `List/Search records` section. ## Sorting rows -Use the `sort` function to sort the rows: +Use `sort`, `asc` and `desc` to sort the rows: ```ts import { sort, asc, desc } from 'typed-pocketbase'; +db.collection('posts').getFullList({ + // sort by descending 'date' + sort: desc('date') +}); + db.collection('posts').getFullList({ // sort by descending 'date' and ascending 'title' sort: sort('-date', '+title') @@ -157,6 +173,31 @@ db.collection('posts').getFullList({ }); ``` +## Expanding + +Use the `expand` function to expand relations: + +**Note:** Don´t use `fields` when expanding as fields only works for the top level and `expand` would end up as an empty object + +```ts +import { expand } from 'typed-pocketbase'; + +db.collection('posts').getFullList({ + expand: expand({ + user: true + }) +}); + +// nested expand +db.collection('posts').getFullList({ + expand: expand({ + user: { + profile: true + } + }) +}); +``` + ## License [MIT](https://github.com/david-plugge/typed-pocketbase/blob/main/LICENSE) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..13d806d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +version: "3.7" + +services: + pocketbase: + image: ghcr.io/muchobien/pocketbase:latest + container_name: pocketbase + restart: unless-stopped + environment: + ENCRYPTION: example #optional + ports: + - "8090:8090" + volumes: + - pb_data:/pb_data + healthcheck: #optional (recommended) since v0.10.0 + test: wget --no-verbose --tries=1 --spider http://localhost:8090/api/health || exit 1 + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pb_data: \ No newline at end of file diff --git a/example/Database.d.ts b/example/Database.d.ts new file mode 100644 index 0000000..42fb92f --- /dev/null +++ b/example/Database.d.ts @@ -0,0 +1,149 @@ +/** + * This file was @generated using typed-pocketbase + */ + +// https://pocketbase.io/docs/collections/#base-collection +type BaseCollectionRecord = { + id: string; + created: string; + updated: string; +}; + +// https://pocketbase.io/docs/collections/#auth-collection +type AuthCollectionRecord = { + id: string; + created: string; + updated: string; + username: string; + email: string; + emailVisibility: boolean; + verified: boolean; +}; + +// https://pocketbase.io/docs/collections/#view-collection +type ViewCollectionRecord = { + id: string; +}; + +// utilities + +type MaybeArray = T | T[]; + +// ===== users ===== + +export type UsersResponse = { + name?: string; + avatar?: string; +} & AuthCollectionRecord; + +export type UsersCreate = { + name?: string; + avatar?: string; +}; + +export type UsersUpdate = { + name?: string; + avatar?: string; +}; + +export type UsersCollection = { + type: 'auth'; + collectionId: '_pb_users_auth_'; + collectionName: 'users'; + response: UsersResponse; + create: UsersCreate; + update: UsersUpdate; + relations: { + 'posts(owner)': PostsCollection[]; + }; +}; + +// ===== posts ===== + +export type PostsResponse = { + title: string; + slug: string; + date?: string; + content?: string; + published?: boolean; + owner?: string; +} & BaseCollectionRecord; + +export type PostsCreate = { + title: string; + slug: string; + date?: string; + content?: string; + published?: boolean; + owner?: string; +}; + +export type PostsUpdate = { + title?: string; + slug?: string; + date?: string; + content?: string; + published?: boolean; + owner?: string; +}; + +export type PostsCollection = { + type: 'base'; + collectionId: 'sbrth2mzfnqba9e'; + collectionName: 'posts'; + response: PostsResponse; + create: PostsCreate; + update: PostsUpdate; + relations: { + owner: UsersCollection; + }; +}; + +// ===== usis ===== + +export type UsisResponse = { + avatar?: string; +} & ViewCollectionRecord; + +export type UsisCollection = { + type: 'view'; + collectionId: 'bubx07xyejsas8a'; + collectionName: 'usis'; + response: UsisResponse; + relations: {}; +}; + +// ===== test ===== + +export type TestResponse = { + options?: ('a' | 'b' | 'c' | 'd')[]; +} & BaseCollectionRecord; + +export type TestCreate = { + options?: MaybeArray<'a' | 'b' | 'c' | 'd'>; +}; + +export type TestUpdate = { + options?: MaybeArray<'a' | 'b' | 'c' | 'd'>; + 'options+'?: MaybeArray<'a' | 'b' | 'c' | 'd'>; + 'options-'?: MaybeArray<'a' | 'b' | 'c' | 'd'>; +}; + +export type TestCollection = { + type: 'base'; + collectionId: '800ro086vmm2fbj'; + collectionName: 'test'; + response: TestResponse; + create: TestCreate; + update: TestUpdate; + relations: {}; +}; + +// ===== Schema ===== + +export type Schema = { + users: UsersCollection; + posts: PostsCollection; + usis: UsisCollection; + test: TestCollection; +}; diff --git a/example/index.ts b/example/index.ts index 0747480..a5f6c49 100644 --- a/example/index.ts +++ b/example/index.ts @@ -1,11 +1,40 @@ import PocketBase from 'pocketbase'; -import { CollectionRecords } from './pocketbase-types'; -import { TypedPocketBase, neq, sort, fields } from '../src'; +import { Schema } from './Database.js'; +import { TypedPocketBase, fields, expand, eq, asc } from '../src/index.js'; -const db: TypedPocketBase = new PocketBase('http://localhost:8090'); +const db = new PocketBase('http://localhost:8090') as TypedPocketBase; +await db.admins.authWithPassword('test@test.com', 'secretpassword'); -const { items } = await db.collection('posts').getList(1, 10, { - fields: fields('content', 'created', 'id'), - sort: sort('-date'), - filter: neq('content', '') -}); +{ + const posts = await db.collection('posts').getFullList({ + fields: fields('id', 'title', 'slug', 'content'), + sort: asc('date'), + filter: eq('published', true) + }); + + console.log(posts); +} + +{ + const posts = await db.collection('posts').getFullList({ + sort: asc('date'), + filter: eq('owner.email', 'user@test.com'), + expand: expand({ + owner: true + }) + }); + + console.log(posts[0].expand.owner); +} + +{ + const post = await db + .collection('posts') + .getFirstListItem(eq('owner.email', 'user@test.com'), { + expand: expand({ + owner: true + }) + }); + + console.log(post); +} diff --git a/example/pocketbase-types.ts b/example/pocketbase-types.ts deleted file mode 100644 index 082d32c..0000000 --- a/example/pocketbase-types.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * This file was @generated using pocketbase-typegen - */ - -export enum Collections { - Posts = 'posts', - Users = 'users' -} - -// Alias types for improved usability -export type IsoDateString = string; -export type RecordIdString = string; -export type HTMLString = string; - -// System fields -export type BaseSystemFields = { - id: RecordIdString; - created: IsoDateString; - updated: IsoDateString; - collectionId: string; - collectionName: Collections; - expand?: T; -}; - -export type AuthSystemFields = { - email: string; - emailVisibility: boolean; - username: string; - verified: boolean; -} & BaseSystemFields; - -// Record types for each collection - -export type PostsRecord = { - title: string; - slug: string; - date?: IsoDateString; - content?: HTMLString; - raw_text?: string; - published?: boolean; - deleted?: boolean; -}; - -export type UsersRecord = { - name?: string; - avatar?: string; -}; - -// Response types include system fields and match responses from the PocketBase API -export type PostsResponse = Required & BaseSystemFields; -export type UsersResponse = Required & AuthSystemFields; - -// Types containing all Records and Responses, useful for creating typing helper functions - -export type CollectionRecords = { - posts: PostsRecord; - users: UsersRecord; -}; - -export type CollectionResponses = { - posts: PostsResponse; - users: UsersResponse; -}; diff --git a/package.json b/package.json index 70b4928..af8504e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typed-pocketbase", - "version": "0.0.2", + "version": "0.0.3", "description": "Add types to the PocketBase JavaScript SDK", "author": "David Plugge", "repository": { @@ -16,9 +16,13 @@ "dist", "src" ], - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", + "type": "module", + "main": "./dist/client/index.cjs", + "module": "./dist/client/index.js", + "types": "./dist/client/index.d.ts", + "bin": { + "typed-pocketbase": "dist/codegen/cli.js" + }, "scripts": { "build": "tsup", "lint": "tsc", @@ -29,11 +33,15 @@ "peerDependencies": { "pocketbase": "^0.15.2" }, + "dependencies": { + "sade": "^1.8.1" + }, "devDependencies": { + "@types/node": "^18.13.0", "@changesets/cli": "^2.26.1", "pocketbase": "^0.15.2", "typescript": "^5.1.3", - "tsup": "^7.0.0", + "tsup": "^7.1.0", "prettier": "^2.8.8" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 194e901..24de6e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,10 +4,18 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +dependencies: + sade: + specifier: ^1.8.1 + version: 1.8.1 + devDependencies: '@changesets/cli': specifier: ^2.26.1 version: 2.26.1 + '@types/node': + specifier: ^18.13.0 + version: 18.13.0 pocketbase: specifier: ^0.15.2 version: 0.15.2 @@ -15,8 +23,8 @@ devDependencies: specifier: ^2.8.8 version: 2.8.8 tsup: - specifier: ^7.0.0 - version: 7.0.0(typescript@5.1.3) + specifier: ^7.1.0 + version: 7.1.0(typescript@5.1.3) typescript: specifier: ^5.1.3 version: 5.1.3 @@ -522,6 +530,10 @@ packages: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} dev: true + /@types/node@18.13.0: + resolution: {integrity: sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==} + dev: true + /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} dev: true @@ -1619,6 +1631,11 @@ packages: engines: {node: '>= 8.0.0'} dev: true + /mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + dev: false + /ms@2.1.2: resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} dev: true @@ -1952,6 +1969,13 @@ packages: queue-microtask: 1.2.3 dev: true + /sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + dependencies: + mri: 1.2.0 + dev: false + /safe-regex-test@1.0.0: resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} dependencies: @@ -2216,8 +2240,8 @@ packages: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true - /tsup@7.0.0(typescript@5.1.3): - resolution: {integrity: sha512-yYARDRkPq07mO3YUXTvF12ISwWQG57Odve8aFEgLdHyeGungxuKxb19yf9G0W8y59SZFkLnRj1gkoVk1gd5fbQ==} + /tsup@7.1.0(typescript@5.1.3): + resolution: {integrity: sha512-mazl/GRAk70j8S43/AbSYXGgvRP54oQeX8Un4iZxzATHt0roW0t6HYDVZIXMw0ZQIpvr1nFMniIVnN5186lW7w==} engines: {node: '>=16.14'} hasBin: true peerDependencies: diff --git a/src/codegen/ambient.d.ts b/src/codegen/ambient.d.ts new file mode 100644 index 0000000..6f7460c --- /dev/null +++ b/src/codegen/ambient.d.ts @@ -0,0 +1,2 @@ +declare const PKG_NAME: string; +declare const PKG_VERSION: string; diff --git a/src/codegen/cli.ts b/src/codegen/cli.ts new file mode 100644 index 0000000..fc4744f --- /dev/null +++ b/src/codegen/cli.ts @@ -0,0 +1,66 @@ +import sade from 'sade'; +import { generateTypes } from './index.js'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +interface CliOptions { + url?: string; + email?: string; + password?: string; + out?: string; +} + +sade(PKG_NAME, true) + .version(PKG_VERSION) + .describe('Generate types for the PocketBase JavaScript SDK') + .option( + '-u, --url', + 'URL to your hosted pocketbase instance.', + 'http://127.0.0.1:8090' + ) + .option('-e, --email', 'email for an admin pocketbase user.') + .option('-p, --password', 'email for an admin pocketbase user.') + .option( + '-o, --out', + 'path to save the typescript output file (prints to console by default)' + ) + .action( + async ({ + url, + email = process.env.POCKETBASE_EMAIL, + password = process.env.POCKETBASE_PASSWORD, + out + }: CliOptions) => { + if (!url) error(`required option '-u, --url' not specified`); + + if (!email) + error( + `required option '-e, --email' not specified and 'POCKETBASE_EMAIL' env not set` + ); + + if (!password) + error( + `required option '-p, --password' not specified and 'POCKETBASE_PASSWORD' env not set` + ); + + const definition = await generateTypes({ + url, + email, + password + }); + + if (out) { + const file = resolve(out); + await mkdir(dirname(file), { recursive: true }); + await writeFile(file, definition, 'utf-8'); + } else { + console.log(definition); + } + } + ) + .parse(process.argv); + +function error(msg: string): never { + console.error(msg); + process.exit(); +} diff --git a/src/codegen/index.ts b/src/codegen/index.ts new file mode 100644 index 0000000..5a43cd6 --- /dev/null +++ b/src/codegen/index.ts @@ -0,0 +1,314 @@ +import PocketBase from 'pocketbase'; +import { Collection, Field } from './types.js'; + +export interface GenerateOptions { + url: string; + email: string; + password: string; +} + +interface Columns { + create: string[]; + update: string[]; + response: string[]; +} + +interface Relation { + name: string; + target: string; +} + +export async function generateTypes({ url, email, password }: GenerateOptions) { + const pb = new PocketBase(url); + await pb.admins.authWithPassword(email, password); + + const collections = await pb.collections + .getFullList() + .then((collections) => + collections.map((c) => c.export() as Collection) + ); + + const deferred: Array<() => void> = []; + + const tables = collections.map((c) => { + const typeName = pascalCase(c.name); + + const columns: Columns = { + create: [], + update: [], + response: [] + }; + const relations: Relation[] = []; + + c.schema.forEach((field) => { + getFieldType(field, columns); + + if (field.type === 'relation') { + deferred.push(() => { + const target = tableMap.get(field.options.collectionId); + const targetCollection = collectionMap.get( + field.options.collectionId + ); + + if (!target || !targetCollection) + throw new Error( + `Collection ${field.options.collectionId} not found for relation ${c.name}.${field.name}` + ); + + relations.push({ + name: field.name, + target: `${target.typeName}Collection${ + field.options.maxSelect > 1 ? '[]' : '' + }` + }); + + /** + * indirect expand + * @see https://pocketbase.io/docs/expanding-relations/#indirect-expand + */ + + const indicies = targetCollection.indexes.map(parseIndex); + + const isUnique = indicies.some( + (i) => + i && + i.unique && + i.fields.length === 1 && + i.fields[0] === field.name + ); + + target.relations.push({ + name: `'${c.name}(${field.name})'`, + target: `${typeName}Collection${isUnique ? '' : '[]'}` + }); + }); + } + }); + + return { + id: c.id, + name: c.name, + type: c.type, + typeName, + columns, + relations + }; + }); + + const tableMap = new Map(tables.map((t) => [t.id, t])); + const collectionMap = new Map(collections.map((c) => [c.id, c])); + + deferred.forEach((c) => c()); + + const indent = '\t'; + + const definition = ` +/** + * This file was @generated using typed-pocketbase + */ + +// https://pocketbase.io/docs/collections/#base-collection +type BaseCollectionRecord = { + id: string; + created: string; + updated: string; +}; + +// https://pocketbase.io/docs/collections/#auth-collection +type AuthCollectionRecord = { + id: string; + created: string; + updated: string; + username: string; + email: string; + emailVisibility: boolean; + verified: boolean; +}; + +// https://pocketbase.io/docs/collections/#view-collection +type ViewCollectionRecord = { + id: string; +}; + +// utilities + +type MaybeArray = T | T[]; + +${tables + .map((t) => + ` +// ===== ${t.name} ===== + +export type ${t.typeName}Response = { + ${t.columns.response.join('\n' + indent)} +} & ${ + t.type === 'base' + ? 'BaseCollectionRecord' + : t.type === 'auth' + ? 'AuthCollectionRecord' + : 'ViewCollectionRecord' + }; +${ + // view collections are readonly + t.type === 'view' + ? '' + : ` +export type ${t.typeName}Create = { + ${t.columns.create.join('\n' + indent)} +}; + +export type ${t.typeName}Update = { + ${t.columns.update.join('\n' + indent)} +}; +` +} +export type ${t.typeName}Collection = { + type: '${t.type}'; + collectionId: '${t.id}'; + collectionName: '${t.name}'; + response: ${t.typeName}Response;${ + t.type === 'view' + ? '' + : ` + create: ${t.typeName}Create; + update: ${t.typeName}Update;` + } + relations: ${ + t.relations.length === 0 + ? '{}' + : `{ + ${t.relations + .map((col) => `${col.name}: ${col.target};`) + .join('\n' + ' '.repeat(8))} + }` + }; +}; + +`.trim() + ) + .join('\n\n')} + +// ===== Schema ===== + +export type Schema = { + ${tables + .map(({ name, typeName }) => `${name}: ${typeName}Collection;`) + .join('\n' + indent)} +}; +`.trim(); + + return definition; +} + +function getFieldType(field: Field, { response, create, update }: Columns) { + const req = field.required ? '' : '?'; + + const addResponse = (type: string, name = field.name) => + response.push(`${name}${req}: ${type};`); + const addCreate = (type: string, name = field.name) => + create.push(`${name}${req}: ${type};`); + const addUpdate = (type: string, name = field.name) => + update.push(`${name}?: ${type};`); + const addAll = (type: string) => { + addResponse(type); + addCreate(type); + addUpdate(type); + }; + + switch (field.type) { + case 'text': + case 'editor': // rich text + case 'email': + case 'url': + case 'date': { + addAll('string'); + break; + } + case 'number': { + const type = 'number'; + addAll(type); + addUpdate(type, `'${field.name}+'`); + addUpdate(type, `'${field.name}-'`); + break; + } + case 'bool': { + addAll('boolean'); + break; + } + case 'select': { + const singleType = field.options.values + .map((v) => `'${v}'`) + .join(' | '); + const multiple = field.options.maxSelect > 1; + const type = multiple + ? `MaybeArray<${singleType}>` + : `${singleType}`; + + addResponse(multiple ? `(${singleType})[]` : singleType); + addCreate(type); + addUpdate(type); + if (multiple) { + addUpdate(type, `'${field.name}+'`); + addUpdate(type, `'${field.name}-'`); + } + + break; + } + case 'relation': { + const singleType = 'string'; + const multiple = field.options.maxSelect > 1; + const type = multiple + ? `MaybeArray<${singleType}>` + : `${singleType}`; + + addResponse(multiple ? `(${singleType})[]` : singleType); + addCreate(type); + addUpdate(type); + if (multiple) { + addUpdate(type, `'${field.name}+'`); + addUpdate(type, `'${field.name}-'`); + } + break; + } + case 'file': { + const singleType = 'string'; + const multiple = field.options.maxSelect > 1; + const type = multiple + ? `MaybeArray<${singleType}>` + : `${singleType}`; + + addResponse(multiple ? `(${singleType})[]` : singleType); + addCreate(type); + addUpdate(type); + if (multiple) { + addUpdate(`'${field.name}-'`, type); + } + break; + } + default: + throw new Error(`Unknown type ${field.type}`); + } +} + +function parseIndex(index: string) { + const match = index.match( + /^CREATE(\s+UNIQUE)?\s+INDEX\s+`(\w+)`\s+ON\s+`(\w+)`\s+\(([\s\S]*)\)$/ + ); + if (!match) return null; + const [_, unique, name, collection, definition] = match; + + const fields = Array.from(definition.matchAll(/`(\S*)`/g)).map((m) => m[1]); + + return { + unique: !!unique, + name, + collection, + fields + }; +} + +function pascalCase(str: string) { + return str + .replace(/[-_]([a-z])/g, (m) => m[1].toUpperCase()) + .replace(/^\w/, (s) => s.toUpperCase()); +} diff --git a/src/codegen/types.ts b/src/codegen/types.ts new file mode 100644 index 0000000..1702beb --- /dev/null +++ b/src/codegen/types.ts @@ -0,0 +1,168 @@ +export type CollectionType = 'auth' | 'view' | 'base'; + +interface GenericCollection { + id: string; + created: string; + updated: string; + name: string; + type: CollectionType; + system: boolean; + schema: Field[]; + indexes: string[]; + listRule: string | null; + viewRule: string | null; + createRule: string | null; + updateRule: string | null; + deleteRule: string | null; + options: {}; +} + +export interface BaseCollection extends GenericCollection { + type: 'base'; + options: {}; +} + +export interface ViewCollection extends GenericCollection { + type: 'view'; + options: { + query: string; + }; +} + +export interface AuthCollection extends GenericCollection { + type: 'auth'; + options: { + allowEmailAuth: boolean; + allowOAuth2Auth: boolean; + allowUsernameAuth: boolean; + exceptEmailDomains: string[] | null; + onlyEmailDomains: string[] | null; + manageRule: string | null; + minPasswordLength: number; + requireEmail: boolean; + }; +} + +export type Collection = BaseCollection | ViewCollection | AuthCollection; + +export type FieldType = + | 'text' + | 'editor' + | 'number' + | 'bool' + | 'email' + | 'url' + | 'date' + | 'select' + | 'relation' + | 'file' + | 'json'; + +interface GenericField { + id: string; + name: string; + type: FieldType; + system: boolean; + required: boolean; + options: {}; +} + +export interface TextField extends GenericField { + type: 'text'; + options: { + min: number | null; + max: number | null; + pattern: string | null; + }; +} + +export interface EditorField extends GenericField { + type: 'editor'; + options: { + exceptDomains: []; + onlyDomains: []; + }; +} + +export interface NumberField extends GenericField { + type: 'number'; + options: { + min: number | null; + max: number | null; + }; +} + +export interface BoolField extends GenericField { + type: 'bool'; +} + +export interface EmailField extends GenericField { + type: 'email'; + options: { + exceptDomains: [] | null; + onlyDomains: [] | null; + }; +} + +export interface UrlField extends GenericField { + type: 'url'; + options: { + exceptDomains: []; + onlyDomains: []; + }; +} + +export interface DateField extends GenericField { + type: 'date'; + options: { + min: string; + max: string; + }; +} + +export interface SelectField extends GenericField { + type: 'select'; + options: { + maxSelect: number; + values: string[]; + }; +} + +export interface RelationField extends GenericField { + type: 'relation'; + options: { + collectionId: string; + cascadeDelete: boolean; + minSelect: number | null; + maxSelect: number; + displayFields: string[]; + }; +} + +export interface FileField extends GenericField { + type: 'file'; + options: { + maxSelect: number; + maxSize: number; + mimeType: string[]; + thumbs: string[]; + protected: boolean; + }; +} + +export interface JsonField extends GenericField { + type: 'json'; +} + +export type Field = + | TextField + | EditorField + | NumberField + | BoolField + | EmailField + | UrlField + | DateField + | SelectField + | RelationField + | FileField + | JsonField; diff --git a/src/expand.ts b/src/expand.ts new file mode 100644 index 0000000..a026b1a --- /dev/null +++ b/src/expand.ts @@ -0,0 +1,63 @@ +import { + TypedRecord, + GenericCollection, + GenericExpand, + ArrayInnerType, + Simplify +} from './types.js'; + +export type ExpandParam< + T extends GenericCollection, + E extends GenericExpand +> = { + __record__?: T; + __expand__?: E; +} & string; + +export type Expand = { + [K in keyof T['relations']]?: + | true + | Expand>; +}; + +type RelationToTypedRecord< + T extends GenericCollection | GenericCollection[], + E extends GenericExpand = {} +> = T extends GenericCollection[] + ? TypedRecord[] + : T extends GenericCollection + ? TypedRecord + : never; + +export type UnpackExpand< + T extends GenericCollection, + E extends Expand +> = Simplify<{ + [K in keyof E & keyof T['relations']]: RelationToTypedRecord< + T['relations'][K], + E[K] extends Expand + ? UnpackExpand, E[K]> + : {} + >; +}>; + +export function expand>( + expand: E +): ExpandParam> { + const expands: string[] = []; + + process(expand); + + function process(obj: any, prefix: string[] = []) { + for (const key in obj) { + const value = obj[key]; + if (value === true) { + expands.push([...prefix, key].join('.')); + } else if (typeof value === 'object') { + process(value, [...prefix, key]); + } + } + } + + return [...new Set(expands)].join(','); +} diff --git a/src/fields.ts b/src/fields.ts index 8f5d9e2..2dcc2da 100644 --- a/src/fields.ts +++ b/src/fields.ts @@ -1,4 +1,4 @@ -import { BaseRecord } from './types'; +import { BaseRecord } from './types.js'; export type FieldsParam = { __record__?: T; diff --git a/src/filter.ts b/src/filter.ts index c962cee..b4e1cd3 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -1,6 +1,10 @@ -import { BaseRecord } from './types'; +import { BaseRecord } from './types.js'; -type ActualFilter = [K, FilterOperand, T[K]]; +type ActualFilter = [ + K, + FilterOperand, + T[K] +]; export type FilterOperand = | '=' @@ -36,19 +40,27 @@ function serializeFilter([key, op, val]: ActualFilter) { function serializeFilters(filters: Filter[]) { return filters .filter(Boolean) - .map((filter) => (Array.isArray(filter) ? serializeFilter(filter) : filter)); + .map((filter) => + Array.isArray(filter) ? serializeFilter(filter) : filter + ); } -export function and(...filters: Filter[]): FilterParam { +export function and( + ...filters: Filter[] +): FilterParam { const str = serializeFilters(filters).join(' && '); return `(${str})`; } -export function or(...filters: Filter[]): FilterParam { +export function or( + ...filters: Filter[] +): FilterParam { const str = filters .filter(Boolean) - .map((filter) => (Array.isArray(filter) ? serializeFilter(filter) : filter)) + .map((filter) => + Array.isArray(filter) ? serializeFilter(filter) : filter + ) .join(' || '); return `(${str})`; } diff --git a/src/index.ts b/src/index.ts index 3a5bad3..dcff629 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,17 @@ -export { TypedPocketBase } from './pocketbase'; -export { TypedRecordService } from './record-service'; +import PocketBase from 'pocketbase'; +import { GenericSchema } from './types.js'; +import { TypedRecordService } from './record-service.js'; -export { fields } from './fields'; -export { and, eq, gt, gte, like, lt, lte, neq, nlike, or } from './filter'; -export { asc, desc, sort } from './sort'; +export { fields } from './fields.js'; +export { and, or, eq, gt, gte, like, lt, lte, neq, nlike } from './filter.js'; +export { expand } from './expand.js'; +export { asc, desc, sort } from './sort.js'; +export { GenericSchema, GenericCollection, TypedRecord } from './types.js'; + +// @ts-expect-error typescript... +export interface TypedPocketBase + extends PocketBase { + collection( + idOrName: C + ): TypedRecordService; +} diff --git a/src/pocketbase.ts b/src/pocketbase.ts deleted file mode 100644 index c2f3f0a..0000000 --- a/src/pocketbase.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type PocketBase from 'pocketbase'; -import { BaseCollectionRecords, SystemFields } from './types'; -import { TypedRecordService } from './record-service'; - -// @ts-expect-error -export interface TypedPocketBase - extends PocketBase { - collection( - name: C - ): TypedRecordService; -} diff --git a/src/record-service.ts b/src/record-service.ts index 7d0517e..9d0d391 100644 --- a/src/record-service.ts +++ b/src/record-service.ts @@ -1,86 +1,158 @@ import { - BaseQueryParams, - FullListQueryParams, - ListQueryParams, ListResult, - RecordListQueryParams, - RecordQueryParams, RecordService, RecordSubscription, UnsubscribeFunc } from 'pocketbase'; -import { Filter, FilterParam } from './filter'; -import { BaseRecord, SystemFields, TypedRecord } from './types'; -import { FieldsParam } from './fields'; -import { SortParam } from './sort'; +import { + Simplify, + GenericCollection, + TypedRecord, + Fields, + Columns, + GenericExpand, + LooseAutocomplete, + RecordWithExpandToDotPath +} from './types.js'; +import { FieldsParam } from './fields.js'; +import { Filter, FilterParam } from './filter.js'; +import { SortParam } from './sort.js'; +import { ExpandParam } from './expand.js'; // @ts-expect-error -export interface TypedRecordService extends RecordService { - getFullList( + getFullList< + Select extends Fields = Fields, + Expand extends GenericExpand = {} + >( batch?: number, - queryParams?: TypedRecordListQueryParams | undefined - ): Promise[]>; + queryParams?: TypedRecordListQueryParams + ): Promise< + TypedRecord, Select>>, Expand>[] + >; - getList( - filter: Filter, - queryParams?: TypedRecordListQueryParams | undefined - ): Promise>; + getFirstListItem< + Select extends Fields = Fields, + Expand extends GenericExpand = {} + >( + filter: Filter>, + queryParams?: TypedRecordListQueryParams + ): Promise< + TypedRecord, Select>>, Expand> + >; - getOne( - bodyParams: Omit, - queryParams?: TypedRecordQueryParams - ): Promise>; + create< + Select extends Fields = Fields, + Expand extends GenericExpand = {} + >( + bodyParams: Collection['create'], + queryParams?: TypedRecordQueryParams + ): Promise< + TypedRecord, Select>>, Expand> + >; - update