@aginix/vulcan-data-provider 0.1.0

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/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @aginix/vulcan-data-provider
2
+
3
+ [React Admin](https://marmelab.com/react-admin/) data provider for APIs built
4
+ with [`@aginix/adonis-vulcan`](../adonis-vulcan)'s `BaseResourceController`.
5
+
6
+ It speaks the same PostgREST-style filter grammar that `BaseResourceController`
7
+ parses on the server side (`?status=eq.active&age=gte.18`), so any controller
8
+ that `extends BaseResourceController` is wired up automatically.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ pnpm add @aginix/vulcan-data-provider react-admin
14
+ ```
15
+
16
+ `react-admin` (or `ra-core`) is a peer dependency.
17
+
18
+ ## Usage
19
+
20
+ ```tsx
21
+ import { Admin, Resource, fetchUtils } from 'react-admin'
22
+ import { vulcanDataProvider } from '@aginix/vulcan-data-provider'
23
+
24
+ import { PostList, PostEdit, PostCreate } from './posts'
25
+
26
+ const dataProvider = vulcanDataProvider({
27
+ apiUrl: '/api',
28
+ httpClient: fetchUtils.fetchJson,
29
+ })
30
+
31
+ export const App = () => (
32
+ <Admin dataProvider={dataProvider}>
33
+ <Resource name="posts" list={PostList} edit={PostEdit} create={PostCreate} />
34
+ </Admin>
35
+ )
36
+ ```
37
+
38
+ ## How it maps to the API
39
+
40
+ | react-admin call | HTTP method + URL |
41
+ | ------------------- | --------------------------------------------------- |
42
+ | `getList` | `GET /<resource>?page=&perPage=&order=&<filters>` |
43
+ | `getOne` | `GET /<resource>/<id>` |
44
+ | `getMany` | `GET /<resource>?id=in.(...)&page=1&perPage=<n>` |
45
+ | `getManyReference` | `GET /<resource>?<target>=eq.<id>&...` |
46
+ | `create` | `POST /<resource>` |
47
+ | `update` | `PUT /<resource>/<id>` (only changed fields) |
48
+ | `updateMany` | `PUT /<resource>/<id>` looped (no batch endpoint) |
49
+ | `delete` | `DELETE /<resource>/<id>` |
50
+ | `deleteMany` | `DELETE /<resource>/<id>` looped |
51
+
52
+ Response envelope (matches `ResourceSerializer` / Lucid pagination):
53
+
54
+ ```jsonc
55
+ // list
56
+ { "data": [...], "metadata": { "total": 99, "perPage": 25, "currentPage": 1, ... } }
57
+
58
+ // single
59
+ { "data": { ... } }
60
+
61
+ // destroy → 204 No Content
62
+ ```
63
+
64
+ ## Filter grammar
65
+
66
+ The data provider reads react-admin's `filter` object and emits
67
+ PostgREST-flavored query strings that `@aginix/adonis-vulcan`'s `FilterParser`
68
+ understands.
69
+
70
+ | Filter shape | URL fragment |
71
+ | ------------------------------------------------- | ----------------------------------------- |
72
+ | `{ status: 'active' }` | `status=eq.active` |
73
+ | `{ 'age@gt': 18 }` | `age=gt.18` |
74
+ | `{ 'name@like': 'foo bar' }` | `name=like.*foo*&name=like.*bar*` |
75
+ | `{ tags: ['admin', 'user'] }` | `tags=in.(admin,user)` |
76
+ | `{ q: 'search' }` | `q=search` (no operator) |
77
+ | `{ faculty: { name: 'CS' } }` | `faculty.name=eq.CS` (embedded filter) |
78
+ | `{ 'curriculums.exists': 'true' }` | `curriculums.exists=true` (special key) |
79
+
80
+ Set `defaultListOp` on the config to change the implicit operator from `eq` to
81
+ something else (e.g. `'ilike'` for free-text fields).
82
+
83
+ ## Compound primary keys
84
+
85
+ For resources whose primary key is more than one column, register the column
86
+ list when constructing the provider:
87
+
88
+ ```ts
89
+ import { vulcanDataProvider, defaultPrimaryKeys } from '@aginix/vulcan-data-provider'
90
+
91
+ defaultPrimaryKeys.set('enrollments', ['student_id', 'course_id'])
92
+
93
+ const dataProvider = vulcanDataProvider({
94
+ apiUrl: '/api',
95
+ httpClient: fetchUtils.fetchJson,
96
+ primaryKeys: defaultPrimaryKeys,
97
+ })
98
+ ```
99
+
100
+ The provider then synthesizes a virtual `id` (a JSON-encoded array of the key
101
+ parts) so react-admin's record-by-id flow keeps working transparently.
102
+
103
+ ## Configuration
104
+
105
+ ```ts
106
+ type VulcanDataProviderConfig = {
107
+ apiUrl: string
108
+ httpClient: (url: string, options?: any) => Promise<{ status, headers, body, json }>
109
+ defaultListOp?: VulcanOperator // default: 'eq'
110
+ sortOrder?: VulcanSortOrder // default: ASC nullsLast / DESC nullsFirst
111
+ primaryKeys?: Map<string, readonly string[]> // default: ['id'] for every resource
112
+ pageParam?: string // default: 'page'
113
+ perPageParam?: string // default: 'perPage'
114
+ }
115
+ ```
@@ -0,0 +1,4 @@
1
+ export * from './src/types.js';
2
+ export { parseFilters, getPrimaryKey, decodeId, encodeId, removePrimaryKey, dataWithVirtualId, dataWithoutVirtualId, getQuery, getOrderBy, type ParsedFilters, } from './src/url_builder.js';
3
+ export { vulcanDataProvider, defaultPrimaryKeys } from './src/data_provider.js';
4
+ export { default } from './src/data_provider.js';
package/build/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import { EMBEDDED_SPECIAL_KEYS, VULCAN_OPERATORS, VulcanSortOrder } from "./src/types.js";
2
+ import { dataWithVirtualId, dataWithoutVirtualId, decodeId, encodeId, getOrderBy, getPrimaryKey, getQuery, parseFilters, removePrimaryKey } from "./src/url_builder.js";
3
+ import vulcanDataProvider, { defaultPrimaryKeys } from "./src/data_provider.js";
4
+ export { EMBEDDED_SPECIAL_KEYS, VULCAN_OPERATORS, VulcanSortOrder, dataWithVirtualId, dataWithoutVirtualId, decodeId, vulcanDataProvider as default, vulcanDataProvider, defaultPrimaryKeys, encodeId, getOrderBy, getPrimaryKey, getQuery, parseFilters, removePrimaryKey };
@@ -0,0 +1,32 @@
1
+ import type { DataProvider } from 'ra-core';
2
+ import { type PrimaryKey, type VulcanDataProviderConfig } from './types.js';
3
+ /**
4
+ * Convenience: a per-resource `Map<resource, PrimaryKey>` you can pass to
5
+ * `vulcanDataProvider({ primaryKeys: defaultPrimaryKeys })` and then `.set()`
6
+ * compound-key resources on. Empty by default — `['id']` is the implicit
7
+ * fallback.
8
+ */
9
+ export declare const defaultPrimaryKeys: Map<string, PrimaryKey>;
10
+ /**
11
+ * React-admin data provider for APIs built with `@aginix/adonis-vulcan`.
12
+ *
13
+ * Endpoints expected (matches `Route.resource()` + `BaseResourceController`):
14
+ *
15
+ * getList GET /<resource>?page=&perPage=&order=&<filters>
16
+ * getOne GET /<resource>/<id>
17
+ * getMany GET /<resource>?id=in.(...)&page=1&perPage=<count>
18
+ * getManyReference GET /<resource>?<target>=eq.<id>&...
19
+ * create POST /<resource>
20
+ * update PUT /<resource>/<id>
21
+ * updateMany PUT /<resource>/<id> (looped)
22
+ * delete DELETE /<resource>/<id>
23
+ * deleteMany DELETE /<resource>/<id> (looped)
24
+ *
25
+ * Response envelopes:
26
+ *
27
+ * list { data: T[], metadata: { total, perPage, currentPage, lastPage, ... } }
28
+ * single { data: T }
29
+ * destroy 204 No Content
30
+ */
31
+ export declare const vulcanDataProvider: (config: VulcanDataProviderConfig) => DataProvider;
32
+ export default vulcanDataProvider;
@@ -0,0 +1,212 @@
1
+ import { dataWithVirtualId, dataWithoutVirtualId, encodeId, getOrderBy, getPrimaryKey, getQuery, parseFilters, removePrimaryKey } from "./url_builder.js";
2
+ import { HttpError } from "ra-core";
3
+ import qs from "qs";
4
+ import { dequal } from "dequal";
5
+ //#region src/data_provider.ts
6
+ /**
7
+ * Convenience: a per-resource `Map<resource, PrimaryKey>` you can pass to
8
+ * `vulcanDataProvider({ primaryKeys: defaultPrimaryKeys })` and then `.set()`
9
+ * compound-key resources on. Empty by default — `['id']` is the implicit
10
+ * fallback.
11
+ */
12
+ const defaultPrimaryKeys = /* @__PURE__ */ new Map();
13
+ const computeChanges = (data, previousData) => {
14
+ return Object.keys(data).reduce((acc, key) => {
15
+ if (!dequal(data[key], previousData[key])) acc[key] = data[key];
16
+ return acc;
17
+ }, {});
18
+ };
19
+ /**
20
+ * Map a VineJS-style 422 error body into the
21
+ * `{ errors: { fieldName: VineError } }` shape that
22
+ * react-admin's `<Form>` reads from `HttpError.body`.
23
+ *
24
+ * AdonisJS + VineJS typically produces `{ errors: [{ field, message, rule, ...}] }`.
25
+ * Some setups return the bare array. Both are handled.
26
+ */
27
+ const checkIfFormValidation = (ex) => {
28
+ if (ex.status === 400 || ex.status === 422) {
29
+ const body = ex.body;
30
+ const list = Array.isArray(body) ? body : Array.isArray(body?.errors) ? body.errors : [];
31
+ if (list.length > 0) {
32
+ const errors = list.reduce((acc, curr) => {
33
+ if (curr && typeof curr.field === "string") acc[curr.field] = curr;
34
+ return acc;
35
+ }, {});
36
+ throw new HttpError(ex.message, ex.status, { errors });
37
+ }
38
+ }
39
+ throw ex;
40
+ };
41
+ const buildHeaders = (extra) => {
42
+ const headers = new Headers({ Accept: "application/json" });
43
+ if (extra) for (const [key, value] of Object.entries(extra)) headers.set(key, value);
44
+ return headers;
45
+ };
46
+ const unwrapList = (json) => {
47
+ const rows = Array.isArray(json?.data) ? json.data : [];
48
+ return {
49
+ rows,
50
+ total: typeof json?.metadata?.total === "number" ? json.metadata.total : typeof json?.meta?.total === "number" ? json.meta.total : rows.length
51
+ };
52
+ };
53
+ const unwrapItem = (json) => {
54
+ if (json === null || json === void 0) return json;
55
+ if (Object.prototype.hasOwnProperty.call(json, "data")) return json.data;
56
+ return json;
57
+ };
58
+ /**
59
+ * React-admin data provider for APIs built with `@aginix/adonis-vulcan`.
60
+ *
61
+ * Endpoints expected (matches `Route.resource()` + `BaseResourceController`):
62
+ *
63
+ * getList GET /<resource>?page=&perPage=&order=&<filters>
64
+ * getOne GET /<resource>/<id>
65
+ * getMany GET /<resource>?id=in.(...)&page=1&perPage=<count>
66
+ * getManyReference GET /<resource>?<target>=eq.<id>&...
67
+ * create POST /<resource>
68
+ * update PUT /<resource>/<id>
69
+ * updateMany PUT /<resource>/<id> (looped)
70
+ * delete DELETE /<resource>/<id>
71
+ * deleteMany DELETE /<resource>/<id> (looped)
72
+ *
73
+ * Response envelopes:
74
+ *
75
+ * list { data: T[], metadata: { total, perPage, currentPage, lastPage, ... } }
76
+ * single { data: T }
77
+ * destroy 204 No Content
78
+ */
79
+ const vulcanDataProvider = (config) => {
80
+ const { apiUrl, httpClient, defaultListOp = "eq", sortOrder, primaryKeys, pageParam = "page", perPageParam = "perPage" } = config;
81
+ const stringify = (q) => qs.stringify(q);
82
+ const sendList = async (url, options, primaryKey) => {
83
+ const { json } = await runRequest(httpClient, url, options);
84
+ const { rows, total } = unwrapList(json);
85
+ return {
86
+ data: rows.map((row) => dataWithVirtualId(row, primaryKey)),
87
+ total
88
+ };
89
+ };
90
+ return {
91
+ getList: async (resource, params) => {
92
+ const primaryKey = getPrimaryKey(resource, primaryKeys);
93
+ const { page = 1, perPage = 25 } = params.pagination ?? {};
94
+ const { field, order } = params.sort ?? {};
95
+ const { filter } = parseFilters(params, defaultListOp);
96
+ const query = {
97
+ [pageParam]: page,
98
+ [perPageParam]: perPage,
99
+ ...filter
100
+ };
101
+ if (field) query.order = getOrderBy(field, order, primaryKey, sortOrder);
102
+ return sendList(`${apiUrl}/${resource}?${stringify(query)}`, { headers: buildHeaders(params.meta?.headers) }, primaryKey);
103
+ },
104
+ getOne: async (resource, params) => {
105
+ const primaryKey = getPrimaryKey(resource, primaryKeys);
106
+ const { json } = await runRequest(httpClient, `${apiUrl}/${resource}/${encodeURIComponent(String(params.id))}`, { headers: buildHeaders(params.meta?.headers) });
107
+ return { data: dataWithVirtualId(unwrapItem(json), primaryKey) };
108
+ },
109
+ getMany: async (resource, params) => {
110
+ if (!params.ids || params.ids.length === 0) return { data: [] };
111
+ const primaryKey = getPrimaryKey(resource, primaryKeys);
112
+ const { data } = await sendList(`${apiUrl}/${resource}?${stringify({
113
+ ...getQuery(primaryKey, params.ids),
114
+ [pageParam]: 1,
115
+ [perPageParam]: params.ids.length
116
+ })}`, { headers: buildHeaders(params.meta?.headers) }, primaryKey);
117
+ return { data };
118
+ },
119
+ getManyReference: async (resource, params) => {
120
+ const primaryKey = getPrimaryKey(resource, primaryKeys);
121
+ const { page = 1, perPage = 25 } = params.pagination ?? {};
122
+ const { field, order } = params.sort ?? {};
123
+ const { filter } = parseFilters(params, defaultListOp);
124
+ const query = {
125
+ ...params.target ? { [params.target]: `eq.${params.id}` } : {},
126
+ [pageParam]: page,
127
+ [perPageParam]: perPage,
128
+ ...filter
129
+ };
130
+ if (field) query.order = getOrderBy(field, order, primaryKey, sortOrder);
131
+ return sendList(`${apiUrl}/${resource}?${stringify(query)}`, { headers: buildHeaders(params.meta?.headers) }, primaryKey);
132
+ },
133
+ create: async (resource, params) => {
134
+ const primaryKey = getPrimaryKey(resource, primaryKeys);
135
+ const url = `${apiUrl}/${resource}`;
136
+ const body = JSON.stringify(dataWithoutVirtualId(params.data, primaryKey));
137
+ try {
138
+ const { json } = await runRequest(httpClient, url, {
139
+ method: "POST",
140
+ headers: buildHeaders({
141
+ "Content-Type": "application/json",
142
+ ...params.meta?.headers ?? {}
143
+ }),
144
+ body
145
+ });
146
+ return { data: dataWithVirtualId(unwrapItem(json), primaryKey) };
147
+ } catch (ex) {
148
+ return checkIfFormValidation(ex);
149
+ }
150
+ },
151
+ update: async (resource, params) => {
152
+ const primaryKey = getPrimaryKey(resource, primaryKeys);
153
+ const url = `${apiUrl}/${resource}/${encodeURIComponent(String(params.id))}`;
154
+ const data = params.data;
155
+ const previousData = params.previousData ?? {};
156
+ const changed = computeChanges(data, previousData);
157
+ if (Object.keys(changed).length === 0) return { data: { ...previousData } };
158
+ const body = JSON.stringify(dataWithoutVirtualId(removePrimaryKey(changed, primaryKey), primaryKey));
159
+ try {
160
+ const { json } = await runRequest(httpClient, url, {
161
+ method: "PUT",
162
+ headers: buildHeaders({
163
+ "Content-Type": "application/json",
164
+ ...params.meta?.headers ?? {}
165
+ }),
166
+ body
167
+ });
168
+ return { data: dataWithVirtualId(unwrapItem(json), primaryKey) };
169
+ } catch (ex) {
170
+ return checkIfFormValidation(ex);
171
+ }
172
+ },
173
+ updateMany: async (resource, params) => {
174
+ const primaryKey = getPrimaryKey(resource, primaryKeys);
175
+ const data = params.data;
176
+ const body = JSON.stringify(dataWithoutVirtualId(removePrimaryKey(data, primaryKey), primaryKey));
177
+ const headers = buildHeaders({
178
+ "Content-Type": "application/json",
179
+ ...params.meta?.headers ?? {}
180
+ });
181
+ const ids = params.ids ?? [];
182
+ return { data: await Promise.all(ids.map((id) => runRequest(httpClient, `${apiUrl}/${resource}/${encodeURIComponent(String(id))}`, {
183
+ method: "PUT",
184
+ headers,
185
+ body
186
+ }).then(({ json }) => encodeId(unwrapItem(json), primaryKey)))) };
187
+ },
188
+ delete: async (resource, params) => {
189
+ const primaryKey = getPrimaryKey(resource, primaryKeys);
190
+ const { json } = await runRequest(httpClient, `${apiUrl}/${resource}/${encodeURIComponent(String(params.id))}`, {
191
+ method: "DELETE",
192
+ headers: buildHeaders(params.meta?.headers)
193
+ });
194
+ const fallback = params.previousData ?? { id: params.id };
195
+ return { data: json ? dataWithVirtualId(unwrapItem(json), primaryKey) : fallback };
196
+ },
197
+ deleteMany: async (resource, params) => {
198
+ const headers = buildHeaders(params.meta?.headers);
199
+ const ids = params.ids ?? [];
200
+ await Promise.all(ids.map((id) => runRequest(httpClient, `${apiUrl}/${resource}/${encodeURIComponent(String(id))}`, {
201
+ method: "DELETE",
202
+ headers
203
+ })));
204
+ return { data: ids };
205
+ }
206
+ };
207
+ };
208
+ const runRequest = async (httpClient, url, options) => {
209
+ return { json: (await httpClient(url, options))?.json };
210
+ };
211
+ //#endregion
212
+ export { vulcanDataProvider as default, vulcanDataProvider, defaultPrimaryKeys };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The full set of PostgREST-style operators that the
3
+ * `@aginix/adonis-vulcan` filter pipeline understands.
4
+ *
5
+ * Mirrors the operator list in
6
+ * `@aginix/adonis-vulcan`'s `FilterParser` (`OPERATOR_PATTERN` / `OPERATOR_SQL_MAP`)
7
+ * plus the `not` / `or` / `and` modifiers that wrap conditions.
8
+ */
9
+ export declare const VULCAN_OPERATORS: readonly ["eq", "gt", "gte", "lt", "lte", "neq", "like", "ilike", "match", "imatch", "in", "is", "fts", "plfts", "phfts", "wfts", "cs", "cd", "ov", "sl", "sr", "nxr", "nxl", "adj", "not", "or", "and"];
10
+ export type VulcanOperator = (typeof VULCAN_OPERATORS)[number];
11
+ /**
12
+ * Special path segments that may appear after a relation name when filtering
13
+ * embedded resources (e.g. `curriculums.exists=true`,
14
+ * `curriculums.order=name.asc`, `curriculums.limit=5`).
15
+ *
16
+ * These are passed through the URL as-is — no `eq.` / `in.` / etc. is
17
+ * prepended, because they are control parameters, not column filters.
18
+ */
19
+ export declare const EMBEDDED_SPECIAL_KEYS: readonly ["exists", "order", "limit"];
20
+ export type EmbeddedSpecialKey = (typeof EMBEDDED_SPECIAL_KEYS)[number];
21
+ /**
22
+ * Order-by null handling. Format: `<asc-spec>,<desc-spec>` so that a single
23
+ * value covers both directions; the appropriate half is chosen depending on
24
+ * whether the user clicked an ASC or DESC sort header.
25
+ */
26
+ export declare enum VulcanSortOrder {
27
+ AscendingNullsLastDescendingNullsFirst = "asc,desc",
28
+ AscendingNullsLastDescendingNullsLast = "asc,desc.nullslast",
29
+ AscendingNullsFirstDescendingNullsFirst = "asc.nullsfirst,desc",
30
+ AscendingNullsFirstDescendingNullsLast = "asc.nullsfirst,desc.nullslast"
31
+ }
32
+ /**
33
+ * A primary key descriptor. A single string (`['id']`) for the common case,
34
+ * or several strings for compound keys (`['org_id', 'sku']`). When the
35
+ * primary key is compound, the data provider synthesizes a virtual `id` field
36
+ * encoded as a JSON-stringified array of the key parts so react-admin can
37
+ * keep using its expected `id` lookup.
38
+ */
39
+ export type PrimaryKey = readonly string[];
40
+ /**
41
+ * Fetch-like signature compatible with `fetchUtils.fetchJson` from
42
+ * react-admin. Implementations resolve with `{ status, headers, body, json }`
43
+ * for 2xx responses and reject with an `HttpError` otherwise.
44
+ */
45
+ export type HttpClient = (url: string, options?: HttpClientOptions) => Promise<HttpClientResponse>;
46
+ export interface HttpClientOptions {
47
+ method?: string;
48
+ headers?: Headers;
49
+ body?: string | null;
50
+ user?: {
51
+ authenticated?: boolean;
52
+ token?: string;
53
+ };
54
+ [key: string]: unknown;
55
+ }
56
+ export interface HttpClientResponse {
57
+ status: number;
58
+ headers: Headers;
59
+ body: string;
60
+ json?: any;
61
+ }
62
+ export interface VulcanDataProviderConfig {
63
+ /**
64
+ * Base URL of the API. e.g. `https://api.example.com` or `/api`. The
65
+ * resource name is appended directly: `<apiUrl>/<resource>`.
66
+ */
67
+ apiUrl: string;
68
+ /**
69
+ * Fetch-compatible function used for every request. Most consumers pass
70
+ * `fetchUtils.fetchJson` from `react-admin`, optionally wrapped with auth
71
+ * headers.
72
+ */
73
+ httpClient: HttpClient;
74
+ /**
75
+ * Operator applied to filter values that don't specify one explicitly via
76
+ * the `field@op` syntax. Defaults to `eq`.
77
+ */
78
+ defaultListOp?: VulcanOperator;
79
+ /**
80
+ * Null handling for ASC / DESC sorts. Defaults to
81
+ * `AscendingNullsLastDescendingNullsFirst` (`asc` / `desc`), which mirrors
82
+ * PostgreSQL's default ordering.
83
+ */
84
+ sortOrder?: VulcanSortOrder;
85
+ /**
86
+ * Per-resource primary key overrides. Resources missing from the map fall
87
+ * back to `['id']`.
88
+ */
89
+ primaryKeys?: Map<string, PrimaryKey>;
90
+ /**
91
+ * Pagination input names used by the `BaseResourceController`. Override
92
+ * here when a controller customizes `pagination.pageInput` /
93
+ * `perPageInput`. Defaults to `page` / `perPage`.
94
+ */
95
+ pageParam?: string;
96
+ perPageParam?: string;
97
+ }
@@ -0,0 +1,65 @@
1
+ //#region src/types.ts
2
+ /**
3
+ * The full set of PostgREST-style operators that the
4
+ * `@aginix/adonis-vulcan` filter pipeline understands.
5
+ *
6
+ * Mirrors the operator list in
7
+ * `@aginix/adonis-vulcan`'s `FilterParser` (`OPERATOR_PATTERN` / `OPERATOR_SQL_MAP`)
8
+ * plus the `not` / `or` / `and` modifiers that wrap conditions.
9
+ */
10
+ const VULCAN_OPERATORS = [
11
+ "eq",
12
+ "gt",
13
+ "gte",
14
+ "lt",
15
+ "lte",
16
+ "neq",
17
+ "like",
18
+ "ilike",
19
+ "match",
20
+ "imatch",
21
+ "in",
22
+ "is",
23
+ "fts",
24
+ "plfts",
25
+ "phfts",
26
+ "wfts",
27
+ "cs",
28
+ "cd",
29
+ "ov",
30
+ "sl",
31
+ "sr",
32
+ "nxr",
33
+ "nxl",
34
+ "adj",
35
+ "not",
36
+ "or",
37
+ "and"
38
+ ];
39
+ /**
40
+ * Special path segments that may appear after a relation name when filtering
41
+ * embedded resources (e.g. `curriculums.exists=true`,
42
+ * `curriculums.order=name.asc`, `curriculums.limit=5`).
43
+ *
44
+ * These are passed through the URL as-is — no `eq.` / `in.` / etc. is
45
+ * prepended, because they are control parameters, not column filters.
46
+ */
47
+ const EMBEDDED_SPECIAL_KEYS = [
48
+ "exists",
49
+ "order",
50
+ "limit"
51
+ ];
52
+ /**
53
+ * Order-by null handling. Format: `<asc-spec>,<desc-spec>` so that a single
54
+ * value covers both directions; the appropriate half is chosen depending on
55
+ * whether the user clicked an ASC or DESC sort header.
56
+ */
57
+ let VulcanSortOrder = /* @__PURE__ */ function(VulcanSortOrder) {
58
+ VulcanSortOrder["AscendingNullsLastDescendingNullsFirst"] = "asc,desc";
59
+ VulcanSortOrder["AscendingNullsLastDescendingNullsLast"] = "asc,desc.nullslast";
60
+ VulcanSortOrder["AscendingNullsFirstDescendingNullsFirst"] = "asc.nullsfirst,desc";
61
+ VulcanSortOrder["AscendingNullsFirstDescendingNullsLast"] = "asc.nullsfirst,desc.nullslast";
62
+ return VulcanSortOrder;
63
+ }({});
64
+ //#endregion
65
+ export { EMBEDDED_SPECIAL_KEYS, VULCAN_OPERATORS, VulcanSortOrder };
@@ -0,0 +1,60 @@
1
+ import type { Identifier, SortPayload } from 'ra-core';
2
+ import { type PrimaryKey, type VulcanOperator, VulcanSortOrder } from './types.js';
3
+ export type ParsedFilters = {
4
+ /**
5
+ * A flat record of query-string keys to either a single
6
+ * `<op>.<value>` string (or `(child1,child2)` for `and`/`or`) or an array of
7
+ * those strings when the same key needs to repeat — `qs.stringify` will
8
+ * expand the array into `field=foo&field=bar`, which the server-side
9
+ * `FilterParser` joins back into an AND.
10
+ */
11
+ filter: Record<string, string | string[]>;
12
+ };
13
+ /**
14
+ * Translate a react-admin `filter` object into the PostgREST-style
15
+ * query-string fragment understood by `@aginix/adonis-vulcan`'s
16
+ * `FilterParser`.
17
+ *
18
+ * Conventions:
19
+ *
20
+ * - `field: value` becomes `field=<defaultListOp>.<value>` (default `eq`).
21
+ * - `field@op: value` overrides the operator (e.g. `age@gt: 18`).
22
+ * - Array values map to `field=in.(v1,v2,v3)`.
23
+ * - `like` / `ilike` split the value on whitespace and emit one
24
+ * `field=like.*token*` per token, which `qs.stringify` turns into repeated
25
+ * keys (joined with AND server-side).
26
+ * - `or` / `and` keys recurse and emit `or=(c1.<op>.v1,c2.<op>.v2)`.
27
+ * - The `q` key is a free-form search term — it is forwarded verbatim
28
+ * (`q=value`), so a filter subclass on the server side can take it via the
29
+ * "custom method takes precedence" hook of `PostgRESTModelFilter`.
30
+ * - Embedded special keys (e.g. `curriculums.exists`) are forwarded verbatim
31
+ * as well — no operator is prepended.
32
+ */
33
+ export declare const parseFilters: (params: {
34
+ filter?: Record<string, any>;
35
+ }, defaultListOp?: VulcanOperator) => ParsedFilters;
36
+ export declare const getPrimaryKey: (resource: string, primaryKeys?: Map<string, PrimaryKey>) => PrimaryKey;
37
+ export declare const decodeId: (id: Identifier, primaryKey: PrimaryKey) => string[] | number[];
38
+ export declare const encodeId: (data: Record<string, unknown>, primaryKey: PrimaryKey) => Identifier;
39
+ export declare const removePrimaryKey: (data: Record<string, unknown>, primaryKey: PrimaryKey) => Record<string, unknown>;
40
+ /**
41
+ * For compound primary keys, attach a synthetic `id` so react-admin can rely
42
+ * on its standard record-by-id flow. No-op for the regular `['id']` case.
43
+ */
44
+ export declare const dataWithVirtualId: <T extends Record<string, unknown>>(data: T, primaryKey: PrimaryKey) => T;
45
+ /**
46
+ * Strip the synthetic `id` field before sending a payload back to the server.
47
+ */
48
+ export declare const dataWithoutVirtualId: (data: Record<string, unknown>, primaryKey: PrimaryKey) => Record<string, unknown>;
49
+ /**
50
+ * Build the filter fragment that selects records by primary key. Works for
51
+ * single ids, multiple ids, and compound primary keys. Used by
52
+ * `getMany`, `updateMany`, and `deleteMany` to address rows in bulk.
53
+ */
54
+ export declare const getQuery: (primaryKey: PrimaryKey, ids: Identifier | readonly Identifier[] | undefined) => Record<string, string | string[]>;
55
+ /**
56
+ * Format `field` + `order` (`'ASC' | 'DESC'`) into the `order=...` value
57
+ * understood by `FilterParser`. When the sort field is the synthetic `id` and
58
+ * the resource has a compound primary key, expand it across each key part.
59
+ */
60
+ export declare const getOrderBy: (field: string, order: SortPayload["order"] | undefined, primaryKey: PrimaryKey, sortOrder?: VulcanSortOrder) => string;
@@ -0,0 +1,168 @@
1
+ import { EMBEDDED_SPECIAL_KEYS } from "./types.js";
2
+ //#region src/url_builder.ts
3
+ const isObject = (obj) => typeof obj === "object" && !Array.isArray(obj) && obj !== null;
4
+ const resolveKeys = (filter, keys) => {
5
+ let result = filter[keys[0]];
6
+ for (let i = 1; i < keys.length; ++i) result = result[keys[i]];
7
+ return result;
8
+ };
9
+ /**
10
+ * `curriculums.exists` / `faculty.order` / etc. The last dotted segment must
11
+ * match one of the `EMBEDDED_SPECIAL_KEYS`.
12
+ */
13
+ const isEmbeddedSpecialKey = (key) => {
14
+ const parts = key.split(".");
15
+ if (parts.length < 2) return false;
16
+ const last = parts[parts.length - 1].toLowerCase();
17
+ return EMBEDDED_SPECIAL_KEYS.includes(last);
18
+ };
19
+ const isCompoundKey = (primaryKey) => primaryKey.length > 1;
20
+ /**
21
+ * Translate a react-admin `filter` object into the PostgREST-style
22
+ * query-string fragment understood by `@aginix/adonis-vulcan`'s
23
+ * `FilterParser`.
24
+ *
25
+ * Conventions:
26
+ *
27
+ * - `field: value` becomes `field=<defaultListOp>.<value>` (default `eq`).
28
+ * - `field@op: value` overrides the operator (e.g. `age@gt: 18`).
29
+ * - Array values map to `field=in.(v1,v2,v3)`.
30
+ * - `like` / `ilike` split the value on whitespace and emit one
31
+ * `field=like.*token*` per token, which `qs.stringify` turns into repeated
32
+ * keys (joined with AND server-side).
33
+ * - `or` / `and` keys recurse and emit `or=(c1.<op>.v1,c2.<op>.v2)`.
34
+ * - The `q` key is a free-form search term — it is forwarded verbatim
35
+ * (`q=value`), so a filter subclass on the server side can take it via the
36
+ * "custom method takes precedence" hook of `PostgRESTModelFilter`.
37
+ * - Embedded special keys (e.g. `curriculums.exists`) are forwarded verbatim
38
+ * as well — no operator is prepended.
39
+ */
40
+ const parseFilters = (params, defaultListOp = "eq") => {
41
+ const filter = params.filter ?? {};
42
+ const result = { filter: {} };
43
+ Object.keys(filter).forEach((rawKey) => {
44
+ let key = rawKey;
45
+ let keyArray = [key];
46
+ let values;
47
+ if (key.split("@")[0] !== "" && isObject(filter[key])) {
48
+ let innerVal = filter[key];
49
+ while (isObject(innerVal)) {
50
+ const [innerKey] = Object.keys(innerVal);
51
+ keyArray.push(innerKey);
52
+ innerVal = innerVal[innerKey];
53
+ }
54
+ key = keyArray.join(".");
55
+ values = [innerVal];
56
+ } else values = [filter[rawKey]];
57
+ const splitKey = key.split("@");
58
+ const filterKey = splitKey[0];
59
+ const filterValue = filter[rawKey];
60
+ const isArrayValue = Array.isArray(filterValue);
61
+ const operation = (() => {
62
+ if (splitKey.length === 2) return splitKey[1];
63
+ if (key[0] === "$") return "";
64
+ if (filterKey === "q") return "";
65
+ if (isEmbeddedSpecialKey(filterKey)) return "";
66
+ if (isArrayValue) return "in";
67
+ return defaultListOp;
68
+ })();
69
+ if (operation === "like" || operation === "ilike") {
70
+ const raw = resolveKeys(filter, keyArray);
71
+ values = String(raw).trim().split(/\s+/);
72
+ } else if (operation === "or" || operation === "and") {
73
+ const subResult = parseFilters({ filter: resolveKeys(filter, keyArray) }, defaultListOp);
74
+ const expressions = [];
75
+ Object.entries(subResult.filter).forEach(([subKey, val]) => {
76
+ if (Array.isArray(val)) expressions.push(...val.map((v) => [subKey, v].join(".")));
77
+ else expressions.push([subKey, val].join("."));
78
+ });
79
+ values = [`(${expressions.join(",")})`];
80
+ }
81
+ if (isArrayValue && operation === "in") {
82
+ result.filter[filterKey] = `in.(${filterValue.join(",")})`;
83
+ return;
84
+ }
85
+ values.forEach((value) => {
86
+ const op = (() => {
87
+ if (operation.length === 0) return `${value}`;
88
+ if (operation.includes("like")) return `${operation}.*${value}*`;
89
+ if (operation === "and" || operation === "or") return `${value}`;
90
+ return `${operation}.${value}`;
91
+ })();
92
+ const writeKey = operation === "and" || operation === "or" ? operation : filterKey;
93
+ const existing = result.filter[writeKey];
94
+ if (existing === void 0) result.filter[writeKey] = op;
95
+ else if (Array.isArray(existing)) existing.push(op);
96
+ else result.filter[writeKey] = [existing, op];
97
+ });
98
+ });
99
+ return result;
100
+ };
101
+ const getPrimaryKey = (resource, primaryKeys) => {
102
+ return primaryKeys?.get(resource) ?? ["id"];
103
+ };
104
+ const decodeId = (id, primaryKey) => {
105
+ if (isCompoundKey(primaryKey)) return JSON.parse(id.toString());
106
+ return [id.toString()];
107
+ };
108
+ const encodeId = (data, primaryKey) => {
109
+ if (isCompoundKey(primaryKey)) return JSON.stringify(primaryKey.map((key) => data[key]));
110
+ return data[primaryKey[0]];
111
+ };
112
+ const removePrimaryKey = (data, primaryKey) => {
113
+ const next = { ...data };
114
+ primaryKey.forEach((key) => {
115
+ delete next[key];
116
+ });
117
+ return next;
118
+ };
119
+ /**
120
+ * For compound primary keys, attach a synthetic `id` so react-admin can rely
121
+ * on its standard record-by-id flow. No-op for the regular `['id']` case.
122
+ */
123
+ const dataWithVirtualId = (data, primaryKey) => {
124
+ if (primaryKey.length === 1 && primaryKey[0] === "id") return data;
125
+ return Object.assign(data, { id: encodeId(data, primaryKey) });
126
+ };
127
+ /**
128
+ * Strip the synthetic `id` field before sending a payload back to the server.
129
+ */
130
+ const dataWithoutVirtualId = (data, primaryKey) => {
131
+ if (primaryKey.length === 1 && primaryKey[0] === "id") return data;
132
+ const next = { ...data };
133
+ delete next.id;
134
+ return next;
135
+ };
136
+ /**
137
+ * Build the filter fragment that selects records by primary key. Works for
138
+ * single ids, multiple ids, and compound primary keys. Used by
139
+ * `getMany`, `updateMany`, and `deleteMany` to address rows in bulk.
140
+ */
141
+ const getQuery = (primaryKey, ids) => {
142
+ if (Array.isArray(ids) && ids.length > 1) {
143
+ if (isCompoundKey(primaryKey)) return { or: `(${ids.map((id) => {
144
+ const parts = decodeId(id, primaryKey);
145
+ return `and(${primaryKey.map((key, i) => `${key}.eq.${parts[i]}`).join(",")})`;
146
+ }).join(",")})` };
147
+ return { [primaryKey[0]]: `in.(${ids.join(",")})` };
148
+ }
149
+ if (ids !== void 0 && ids !== null) {
150
+ const id = (Array.isArray(ids) ? ids[0] : ids).toString();
151
+ const parts = decodeId(id, primaryKey);
152
+ if (isCompoundKey(primaryKey)) return { and: `(${primaryKey.map((key, i) => `${key}.eq.${parts[i]}`).join(",")})` };
153
+ return { [primaryKey[0]]: `eq.${id}` };
154
+ }
155
+ return {};
156
+ };
157
+ /**
158
+ * Format `field` + `order` (`'ASC' | 'DESC'`) into the `order=...` value
159
+ * understood by `FilterParser`. When the sort field is the synthetic `id` and
160
+ * the resource has a compound primary key, expand it across each key part.
161
+ */
162
+ const getOrderBy = (field, order, primaryKey, sortOrder = "asc,desc") => {
163
+ const direction = sortOrder.split(",")[order === "ASC" ? 0 : 1];
164
+ if (field === "id") return primaryKey.map((key) => `${key}.${direction}`).join(",");
165
+ return `${field}.${direction}`;
166
+ };
167
+ //#endregion
168
+ export { dataWithVirtualId, dataWithoutVirtualId, decodeId, encodeId, getOrderBy, getPrimaryKey, getQuery, parseFilters, removePrimaryKey };
package/package.json ADDED
@@ -0,0 +1,149 @@
1
+ {
2
+ "name": "@aginix/vulcan-data-provider",
3
+ "description": "React Admin data provider for APIs built with @aginix/adonis-vulcan",
4
+ "version": "0.1.0",
5
+ "engines": {
6
+ "node": ">=24.0.0"
7
+ },
8
+ "type": "module",
9
+ "files": [
10
+ "build",
11
+ "!build/bin",
12
+ "!build/tests"
13
+ ],
14
+ "main": "build/index.js",
15
+ "types": "build/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./build/index.d.ts",
19
+ "default": "./build/index.js"
20
+ },
21
+ "./url_builder": {
22
+ "types": "./build/src/url_builder.d.ts",
23
+ "default": "./build/src/url_builder.js"
24
+ },
25
+ "./types": {
26
+ "types": "./build/src/types.d.ts",
27
+ "default": "./build/src/types.js"
28
+ }
29
+ },
30
+ "scripts": {
31
+ "typecheck": "tsc --noEmit",
32
+ "lint": "eslint .",
33
+ "format": "prettier --write .",
34
+ "quick:test": "node --import=@poppinss/ts-exec --enable-source-maps bin/test.ts",
35
+ "pretest": "npm run lint",
36
+ "test": "c8 npm run quick:test",
37
+ "precompile": "npm run lint",
38
+ "compile": "tsdown && tsc --emitDeclarationOnly --declaration",
39
+ "build": "npm run compile",
40
+ "release": "release-it",
41
+ "version": "npm run build",
42
+ "prepublishOnly": "npm run build"
43
+ },
44
+ "keywords": [
45
+ "react-admin",
46
+ "data-provider",
47
+ "adonisjs",
48
+ "adonis",
49
+ "vulcan",
50
+ "postgrest",
51
+ "rest",
52
+ "api"
53
+ ],
54
+ "author": "n3n",
55
+ "license": "UNLICENSED",
56
+ "dependencies": {
57
+ "dequal": "^2.0.3",
58
+ "qs": "^6.14.0"
59
+ },
60
+ "devDependencies": {
61
+ "@adonisjs/eslint-config": "^3.0.0",
62
+ "@adonisjs/prettier-config": "^1.4.5",
63
+ "@adonisjs/tsconfig": "^2.0.0",
64
+ "@japa/assert": "^4.2.0",
65
+ "@japa/runner": "^5.3.0",
66
+ "@poppinss/ts-exec": "^1.4.4",
67
+ "@release-it/conventional-changelog": "^10.0.5",
68
+ "@types/node": "^25.3.5",
69
+ "@types/qs": "^6.9.18",
70
+ "c8": "^11.0.0",
71
+ "eslint": "^10.0.3",
72
+ "prettier": "^3.8.1",
73
+ "ra-core": "^5.0.0",
74
+ "react-admin": "^5.0.0",
75
+ "release-it": "^19.2.4",
76
+ "tsdown": "^0.21.0",
77
+ "typescript": "^5.9.3"
78
+ },
79
+ "peerDependencies": {
80
+ "ra-core": "^5.0.0",
81
+ "react-admin": "^5.0.0"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "react-admin": {
85
+ "optional": true
86
+ }
87
+ },
88
+ "publishConfig": {
89
+ "access": "public"
90
+ },
91
+ "tsdown": {
92
+ "entry": [
93
+ "./index.ts",
94
+ "./src/types.ts",
95
+ "./src/url_builder.ts",
96
+ "./src/data_provider.ts"
97
+ ],
98
+ "outDir": "./build",
99
+ "clean": true,
100
+ "format": "esm",
101
+ "minify": "dce-only",
102
+ "fixedExtension": false,
103
+ "dts": false,
104
+ "treeshake": false,
105
+ "sourcemaps": false,
106
+ "target": "esnext",
107
+ "external": [
108
+ "ra-core",
109
+ "react-admin",
110
+ "qs",
111
+ "dequal"
112
+ ]
113
+ },
114
+ "release-it": {
115
+ "git": {
116
+ "requireCleanWorkingDir": true,
117
+ "requireUpstream": true,
118
+ "commitMessage": "chore(vulcan-data-provider): release ${version}",
119
+ "tagAnnotation": "vulcan-data-provider-v${version}",
120
+ "push": true,
121
+ "tagName": "vulcan-data-provider-v${version}"
122
+ },
123
+ "github": {
124
+ "release": true,
125
+ "releaseName": "@aginix/vulcan-data-provider ${version}"
126
+ },
127
+ "npm": {
128
+ "publish": true,
129
+ "skipChecks": true
130
+ },
131
+ "plugins": {
132
+ "@release-it/conventional-changelog": {
133
+ "preset": {
134
+ "name": "angular"
135
+ }
136
+ }
137
+ }
138
+ },
139
+ "c8": {
140
+ "reporter": [
141
+ "text",
142
+ "html"
143
+ ],
144
+ "exclude": [
145
+ "tests/**"
146
+ ]
147
+ },
148
+ "prettier": "@adonisjs/prettier-config"
149
+ }