@svadmin/pocketbase 0.10.7 → 0.11.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svadmin/pocketbase",
3
- "version": "0.10.7",
3
+ "version": "0.11.6",
4
4
  "description": "PocketBase adapters — DataProvider, AuthProvider, LiveProvider for svadmin",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,9 +20,15 @@
20
20
  }
21
21
  },
22
22
  "peerDependencies": {
23
- "@svadmin/core": "^0.50.0",
23
+ "@svadmin/core": "^0.52.0",
24
24
  "pocketbase": ">=0.28.0"
25
25
  },
26
+ "dependencies": {
27
+ "@sinclair/typebox": "^0.34.52"
28
+ },
29
+ "devDependencies": {
30
+ "pocketbase": "0.28.0"
31
+ },
26
32
  "keywords": [
27
33
  "svadmin",
28
34
  "pocketbase",
@@ -34,7 +40,7 @@
34
40
  "author": "zuohuadong",
35
41
  "repository": {
36
42
  "type": "git",
37
- "url": "https://github.com/vibeunion/svadmin.git",
43
+ "url": "git+https://github.com/vibeunion/svadmin.git",
38
44
  "directory": "packages/pocketbase"
39
45
  },
40
46
  "homepage": "https://github.com/vibeunion/svadmin#readme",
@@ -1,117 +1,179 @@
1
- // @svadmin/pocketbase — PocketBase AuthProvider
1
+ import { Type } from '@sinclair/typebox';
2
+ import { definedOptions } from '@svadmin/core/options';
2
3
  import type { AuthProvider, Identity, AuthActionResult, CheckResult } from '@svadmin/core';
4
+ import { authRecord, collectionName, decode, nonempty, PocketBaseBoundaryError } from './boundary';
3
5
 
4
- interface PocketBaseAuthOptions {
5
- /** PocketBase client instance */
6
- pb: unknown; // PocketBase
7
- }
8
-
9
- // Duck-typed PocketBase client with auth methods
10
- interface PBClientAuth {
11
- authStore: {
12
- isValid: boolean;
13
- model: { id: string; name?: string; email: string; avatar?: string; [key: string]: unknown } | null;
14
- clear: () => void;
15
- token: string;
6
+ export interface PocketBaseAuthClient {
7
+ readonly authStore: {
8
+ readonly isValid: unknown;
9
+ readonly record: unknown;
10
+ readonly token: unknown;
11
+ clear(): void;
16
12
  };
17
- collection: (name: string) => {
18
- authWithPassword: (email: string, password: string) => Promise<{ record: Record<string, unknown>; token: string }>;
19
- create: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
20
- requestPasswordReset: (email: string) => Promise<boolean>;
21
- confirmPasswordReset: (token: string, password: string, passwordConfirm: string) => Promise<boolean>;
13
+ collection(name: string): {
14
+ authWithPassword(email: string, password: string): Promise<unknown>;
15
+ create(data: Record<string, unknown>): Promise<unknown>;
16
+ requestPasswordReset(email: string): Promise<unknown>;
17
+ confirmPasswordReset(token: string, password: string, passwordConfirm: string): Promise<unknown>;
22
18
  };
23
19
  }
24
20
 
25
- /**
26
- * Create an AuthProvider backed by PocketBase.
27
- *
28
- * Usage:
29
- * ```ts
30
- * import PocketBase from 'pocketbase';
31
- * import { createPocketBaseAuthProvider } from '@svadmin/pocketbase';
32
- *
33
- * const pb = new PocketBase('http://127.0.0.1:8090');
34
- * const authProvider = createPocketBaseAuthProvider({ pb });
35
- * ```
36
- */
37
- export function createPocketBaseAuthProvider(options: PocketBaseAuthOptions): AuthProvider {
38
- const pb = options.pb as PBClientAuth;
21
+ export interface PocketBaseAuthOptions {
22
+ pb: PocketBaseAuthClient;
23
+ }
24
+
25
+ const collection = Type.Optional(collectionName);
26
+ const username = Type.Optional(nonempty);
27
+ const credentials = Type.Object({ email: nonempty, password: nonempty, username, collection }, { additionalProperties: false });
28
+ const registration = Type.Object({
29
+ email: nonempty, password: nonempty, username,
30
+ confirmPassword: Type.Optional(nonempty), name: Type.Optional(Type.String()), collection,
31
+ }, { additionalProperties: false });
32
+ const resetRequest = Type.Object({ email: nonempty, username, collection }, { additionalProperties: false });
33
+ const resetConfirmation = Type.Object({
34
+ token: nonempty, password: nonempty, confirmPassword: Type.Optional(nonempty), collection,
35
+ }, { additionalProperties: false });
36
+ const authResponse = Type.Object({ record: authRecord, token: nonempty });
37
+ const session = Type.Object({ isValid: Type.Literal(true), record: authRecord, token: nonempty });
38
+ const confirmed = Type.Literal(true);
39
+
40
+ function validateIdentifier(input: { email: string; username?: string }): void {
41
+ if (input.username !== undefined && input.username !== input.email) throw new PocketBaseBoundaryError('input');
42
+ }
43
+
44
+ function failure(operation: string, error: unknown, write = false): AuthActionResult {
45
+ if (error instanceof PocketBaseBoundaryError) {
46
+ return {
47
+ success: false,
48
+ error: {
49
+ name: write && error.phase === 'response' ? 'WRITE_OUTCOME_UNKNOWN' : 'INVALID_AUTH_DATA',
50
+ message: write && error.phase === 'response'
51
+ ? `${operation} response was invalid; the operation may have succeeded.`
52
+ : error.message,
53
+ },
54
+ };
55
+ }
56
+ return { success: false, error: { message: `${operation} failed.` } };
57
+ }
58
+
59
+ /** SDK return values and auth-store records enter the application only after validation. */
60
+ export function createPocketBaseAuthProvider({ pb }: PocketBaseAuthOptions): AuthProvider {
61
+ function currentRecord() {
62
+ try {
63
+ const isValid: unknown = pb.authStore.isValid;
64
+ if (isValid === false) return null;
65
+ return decode(session, {
66
+ isValid, record: pb.authStore.record, token: pb.authStore.token,
67
+ }, 'session').record;
68
+ } catch {
69
+ pb.authStore.clear();
70
+ return null;
71
+ }
72
+ }
39
73
 
40
74
  return {
41
- async login({ email, password, collection }: Record<string, unknown>): Promise<AuthActionResult> {
75
+ async login(params): Promise<AuthActionResult> {
42
76
  try {
43
- const col = (collection as string) || 'users';
44
- await pb.collection(col).authWithPassword(email as string, password as string);
77
+ const input = decode(credentials, params, 'input');
78
+ validateIdentifier(input);
79
+ const result: unknown = await pb.collection(input.collection ?? 'users')
80
+ .authWithPassword(input.email, input.password);
81
+ try {
82
+ decode(authResponse, result, 'response');
83
+ } catch (error) {
84
+ pb.authStore.clear();
85
+ throw error;
86
+ }
45
87
  return { success: true, redirectTo: '/' };
46
- } catch (err) {
47
- return { success: false, error: { message: (err as Error).message } };
88
+ } catch (error) {
89
+ return failure('Login', error);
48
90
  }
49
91
  },
50
92
 
51
93
  async logout(): Promise<AuthActionResult> {
52
- pb.authStore.clear();
53
- return { success: true, redirectTo: '/login' };
94
+ try {
95
+ pb.authStore.clear();
96
+ return { success: true, redirectTo: '/login' };
97
+ } catch (error) {
98
+ return failure('Logout', error);
99
+ }
54
100
  },
55
101
 
56
102
  async check(): Promise<CheckResult> {
57
- if (pb.authStore.isValid && pb.authStore.model) {
58
- return { authenticated: true };
59
- }
60
- return { authenticated: false, redirectTo: '/login', logout: true };
103
+ return currentRecord()
104
+ ? { authenticated: true }
105
+ : { authenticated: false, redirectTo: '/login', logout: true };
61
106
  },
62
107
 
63
108
  async getIdentity(): Promise<Identity | null> {
64
- const model = pb.authStore.model;
65
- if (!model) return null;
66
- return {
67
- id: model.id,
68
- name: model.name || model.email.split('@')[0],
69
- email: model.email,
70
- avatar: model.avatar,
71
- };
109
+ const record = currentRecord();
110
+ if (!record) return null;
111
+ return definedOptions({
112
+ id: record.id,
113
+ name: record.name || record.email?.split('@')[0] || record.id,
114
+ email: record.email,
115
+ avatar: record.avatar,
116
+ });
72
117
  },
73
118
 
74
- async register({ email, password, passwordConfirm, name, collection }: Record<string, unknown>): Promise<AuthActionResult> {
119
+ async register(params): Promise<AuthActionResult> {
75
120
  try {
76
- const col = (collection as string) || 'users';
77
- await pb.collection(col).create({
78
- email: email as string,
79
- password: password as string,
80
- passwordConfirm: (passwordConfirm ?? password) as string,
81
- name: name as string | undefined,
82
- });
121
+ const input = decode(registration, params, 'input');
122
+ validateIdentifier(input);
123
+ if (input.confirmPassword !== undefined && input.confirmPassword !== input.password) {
124
+ throw new PocketBaseBoundaryError('input');
125
+ }
126
+ const result: unknown = await pb.collection(input.collection ?? 'users').create(definedOptions({
127
+ email: input.email, password: input.password,
128
+ passwordConfirm: input.confirmPassword ?? input.password, name: input.name,
129
+ }));
130
+ decode(authRecord, result, 'response');
83
131
  return { success: true, redirectTo: '/login' };
84
- } catch (err) {
85
- return { success: false, error: { message: (err as Error).message } };
132
+ } catch (error) {
133
+ return failure('Registration', error, true);
86
134
  }
87
135
  },
88
136
 
89
- async forgotPassword({ email, collection }: Record<string, unknown>): Promise<AuthActionResult> {
137
+ async forgotPassword(params): Promise<AuthActionResult> {
90
138
  try {
91
- const col = (collection as string) || 'users';
92
- await pb.collection(col).requestPasswordReset(email as string);
139
+ const input = decode(resetRequest, params, 'input');
140
+ validateIdentifier(input);
141
+ const result: unknown = await pb.collection(input.collection ?? 'users').requestPasswordReset(input.email);
142
+ decode(confirmed, result, 'response');
93
143
  return { success: true };
94
- } catch (err) {
95
- return { success: false, error: { message: (err as Error).message } };
144
+ } catch (error) {
145
+ return failure('Password reset request', error, true);
96
146
  }
97
147
  },
98
148
 
99
- async updatePassword({ token, password, passwordConfirm, collection }: Record<string, unknown>): Promise<AuthActionResult> {
149
+ async updatePassword(params): Promise<AuthActionResult> {
100
150
  try {
101
- const col = (collection as string) || 'users';
102
- await pb.collection(col).confirmPasswordReset(
103
- token as string,
104
- password as string,
105
- (passwordConfirm ?? password) as string,
151
+ const input = decode(resetConfirmation, params, 'input');
152
+ if (input.confirmPassword !== undefined && input.confirmPassword !== input.password) {
153
+ throw new PocketBaseBoundaryError('input');
154
+ }
155
+ const result: unknown = await pb.collection(input.collection ?? 'users').confirmPasswordReset(
156
+ input.token, input.password, input.confirmPassword ?? input.password,
106
157
  );
158
+ decode(confirmed, result, 'response');
107
159
  return { success: true, redirectTo: '/' };
108
- } catch (err) {
109
- return { success: false, error: { message: (err as Error).message } };
160
+ } catch (error) {
161
+ return failure('Password reset', error, true);
110
162
  }
111
163
  },
112
164
 
113
- async onError(error: unknown): Promise<{ redirectTo?: string; logout?: boolean }> {
114
- if (error instanceof Error && (error.message.includes('401') || error.message.includes('403'))) {
165
+ async onError(error): Promise<{ redirectTo?: string; logout?: boolean }> {
166
+ // SDK errors are class instances. Only the numeric status is projected.
167
+ let status: unknown;
168
+ try {
169
+ const descriptor = typeof error === 'object' && error !== null
170
+ ? Object.getOwnPropertyDescriptor(error, 'status') : undefined;
171
+ status = descriptor && 'value' in descriptor ? descriptor.value : undefined;
172
+ } catch {
173
+ return {};
174
+ }
175
+ if (status === 401 || status === 403) {
176
+ pb.authStore.clear();
115
177
  return { redirectTo: '/login', logout: true };
116
178
  }
117
179
  return {};
@@ -0,0 +1,30 @@
1
+ import { Type, type Static, type TSchema } from '@sinclair/typebox';
2
+ import { checkExact, snapshotPlainData } from '@svadmin/core/schema';
3
+
4
+ export const nonempty = Type.String({ minLength: 1 });
5
+ export const collectionName = Type.String({ minLength: 1, pattern: '^[A-Za-z0-9_]+$' });
6
+ export const authRecord = Type.Object({
7
+ id: nonempty,
8
+ name: Type.Optional(Type.String()),
9
+ email: Type.Optional(Type.String()),
10
+ avatar: Type.Optional(Type.String()),
11
+ });
12
+
13
+ export class PocketBaseBoundaryError extends Error {
14
+ constructor(readonly phase: 'input' | 'response' | 'session' | 'event') {
15
+ super(`Invalid PocketBase ${phase}`);
16
+ this.name = 'PocketBaseBoundaryError';
17
+ }
18
+ }
19
+
20
+ export function decode<S extends TSchema>(
21
+ schema: S, value: unknown, phase: PocketBaseBoundaryError['phase'],
22
+ ): Static<S> {
23
+ try {
24
+ const candidate = snapshotPlainData(value);
25
+ if (checkExact(schema, candidate)) return candidate;
26
+ } catch {
27
+ // Never expose submitted credentials, SDK payloads, or accessor exceptions.
28
+ }
29
+ throw new PocketBaseBoundaryError(phase);
30
+ }
@@ -1,26 +1,25 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
1
  // @svadmin/pocketbase — PocketBase DataProvider
3
2
  // Maps svadmin CRUD operations to PocketBase SDK
4
3
 
5
4
  import type {
6
- DataProvider, GetListParams, GetListResult, GetOneParams, GetOneResult,
7
- CreateParams, CreateResult, UpdateParams, UpdateResult, DeleteParams, DeleteResult,
8
- GetManyParams, GetManyResult, DeleteManyParams, DeleteManyResult, BaseRecord,
5
+ DataProvider,
9
6
  } from '@svadmin/core';
7
+ import { definedOptions } from '@svadmin/core/options';
8
+ import { withValidatedResponses, type DataTransport } from '@svadmin/core/schema';
10
9
 
11
10
  interface PocketBaseProviderOptions {
12
11
  /** PocketBase client instance */
13
- pb: unknown; // PocketBase
12
+ pb: PBClient;
14
13
  }
15
14
 
16
- // Duck-typed PocketBase collection interface
15
+ // SDK results remain unknown until the shared response boundary validates them.
17
16
  interface PBCollection {
18
- getList: (page: number, perPage: number, options?: { sort?: string; filter?: string }) => Promise<{ items: Record<string, unknown>[]; totalItems: number }>;
19
- getOne: (id: string) => Promise<Record<string, unknown>>;
20
- getFullList: (options?: { sort?: string; filter?: string; batch?: number }) => Promise<Record<string, unknown>[]>;
21
- create: (data: Record<string, unknown>) => Promise<Record<string, unknown>>;
22
- update: (id: string, data: Record<string, unknown>) => Promise<Record<string, unknown>>;
23
- delete: (id: string) => Promise<boolean>;
17
+ getList: (page: number, perPage: number, options?: { sort?: string; filter?: string }) => Promise<unknown>;
18
+ getOne: (id: string) => Promise<unknown>;
19
+ getFullList: (options?: { sort?: string; filter?: string; batch?: number }) => Promise<unknown>;
20
+ create: (data: Record<string, unknown>) => Promise<unknown>;
21
+ update: (id: string, data: Record<string, unknown>) => Promise<unknown>;
22
+ delete: (id: string) => Promise<unknown>;
24
23
  }
25
24
 
26
25
  interface PBClient {
@@ -28,6 +27,15 @@ interface PBClient {
28
27
  buildUrl: (path: string) => string;
29
28
  }
30
29
 
30
+ function isObject(value: unknown): value is Record<string, unknown> {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ function inputRecord(value: unknown): Record<string, unknown> {
35
+ if (!isObject(value)) throw new TypeError('PocketBase variables must be an object');
36
+ return value;
37
+ }
38
+
31
39
  function buildSort(sorters?: { field: string; order: 'asc' | 'desc' }[]): string | undefined {
32
40
  if (!sorters?.length) return undefined;
33
41
  return sorters.map(s => s.order === 'desc' ? `-${s.field}` : s.field).join(',');
@@ -48,7 +56,9 @@ function buildFilter(filters?: { field: string; operator: string; value: unknown
48
56
  case 'lte': return `${f.field} <= '${v}'`;
49
57
  case 'null': return `${f.field} = ''`;
50
58
  case 'nnull': return `${f.field} != ''`;
51
- case 'in': return `${f.field} ?= '${(v as unknown[]).join("' || " + f.field + " ?= '")}'`;
59
+ case 'in':
60
+ if (!Array.isArray(v)) throw new TypeError('PocketBase in filter requires an array');
61
+ return `${f.field} ?= '${v.join("' || " + f.field + " ?= '")}'`;
52
62
  default: return `${f.field} = '${v}'`;
53
63
  }
54
64
  }).join(' && ');
@@ -67,57 +77,62 @@ function buildFilter(filters?: { field: string; operator: string; value: unknown
67
77
  * ```
68
78
  */
69
79
  export function createPocketBaseDataProvider(options: PocketBaseProviderOptions): DataProvider {
70
- const pb = options.pb as PBClient;
80
+ const pb = options.pb;
71
81
 
72
- return {
82
+ const transport: DataTransport = {
73
83
  getApiUrl: () => pb.buildUrl('/api'),
74
84
 
75
- async getList<TData extends BaseRecord = BaseRecord>(params: GetListParams): Promise<GetListResult<TData>> {
85
+ async getList(params) {
76
86
  const page = params.pagination?.current ?? 1;
77
87
  const perPage = params.pagination?.pageSize ?? 10;
78
- const result = await pb.collection(params.resource).getList(page, perPage, {
88
+ const result = await pb.collection(params.resource).getList(page, perPage, definedOptions({
79
89
  sort: buildSort(params.sorters),
80
- filter: buildFilter((params.filters || []).filter((f) => 'field' in f) as any),
81
- });
90
+ filter: buildFilter((params.filters || []).filter((f) => 'field' in f)),
91
+ }));
92
+ if (!isObject(result)) return result;
82
93
  return {
83
- data: result.items as unknown as TData[],
84
- total: result.totalItems,
94
+ data: result['items'],
95
+ total: result['totalItems'],
85
96
  };
86
97
  },
87
98
 
88
- async getOne<TData extends BaseRecord = BaseRecord>(params: GetOneParams): Promise<GetOneResult<TData>> {
99
+ async getOne(params) {
89
100
  const record = await pb.collection(params.resource).getOne(String(params.id));
90
- return { data: record as unknown as TData };
101
+ return { data: record };
91
102
  },
92
103
 
93
- async create<TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: CreateParams<TVariables>): Promise<CreateResult<TData>> {
94
- const record = await pb.collection(params.resource).create(params.variables as Record<string, unknown>);
95
- return { data: record as unknown as TData };
104
+ async create(params) {
105
+ const variables = inputRecord(params.variables);
106
+ const record = await pb.collection(params.resource).create(variables);
107
+ return { data: record };
96
108
  },
97
109
 
98
- async update<TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: UpdateParams<TVariables>): Promise<UpdateResult<TData>> {
99
- const record = await pb.collection(params.resource).update(String(params.id), params.variables as Record<string, unknown>);
100
- return { data: record as unknown as TData };
110
+ async update(params) {
111
+ const variables = inputRecord(params.variables);
112
+ const record = await pb.collection(params.resource).update(String(params.id), variables);
113
+ return { data: record };
101
114
  },
102
115
 
103
- async deleteOne<TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: DeleteParams<TVariables>): Promise<DeleteResult<TData>> {
104
- await pb.collection(params.resource).delete(String(params.id));
105
- return { data: { id: params.id } as unknown as TData };
116
+ async deleteOne(params) {
117
+ const deleted = await pb.collection(params.resource).delete(String(params.id));
118
+ return deleted === true ? { data: { id: params.id } } : undefined;
106
119
  },
107
120
 
108
- async getMany<TData extends BaseRecord = BaseRecord>(params: GetManyParams): Promise<GetManyResult<TData>> {
121
+ async getMany(params) {
109
122
  const filter = params.ids.map(id => `id = '${id}'`).join(' || ');
110
123
  const records = await pb.collection(params.resource).getFullList({ filter });
111
- return { data: records as unknown as TData[] };
124
+ return { data: records };
112
125
  },
113
126
 
114
- async deleteMany<TData extends BaseRecord = BaseRecord, TVariables = unknown>(params: DeleteManyParams<TVariables>): Promise<DeleteManyResult<TData>> {
127
+ async deleteMany(params) {
115
128
  const results: unknown[] = [];
116
129
  for (const id of params.ids) {
117
- await pb.collection(params.resource).delete(String(id));
130
+ const deleted = await pb.collection(params.resource).delete(String(id));
131
+ if (deleted !== true) return undefined;
118
132
  results.push({ id });
119
133
  }
120
- return { data: results as unknown as TData[] };
134
+ return { data: results };
121
135
  },
122
136
  };
137
+ return withValidatedResponses(transport);
123
138
  }
package/src/index.ts CHANGED
@@ -2,4 +2,6 @@
2
2
 
3
3
  export { createPocketBaseDataProvider } from './data-provider';
4
4
  export { createPocketBaseAuthProvider } from './auth-provider';
5
- export { createPocketBaseLiveProvider } from './live-provider';
5
+ export type { PocketBaseAuthClient, PocketBaseAuthOptions } from './auth-provider';
6
+ export { createPocketBaseLiveProvider, PocketBaseLiveError } from './live-provider';
7
+ export type { PocketBaseRealtimeClient, PocketBaseLiveOptions } from './live-provider';
@@ -1,65 +1,111 @@
1
- // @svadmin/pocketbase — PocketBase LiveProvider (SSE Realtime)
1
+ import { Type, type Static } from '@sinclair/typebox';
2
2
  import type { LiveProvider, LiveEvent } from '@svadmin/core';
3
+ import { collectionName, decode, nonempty } from './boundary';
3
4
 
4
- interface PocketBaseLiveOptions {
5
- /** PocketBase client instance */
6
- pb: unknown; // PocketBase
5
+ export interface PocketBaseRealtimeClient {
6
+ collection(name: string): {
7
+ subscribe(topic: string, callback: (event: unknown) => void): Promise<unknown>;
8
+ };
7
9
  }
8
10
 
9
- // Duck-typed PocketBase realtime interface
10
- interface PBClientRealtime {
11
- collection: (name: string) => {
12
- subscribe: (topic: string, callback: (data: PBRealtimeEvent) => void) => Promise<void>;
13
- unsubscribe: (topic?: string, callback?: (data: PBRealtimeEvent) => void) => Promise<void>;
14
- };
11
+ type LiveErrorCode = 'INVALID_EVENT' | 'SUBSCRIBE_FAILED' | 'UNSUBSCRIBE_FAILED' | 'CALLBACK_FAILED';
12
+
13
+ export class PocketBaseLiveError extends Error {
14
+ constructor(readonly code: LiveErrorCode) {
15
+ super(`PocketBase realtime ${code.toLowerCase().replaceAll('_', ' ')}`);
16
+ this.name = 'PocketBaseLiveError';
17
+ }
15
18
  }
16
19
 
17
- interface PBRealtimeEvent {
18
- action: 'create' | 'update' | 'delete';
19
- record: Record<string, unknown>;
20
+ export interface PocketBaseLiveOptions {
21
+ pb: PocketBaseRealtimeClient;
22
+ onError?: (error: PocketBaseLiveError) => void;
20
23
  }
21
24
 
22
- /**
23
- * Map PocketBase realtime action to svadmin LiveEvent type.
24
- */
25
- function mapAction(action: string): LiveEvent['type'] {
25
+ const eventSchema = Type.Object({
26
+ action: Type.Union([Type.Literal('create'), Type.Literal('update'), Type.Literal('delete')]),
27
+ record: Type.Object({ id: nonempty }),
28
+ }, { additionalProperties: false });
29
+
30
+ function mapAction(action: Static<typeof eventSchema>['action']): LiveEvent['type'] {
26
31
  switch (action) {
27
32
  case 'create': return 'INSERT';
28
33
  case 'update': return 'UPDATE';
29
34
  case 'delete': return 'DELETE';
30
- default: return 'UPDATE';
31
35
  }
32
36
  }
33
37
 
34
- /**
35
- * Create a LiveProvider backed by PocketBase Realtime (SSE).
36
- *
37
- * Usage:
38
- * ```ts
39
- * import PocketBase from 'pocketbase';
40
- * import { createPocketBaseLiveProvider } from '@svadmin/pocketbase';
41
- *
42
- * const pb = new PocketBase('http://127.0.0.1:8090');
43
- * const liveProvider = createPocketBaseLiveProvider({ pb });
44
- * ```
45
- */
38
+ /** Each subscription owns the SDK-provided disposer, including cancellation during setup. */
46
39
  export function createPocketBaseLiveProvider(options: PocketBaseLiveOptions): LiveProvider {
47
- const pb = options.pb as PBClientRealtime;
40
+ const report = (code: LiveErrorCode): void => {
41
+ const error = new PocketBaseLiveError(code);
42
+ if (!options.onError) {
43
+ console.error(error);
44
+ return;
45
+ }
46
+ try {
47
+ void Promise.resolve(options.onError(error)).catch(() => {
48
+ console.error('PocketBase realtime error handler failed');
49
+ });
50
+ } catch {
51
+ console.error('PocketBase realtime error handler failed');
52
+ }
53
+ };
48
54
 
49
55
  return {
50
56
  subscribe({ resource, callback }) {
51
- const handler = (data: PBRealtimeEvent) => {
52
- callback({
53
- type: mapAction(data.action),
54
- resource,
55
- payload: data.record,
56
- });
57
+ const name = decode(collectionName, resource, 'input');
58
+ let active = true;
59
+ let dispose: (() => unknown) | undefined;
60
+ let disposed = false;
61
+ const cleanup = (): void => {
62
+ if (!dispose || disposed) return;
63
+ disposed = true;
64
+ try {
65
+ const result = dispose();
66
+ void Promise.resolve(result).catch(() => report('UNSUBSCRIBE_FAILED'));
67
+ } catch {
68
+ report('UNSUBSCRIBE_FAILED');
69
+ }
57
70
  };
58
-
59
- pb.collection(resource).subscribe('*', handler).catch(console.error);
60
-
71
+ const handler = (value: unknown): void => {
72
+ if (!active) return;
73
+ let event: LiveEvent;
74
+ try {
75
+ const data = decode(eventSchema, value, 'event');
76
+ event = { type: mapAction(data.action), resource: name, payload: data.record };
77
+ } catch {
78
+ report('INVALID_EVENT');
79
+ return;
80
+ }
81
+ try {
82
+ void Promise.resolve(callback(event)).catch(() => report('CALLBACK_FAILED'));
83
+ } catch {
84
+ report('CALLBACK_FAILED');
85
+ }
86
+ };
87
+ try {
88
+ const result = options.pb.collection(name).subscribe('*', handler);
89
+ void Promise.resolve(result).then((unsubscribe: unknown) => {
90
+ if (typeof unsubscribe !== 'function') {
91
+ active = false;
92
+ report('SUBSCRIBE_FAILED');
93
+ return;
94
+ }
95
+ dispose = (): unknown => unsubscribe();
96
+ if (!active) cleanup();
97
+ }, () => {
98
+ const wasActive = active;
99
+ active = false;
100
+ if (wasActive) report('SUBSCRIBE_FAILED');
101
+ });
102
+ } catch {
103
+ active = false;
104
+ report('SUBSCRIBE_FAILED');
105
+ }
61
106
  return () => {
62
- pb.collection(resource).unsubscribe('*', handler).catch(console.error);
107
+ active = false;
108
+ cleanup();
63
109
  };
64
110
  },
65
111
  };