Skip to content

Commit

Permalink
feat: add pocketbase package (#61)
Browse files Browse the repository at this point in the history
* feat: pocketbase module

* feat: add show record

* feat: create record

* feat: create and update record statements

* refactor: abstract  createRecordOptions func

* fix: parameters types (not undefined allowed) and update+delete test

* chore: serve pocketbase before run test

---------

Co-authored-by: Puria Nafisi Azizi <[email protected]>
  • Loading branch information
phoebus-84 and puria authored Feb 26, 2024
1 parent 42af3bf commit 8e9b08c
Show file tree
Hide file tree
Showing 11 changed files with 770 additions and 65 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
- name: Linting
continue-on-error: true
run: pnpm lint

- run: ./pkg/pocketbase/test/pocketbase serve &

- name: Run the tests
run: pnpm coverage
Expand Down
3 changes: 3 additions & 0 deletions pkg/pocketbase/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
PB_ADDRESS = "http://127.0.0.1:8090/"
EMAIL = "[email protected]"
PASSWORD = "pppppppp"
33 changes: 33 additions & 0 deletions pkg/pocketbase/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@slangroom/pocketbase",
"version": "1.0.0",
"dependencies": {
"@slangroom/core": "workspace:*",
"@slangroom/shared": "workspace:*",
"dotenv": "^16.3.1",
"pocketbase": "^0.19.0",
"zod": "^3.22.4"
},
"repository": "https://github.com/dyne/slangroom",
"license": "AGPL-3.0-only",
"type": "module",
"main": "./build/esm/src/index.js",
"types": "./build/esm/src/index.d.ts",
"exports": {
".": {
"import": {
"types": "./build/esm/src/index.d.ts",
"default": "./build/esm/src/index.js"
}
},
"./*": {
"import": {
"types": "./build/esm/src/*.d.ts",
"default": "./build/esm/src/*.js"
}
}
},
"publishConfig": {
"access": "public"
}
}
1 change: 1 addition & 0 deletions pkg/pocketbase/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from '@slangroom/pocketbase/plugin';
246 changes: 246 additions & 0 deletions pkg/pocketbase/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import PocketBase from 'pocketbase';
import type { FullListOptions, ListResult, RecordModel, RecordOptions } from 'pocketbase';
import { Plugin } from '@slangroom/core';
import { z } from 'zod';

let pb: PocketBase;
const p = new Plugin();

const serverUrlSchema = z.literal(
`http${z.union([z.literal('s'), z.literal('')])}://${z.string()}/`,
);
export type ServerUrl = z.infer<typeof serverUrlSchema>;

const credentialsSchema = z.object({
email: z.string().email(),
password: z.string(),
});
export type Credentials = z.infer<typeof credentialsSchema>;

const baseRecordParametersSchema = z.object({
expand: z.string().nullable(),
requestKey: z.string().nullable(),
fields: z.string().nullable(),
});
export type RecordBaseParameters = z.infer<typeof baseRecordParametersSchema>;

const baseFetchRecordParametersSchema = baseRecordParametersSchema.extend({
collection: z.string(),
});

const paginationSchema = z.object({
page: z.number().int(),
perPage: z.number().int(),
});

const listParametersBaseSchema = z
.object({
sort: z.string().nullable(),
})
.merge(baseFetchRecordParametersSchema);

const listParametersSchema = z.discriminatedUnion('type', [
listParametersBaseSchema.extend({ type: z.literal('all'), filter: z.string().nullable() }),
listParametersBaseSchema.extend({
type: z.literal('list'),
filter: z.string().nullable(),
pagination: paginationSchema,
}),
listParametersBaseSchema.extend({ type: z.literal('first'), filter: z.string() }),
]);
export type ListParameters = z.input<typeof listParametersSchema>;

const showParametersSchema = z
.object({
id: z.string(),
})
.merge(baseFetchRecordParametersSchema);
export type ShowRecordParameters = z.infer<typeof showParametersSchema>;

const createRecordParametersSchema = z
.object({
record: z.record(z.string(), z.any()),
collection: z.string()
})
export type CreateRecordParameters = z.infer<typeof createRecordParametersSchema>;

const updateRecordParametersSchema = z
.object({
id: z.string(),
})
.merge(createRecordParametersSchema);
export type UpdateRecordParameters = z.infer<typeof updateRecordParametersSchema>;

const deleteParametersSchema = z.object({
collection: z.string(),
id: z.string(),
});
export type DeleteRecordParameters = z.infer<typeof deleteParametersSchema>;

const isPbRunning = async () => {
const res = await pb.health.check({ requestKey: null });
return res.code === 200;
};

const createRecordOptions = (p: RecordBaseParameters) => {
const { expand, fields, requestKey } = p;
const options: RecordOptions = {};
if (expand !== null) options.expand = expand;
if (fields !== null) options.fields = fields;
if (requestKey !== null) options.requestKey = requestKey;
return options;
};

/**
* @internal
*/
export const setupClient = p.new(['pb_address'], 'create pb_client', async (ctx) => {
const address = ctx.fetch('pb_address');
if (typeof address !== 'string') return ctx.fail('Invalid address');
try {
pb = new PocketBase(address);
if (!(await isPbRunning())) return ctx.fail('Client is not running');
return ctx.pass('pb client successfully created');
} catch (e) {
throw new Error(e)
}
});

/**
* @internal
*/
export const authWithPassword = p.new(['my_credentials'], 'login', async (ctx) => {
const credentials = ctx.fetch('my_credentials') as Credentials;

const validation = credentialsSchema.safeParse(credentials);
if (!validation.success) return ctx.fail(validation.error);
if (!(await isPbRunning())) return ctx.fail('Client is not running');

try {
const res = await pb
.collection('users')
.authWithPassword(credentials!.email, credentials!.password, { requestKey: null });
return ctx.pass({ token: res.token, record: res.record });
} catch (err) {
throw new Error(err)
}
});

/**
* @internal
*/
export const getList = p.new(['list_parameters'], 'ask records', async (ctx) => {
const params = ctx.fetch('list_parameters') as ListParameters;
const validation = listParametersSchema.safeParse(params);
if (!validation.success) return ctx.fail(validation.error);

const { collection, sort, filter, expand, type, requestKey } = params;
if (!(await isPbRunning())) return ctx.fail('client is not working');

const options: FullListOptions = { requestKey: requestKey || type };
if (sort) options.sort = sort;
if (filter) options.filter = filter;
if (expand) options['expand'] = expand;

let res: RecordModel | RecordModel[] | ListResult<RecordModel>;
if (type === 'all') {
res = await pb.collection(collection).getFullList(options);
} else if (type === 'list') {
const { page, perPage } = params.pagination;
res = await pb.collection(collection).getList(page, perPage, options);
} else {
res = await pb.collection(collection).getFirstListItem(filter, options);
}
//@ts-expect-error Jsonable should take also ListResult
return ctx.pass({ records: res });
});

/**
* @internal
*/
export const showRecord = p.new(['show_parameters'], 'ask record', async (ctx) => {
const p = ctx.fetch('show_parameters') as ShowRecordParameters;
const validation = showParametersSchema.safeParse(p);
if (!validation.success) return ctx.fail(validation.error);

const options = createRecordOptions(p);

try {
const res = await pb.collection(p.collection).getOne(p.id, options);
return ctx.pass(res);
} catch (err) {
throw new Error(err)
}
});

/**
* @internal
*/
export const createRecord = p.new(
['create_parameters', 'record_parameters'],
'create record',
async (ctx) => {
const p = ctx.fetch('create_parameters') as CreateRecordParameters;
const r = ctx.fetch('record_parameters') as RecordBaseParameters;

const validateCreateParams = createRecordParametersSchema.safeParse(p);
const validateRecordParams = baseRecordParametersSchema.safeParse(r);
if (!validateCreateParams.success) return ctx.fail(validateCreateParams.error);
if (!validateRecordParams.success) return ctx.fail(validateRecordParams.error);

const { collection, record } = p;
const options = createRecordOptions(r);

try {
const res = await pb.collection(collection).create(record, options);
return ctx.pass(res);
} catch (err) {
throw new Error(err.message);
}
},
);

/**
* @internal
*/
export const updateRecord = p.new(
['update_parameters', 'record_parameters'],
'update record',
async (ctx) => {
const p = ctx.fetch('update_parameters') as UpdateRecordParameters;
const r = ctx.fetch('record_parameters') as RecordBaseParameters;

const validateUpdateParams = updateRecordParametersSchema.safeParse(p);
const validateRecordParams = baseRecordParametersSchema.safeParse(r);
if (!validateUpdateParams.success) return ctx.fail(validateUpdateParams.error);
if (!validateRecordParams.success) return ctx.fail(validateRecordParams.error);

const { collection, record, id } = p;
const options = createRecordOptions(r);

try {
const res = await pb.collection(collection).update(id, record, options);
return ctx.pass(res);
} catch (err) {
throw new Error(err.message);
}
},
);

/**
* @internal
*/
export const deleteRecord = p.new(['delete_parameters'], 'delete record', async (ctx) => {
const p = ctx.fetch('delete_parameters') as DeleteRecordParameters;

const validation = deleteParametersSchema.safeParse(p);
if (!validation.success) return ctx.fail(validation.error);

const { collection, id } = p;
const res = await pb.collection(collection).delete(id);
if (res) return ctx.pass('deleted');
return ctx.fail('shit happened');
});

export const pocketbase = p;

Loading

0 comments on commit 8e9b08c

Please sign in to comment.