firstly 0.4.0 → 0.4.1
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/CHANGELOG.md +8 -0
- package/esm/core/FF_Allow.d.ts +55 -0
- package/esm/core/FF_Allow.js +54 -0
- package/esm/core/FF_Filter.d.ts +55 -0
- package/esm/core/FF_Filter.js +57 -0
- package/esm/index.d.ts +2 -0
- package/esm/index.js +2 -0
- package/esm/sqlAdmin/Roles_SqlAdmin.d.ts +3 -0
- package/esm/sqlAdmin/Roles_SqlAdmin.js +3 -0
- package/esm/sqlAdmin/SqlAdminController.d.ts +9 -0
- package/esm/sqlAdmin/SqlAdminController.js +23 -0
- package/esm/sqlAdmin/index.d.ts +6 -0
- package/esm/sqlAdmin/index.js +6 -0
- package/esm/sqlAdmin/server/index.d.ts +40 -0
- package/esm/sqlAdmin/server/index.js +40 -0
- package/esm/sqlAdmin/ui/SqlAdmin.svelte +146 -0
- package/esm/sqlAdmin/ui/SqlAdmin.svelte.d.ts +3 -0
- package/package.json +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# firstly
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#250](https://github.com/jycouet/firstly/pull/250) [`53916ad`](https://github.com/jycouet/firstly/commit/53916ad519835a47e3598afb68ce6d84356af944) Thanks [@jycouet](https://github.com/jycouet)! - Add `firstly/sqlAdmin` module: a drop-in `<SqlAdmin />` Svelte component plus a `BackendMethod` controller gated by `Roles_SqlAdmin.SqlAdmin_Admin` (or `FF_Role.FF_Role_Admin`). The component ships with prefilled queries (DB size, table sizes, indexes) and logs results as `for AI: <rows>` to the browser console for chrome-devtools / AI-agent inspection. `sqlAdmin({ path })` logs an AI hint on server start pointing to the page (default `/sql/admin`).
|
|
8
|
+
|
|
9
|
+
Add `FF_Allow` and `FF_Filter` helpers (exported from `firstly`) for owner-only / admin-or-owner row checks and prefilters - usable in `allowApi*` and `apiPrefilter`. Both accept an entity generic (`FF_Allow.owner<Task>('userId')`) for type-safe column names; `col` defaults to `'userId'` if omitted. The `ownerOr<T>({ col?, roles })` variants are shortcuts for the "admin (or any role) OR owner" pattern.
|
|
10
|
+
|
|
3
11
|
## 0.4.0
|
|
4
12
|
|
|
5
13
|
### Minor Changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row-level allow helpers (for `allowApiUpdate`, `allowApiDelete`, ...).
|
|
3
|
+
*
|
|
4
|
+
* Pair with `FF_Filter` (the equivalent for `apiPrefilter`).
|
|
5
|
+
*
|
|
6
|
+
* Pass the entity type as a generic (`FF_Allow.owner<Task>('userId')`) to get
|
|
7
|
+
* autocompletion and type-safety on the column name. Without a generic the
|
|
8
|
+
* column is just a `string`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const FF_Allow: {
|
|
11
|
+
/**
|
|
12
|
+
* Allow only when the row's `col` equals the current user id.
|
|
13
|
+
*
|
|
14
|
+
* `col` defaults to `'userId'` if omitted.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* Owner-only update / delete (typed):
|
|
18
|
+
* ```ts
|
|
19
|
+
* import { FF_Entity, FF_Allow } from '..'
|
|
20
|
+
*
|
|
21
|
+
* \@FF_Entity<Task>('tasks', {
|
|
22
|
+
* allowApiUpdate: FF_Allow.owner<Task>('userId'), // typed: 'userId' must be a key of Task
|
|
23
|
+
* allowApiDelete: FF_Allow.owner<Task>(), // defaults to 'userId'
|
|
24
|
+
* })
|
|
25
|
+
* export class Task { ... }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* For "admin OR owner", prefer `FF_Allow.ownerOr` instead of inlining the
|
|
29
|
+
* combination yourself.
|
|
30
|
+
*/
|
|
31
|
+
owner: <T>(col?: keyof T & string) => (entity?: T) => boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Allow when the current user has any of the given `roles`, OR when the
|
|
34
|
+
* row's `col` equals the current user id.
|
|
35
|
+
*
|
|
36
|
+
* `col` defaults to `'userId'` if omitted.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* Admin OR owner (typed):
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { FF_Entity, FF_Allow } from '..'
|
|
42
|
+
* import { Roles } from '../roles'
|
|
43
|
+
*
|
|
44
|
+
* \@FF_Entity<Task>('tasks', {
|
|
45
|
+
* allowApiUpdate: FF_Allow.ownerOr<Task>({ roles: [Roles.Admin] }),
|
|
46
|
+
* allowApiDelete: FF_Allow.ownerOr<Task>({ col: 'createdBy', roles: [Roles.Admin] }),
|
|
47
|
+
* })
|
|
48
|
+
* export class Task { ... }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
ownerOr: <T>({ col, roles }: {
|
|
52
|
+
col?: keyof T & string;
|
|
53
|
+
roles: string[];
|
|
54
|
+
}) => (entity?: T) => boolean;
|
|
55
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { remult } from 'remult';
|
|
2
|
+
/**
|
|
3
|
+
* Row-level allow helpers (for `allowApiUpdate`, `allowApiDelete`, ...).
|
|
4
|
+
*
|
|
5
|
+
* Pair with `FF_Filter` (the equivalent for `apiPrefilter`).
|
|
6
|
+
*
|
|
7
|
+
* Pass the entity type as a generic (`FF_Allow.owner<Task>('userId')`) to get
|
|
8
|
+
* autocompletion and type-safety on the column name. Without a generic the
|
|
9
|
+
* column is just a `string`.
|
|
10
|
+
*/
|
|
11
|
+
export const FF_Allow = {
|
|
12
|
+
/**
|
|
13
|
+
* Allow only when the row's `col` equals the current user id.
|
|
14
|
+
*
|
|
15
|
+
* `col` defaults to `'userId'` if omitted.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* Owner-only update / delete (typed):
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { FF_Entity, FF_Allow } from '..'
|
|
21
|
+
*
|
|
22
|
+
* \@FF_Entity<Task>('tasks', {
|
|
23
|
+
* allowApiUpdate: FF_Allow.owner<Task>('userId'), // typed: 'userId' must be a key of Task
|
|
24
|
+
* allowApiDelete: FF_Allow.owner<Task>(), // defaults to 'userId'
|
|
25
|
+
* })
|
|
26
|
+
* export class Task { ... }
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* For "admin OR owner", prefer `FF_Allow.ownerOr` instead of inlining the
|
|
30
|
+
* combination yourself.
|
|
31
|
+
*/
|
|
32
|
+
owner: (col = 'userId') => (entity) => !!remult.user?.id && entity?.[col] === remult.user.id,
|
|
33
|
+
/**
|
|
34
|
+
* Allow when the current user has any of the given `roles`, OR when the
|
|
35
|
+
* row's `col` equals the current user id.
|
|
36
|
+
*
|
|
37
|
+
* `col` defaults to `'userId'` if omitted.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* Admin OR owner (typed):
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { FF_Entity, FF_Allow } from '..'
|
|
43
|
+
* import { Roles } from '../roles'
|
|
44
|
+
*
|
|
45
|
+
* \@FF_Entity<Task>('tasks', {
|
|
46
|
+
* allowApiUpdate: FF_Allow.ownerOr<Task>({ roles: [Roles.Admin] }),
|
|
47
|
+
* allowApiDelete: FF_Allow.ownerOr<Task>({ col: 'createdBy', roles: [Roles.Admin] }),
|
|
48
|
+
* })
|
|
49
|
+
* export class Task { ... }
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
ownerOr: ({ col = 'userId', roles }) => (entity) => roles.some((r) => remult.isAllowed(r)) ||
|
|
53
|
+
(!!remult.user?.id && entity?.[col] === remult.user.id),
|
|
54
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefilter helpers (for `apiPrefilter`, `backendPrefilter`).
|
|
3
|
+
*
|
|
4
|
+
* Pair with `FF_Allow` (the equivalent for `allowApi*` row checks).
|
|
5
|
+
*
|
|
6
|
+
* Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
|
|
7
|
+
* autocompletion and type-safety on the column name. Without a generic the
|
|
8
|
+
* column is just a `string`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const FF_Filter: {
|
|
11
|
+
/**
|
|
12
|
+
* Prefilter rows where `col` equals the current user id.
|
|
13
|
+
*
|
|
14
|
+
* `col` defaults to `'userId'` if omitted. When anonymous, yields
|
|
15
|
+
* `IN (NULL)` which matches nothing.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* Owner-only prefilter (typed):
|
|
19
|
+
* ```ts
|
|
20
|
+
* import { FF_Entity, FF_Filter } from '..'
|
|
21
|
+
*
|
|
22
|
+
* \@FF_Entity<Task>('tasks', {
|
|
23
|
+
* apiPrefilter: () => FF_Filter.owner<Task>('userId'),
|
|
24
|
+
* })
|
|
25
|
+
* export class Task { ... }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* For "admin sees all, others see only their own", prefer
|
|
29
|
+
* `FF_Filter.ownerOr` instead of inlining the combination yourself.
|
|
30
|
+
*/
|
|
31
|
+
owner: <T>(col?: keyof T & string) => any;
|
|
32
|
+
/**
|
|
33
|
+
* Prefilter that returns `{}` (no filter) when the current user has any of
|
|
34
|
+
* the given `roles`, otherwise restricts to rows where `col` equals the
|
|
35
|
+
* current user id.
|
|
36
|
+
*
|
|
37
|
+
* `col` defaults to `'userId'` if omitted.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* Admin sees all, others only their own (typed):
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { FF_Entity, FF_Filter } from '..'
|
|
43
|
+
* import { Roles } from '../roles'
|
|
44
|
+
*
|
|
45
|
+
* \@FF_Entity<Task>('tasks', {
|
|
46
|
+
* apiPrefilter: () => FF_Filter.ownerOr<Task>({ roles: [Roles.Admin] }),
|
|
47
|
+
* })
|
|
48
|
+
* export class Task { ... }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
ownerOr: <T>({ col, roles, }: {
|
|
52
|
+
col?: keyof T & string;
|
|
53
|
+
roles: string[];
|
|
54
|
+
}) => any;
|
|
55
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { remult } from 'remult';
|
|
2
|
+
/**
|
|
3
|
+
* Prefilter helpers (for `apiPrefilter`, `backendPrefilter`).
|
|
4
|
+
*
|
|
5
|
+
* Pair with `FF_Allow` (the equivalent for `allowApi*` row checks).
|
|
6
|
+
*
|
|
7
|
+
* Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
|
|
8
|
+
* autocompletion and type-safety on the column name. Without a generic the
|
|
9
|
+
* column is just a `string`.
|
|
10
|
+
*/
|
|
11
|
+
export const FF_Filter = {
|
|
12
|
+
/**
|
|
13
|
+
* Prefilter rows where `col` equals the current user id.
|
|
14
|
+
*
|
|
15
|
+
* `col` defaults to `'userId'` if omitted. When anonymous, yields
|
|
16
|
+
* `IN (NULL)` which matches nothing.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* Owner-only prefilter (typed):
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { FF_Entity, FF_Filter } from '..'
|
|
22
|
+
*
|
|
23
|
+
* \@FF_Entity<Task>('tasks', {
|
|
24
|
+
* apiPrefilter: () => FF_Filter.owner<Task>('userId'),
|
|
25
|
+
* })
|
|
26
|
+
* export class Task { ... }
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* For "admin sees all, others see only their own", prefer
|
|
30
|
+
* `FF_Filter.ownerOr` instead of inlining the combination yourself.
|
|
31
|
+
*/
|
|
32
|
+
owner: (col = 'userId') => ({ [col]: [remult.user?.id] }),
|
|
33
|
+
/**
|
|
34
|
+
* Prefilter that returns `{}` (no filter) when the current user has any of
|
|
35
|
+
* the given `roles`, otherwise restricts to rows where `col` equals the
|
|
36
|
+
* current user id.
|
|
37
|
+
*
|
|
38
|
+
* `col` defaults to `'userId'` if omitted.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* Admin sees all, others only their own (typed):
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { FF_Entity, FF_Filter } from '..'
|
|
44
|
+
* import { Roles } from '../roles'
|
|
45
|
+
*
|
|
46
|
+
* \@FF_Entity<Task>('tasks', {
|
|
47
|
+
* apiPrefilter: () => FF_Filter.ownerOr<Task>({ roles: [Roles.Admin] }),
|
|
48
|
+
* })
|
|
49
|
+
* export class Task { ... }
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
ownerOr: ({ col = 'userId', roles, }) => {
|
|
53
|
+
if (roles.some((r) => remult.isAllowed(r)))
|
|
54
|
+
return {};
|
|
55
|
+
return { [col]: [remult.user?.id] };
|
|
56
|
+
},
|
|
57
|
+
};
|
package/esm/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export { BaseEnum } from './core/BaseEnum.js';
|
|
|
7
7
|
export type { BaseEnumOptions, BaseItem, BaseItemLight, FF_Icon } from './core/BaseEnum.js';
|
|
8
8
|
export { FF_Entity } from './core/FF_Entity.js';
|
|
9
9
|
export { FF_Role } from './core/common.js';
|
|
10
|
+
export { FF_Allow } from './core/FF_Allow.js';
|
|
11
|
+
export { FF_Filter } from './core/FF_Filter.js';
|
|
10
12
|
export { isError } from './core/helper.js';
|
|
11
13
|
export { tryCatch, tryCatchSync } from './core/tryCatch.js';
|
|
12
14
|
export type { ResolvedType, UnArray, RecursivePartial } from './core/types.js';
|
package/esm/index.js
CHANGED
|
@@ -9,6 +9,8 @@ export const ff_Log = new h.Log('firstly');
|
|
|
9
9
|
export { BaseEnum } from './core/BaseEnum.js';
|
|
10
10
|
export { FF_Entity } from './core/FF_Entity.js';
|
|
11
11
|
export { FF_Role } from './core/common.js';
|
|
12
|
+
export { FF_Allow } from './core/FF_Allow.js';
|
|
13
|
+
export { FF_Filter } from './core/FF_Filter.js';
|
|
12
14
|
export { isError } from './core/helper.js';
|
|
13
15
|
export { tryCatch, tryCatchSync } from './core/tryCatch.js';
|
|
14
16
|
export { tw } from './core/tailwind.js';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { SqlDatabase } from 'remult';
|
|
2
|
+
export declare class SqlAdminController {
|
|
3
|
+
/** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
|
|
4
|
+
static dp?: SqlDatabase;
|
|
5
|
+
static exec(cmd: string): Promise<{
|
|
6
|
+
r: import("remult").SqlResult;
|
|
7
|
+
took: number;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
import { BackendMethod, SqlDatabase } from 'remult';
|
|
8
|
+
import { FF_Role } from '../core/common';
|
|
9
|
+
import { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
10
|
+
export class SqlAdminController {
|
|
11
|
+
/** Optional override set by the `sqlAdmin()` module's `initApi`. Falls back to `SqlDatabase.getDb()`. */
|
|
12
|
+
static dp;
|
|
13
|
+
static async exec(cmd) {
|
|
14
|
+
const db = SqlAdminController.dp ?? SqlDatabase.getDb();
|
|
15
|
+
const start = performance.now();
|
|
16
|
+
const r = await db.execute(cmd);
|
|
17
|
+
const took = performance.now() - start;
|
|
18
|
+
return { r, took };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
__decorate([
|
|
22
|
+
BackendMethod({ allowed: [Roles_SqlAdmin.SqlAdmin_Admin, FF_Role.FF_Role_Admin] })
|
|
23
|
+
], SqlAdminController, "exec", null);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Log } from '@kitql/helpers';
|
|
2
|
+
export { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
3
|
+
export { SqlAdminController } from './SqlAdminController';
|
|
4
|
+
export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
|
|
5
|
+
export declare const key = "sqlAdmin";
|
|
6
|
+
export declare const log: Log;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Log } from '@kitql/helpers';
|
|
2
|
+
export { Roles_SqlAdmin } from './Roles_SqlAdmin';
|
|
3
|
+
export { SqlAdminController } from './SqlAdminController';
|
|
4
|
+
export { default as SqlAdmin } from './ui/SqlAdmin.svelte';
|
|
5
|
+
export const key = 'sqlAdmin';
|
|
6
|
+
export const log = new Log(key);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { SqlDatabase } from 'remult';
|
|
2
|
+
import { Module } from 'remult/server';
|
|
3
|
+
export type SqlAdminOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Override the SqlDatabase used to execute queries.
|
|
6
|
+
* Defaults to `SqlDatabase.getDb()` (the active Remult data provider).
|
|
7
|
+
*/
|
|
8
|
+
dp?: () => SqlDatabase | Promise<SqlDatabase>;
|
|
9
|
+
/**
|
|
10
|
+
* The route where you mounted the `<SqlAdmin />` component.
|
|
11
|
+
* Used only for the AI hint logged on server start.
|
|
12
|
+
*
|
|
13
|
+
* @default '/sql/admin'
|
|
14
|
+
*/
|
|
15
|
+
path?: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Drop-in SQL admin endpoint + companion `<SqlAdmin />` component (`firstly/sqlAdmin`).
|
|
19
|
+
*
|
|
20
|
+
* Gated by `Roles_SqlAdmin.SqlAdmin_Admin` (or the global `FF_Role.FF_Role_Admin`).
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { remultApi } from 'remult/remult-sveltekit'
|
|
25
|
+
* import { sqlAdmin } from './'
|
|
26
|
+
*
|
|
27
|
+
* export const api = remultApi({
|
|
28
|
+
* modules: [sqlAdmin()],
|
|
29
|
+
* })
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* Then on any admin route:
|
|
33
|
+
* ```svelte
|
|
34
|
+
* <script>
|
|
35
|
+
* import { SqlAdmin } from '..'
|
|
36
|
+
* </script>
|
|
37
|
+
* <SqlAdmin />
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export declare const sqlAdmin: (opts?: SqlAdminOptions) => Module<unknown>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Module } from 'remult/server';
|
|
2
|
+
import { yellow } from '@kitql/helpers';
|
|
3
|
+
import { log } from '..';
|
|
4
|
+
import { SqlAdminController } from '../SqlAdminController';
|
|
5
|
+
/**
|
|
6
|
+
* Drop-in SQL admin endpoint + companion `<SqlAdmin />` component (`firstly/sqlAdmin`).
|
|
7
|
+
*
|
|
8
|
+
* Gated by `Roles_SqlAdmin.SqlAdmin_Admin` (or the global `FF_Role.FF_Role_Admin`).
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { remultApi } from 'remult/remult-sveltekit'
|
|
13
|
+
* import { sqlAdmin } from './'
|
|
14
|
+
*
|
|
15
|
+
* export const api = remultApi({
|
|
16
|
+
* modules: [sqlAdmin()],
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Then on any admin route:
|
|
21
|
+
* ```svelte
|
|
22
|
+
* <script>
|
|
23
|
+
* import { SqlAdmin } from '..'
|
|
24
|
+
* </script>
|
|
25
|
+
* <SqlAdmin />
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export const sqlAdmin = (opts) => {
|
|
29
|
+
const path = opts?.path ?? '/sql/admin';
|
|
30
|
+
return new Module({
|
|
31
|
+
key: 'sqlAdmin',
|
|
32
|
+
controllers: [SqlAdminController],
|
|
33
|
+
initApi: async () => {
|
|
34
|
+
if (opts?.dp) {
|
|
35
|
+
SqlAdminController.dp = await opts.dp();
|
|
36
|
+
}
|
|
37
|
+
log.info(`AI Hint: visit ${yellow(path)} to query raw SQL.`);
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SQL Admin UI.
|
|
4
|
+
*
|
|
5
|
+
* Results are logged to the browser console as `for AI: <json rows>` after
|
|
6
|
+
* each successful query - chrome-devtools / AI agents inspecting the page
|
|
7
|
+
* can read them with `list_console_messages`.
|
|
8
|
+
*/
|
|
9
|
+
import { SqlAdminController } from '../SqlAdminController'
|
|
10
|
+
|
|
11
|
+
const defaultQuery = `SELECT *
|
|
12
|
+
FROM "public"."users"
|
|
13
|
+
LIMIT 10`
|
|
14
|
+
|
|
15
|
+
let sqlInput = $state(defaultQuery)
|
|
16
|
+
let result: any = $state()
|
|
17
|
+
let error = $state('')
|
|
18
|
+
let isLoading = $state(false)
|
|
19
|
+
|
|
20
|
+
const queries = {
|
|
21
|
+
default: { label: 'Default', sql: defaultQuery },
|
|
22
|
+
tables: {
|
|
23
|
+
label: 'Tables & Sizes',
|
|
24
|
+
sql: `SELECT
|
|
25
|
+
table_schema,
|
|
26
|
+
table_name,
|
|
27
|
+
pg_size_pretty(pg_total_relation_size(quote_ident(table_schema) || '.' || quote_ident(table_name))) as total_size,
|
|
28
|
+
pg_size_pretty(pg_table_size(quote_ident(table_schema) || '.' || quote_ident(table_name))) as data_size,
|
|
29
|
+
pg_size_pretty(pg_indexes_size(quote_ident(table_schema) || '.' || quote_ident(table_name))) as index_size
|
|
30
|
+
FROM information_schema.tables
|
|
31
|
+
WHERE table_schema IN ('public', 'ff_auth')
|
|
32
|
+
ORDER BY pg_total_relation_size(quote_ident(table_schema) || '.' || quote_ident(table_name)) DESC;`,
|
|
33
|
+
},
|
|
34
|
+
indexes: {
|
|
35
|
+
label: 'Indexes',
|
|
36
|
+
sql: `SELECT *
|
|
37
|
+
FROM pg_indexes
|
|
38
|
+
WHERE schemaname = 'public'
|
|
39
|
+
ORDER BY tablename, indexname`,
|
|
40
|
+
},
|
|
41
|
+
dbSize: {
|
|
42
|
+
label: 'Database Size',
|
|
43
|
+
sql: `SELECT
|
|
44
|
+
current_database() as database_name,
|
|
45
|
+
pg_size_pretty(pg_database_size(current_database())) as database_size`,
|
|
46
|
+
},
|
|
47
|
+
} as const
|
|
48
|
+
|
|
49
|
+
console.info(
|
|
50
|
+
'[firstly/SqlAdmin] AI Hint: results are also logged as "for AI:" JSON after each query.',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
function setPresetQuery(queryId: keyof typeof queries) {
|
|
54
|
+
sqlInput = queries[queryId].sql
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function handleSubmit(e: Event) {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
try {
|
|
60
|
+
error = ''
|
|
61
|
+
isLoading = true
|
|
62
|
+
result = { ...(await SqlAdminController.exec(sqlInput)) }
|
|
63
|
+
console.info('for AI:', JSON.stringify(result.r.rows))
|
|
64
|
+
console.info('for humans:', result)
|
|
65
|
+
} catch (e) {
|
|
66
|
+
error = JSON.stringify(e, null, 2)
|
|
67
|
+
} finally {
|
|
68
|
+
isLoading = false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getHeaders(rows: any[]): string[] {
|
|
73
|
+
if (!rows || rows.length === 0) return []
|
|
74
|
+
return Object.keys(rows[0])
|
|
75
|
+
}
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<div class="flex flex-col gap-4">
|
|
79
|
+
<div class="flex flex-col gap-2">
|
|
80
|
+
<h2 class="text-2xl font-bold">SQL Admin</h2>
|
|
81
|
+
<p class="text-sm text-base-content/70">
|
|
82
|
+
Execute SQL queries directly on the database. Results are displayed below and also logged to the
|
|
83
|
+
browser console.
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
<div class="flex flex-wrap gap-2">
|
|
87
|
+
{#each Object.entries(queries) as [id, query] (id)}
|
|
88
|
+
<button class="btn btn-outline btn-sm" onclick={() => setPresetQuery(id as keyof typeof queries)}
|
|
89
|
+
>{query.label}</button
|
|
90
|
+
>
|
|
91
|
+
{/each}
|
|
92
|
+
</div>
|
|
93
|
+
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
|
|
94
|
+
<fieldset class="fieldset">
|
|
95
|
+
<textarea
|
|
96
|
+
bind:value={sqlInput}
|
|
97
|
+
class="textarea h-52 w-full font-mono"
|
|
98
|
+
placeholder="Enter SQL command..."
|
|
99
|
+
disabled={isLoading}
|
|
100
|
+
></textarea>
|
|
101
|
+
</fieldset>
|
|
102
|
+
<div class="flex items-center gap-4">
|
|
103
|
+
<button type="submit" class="btn btn-primary" disabled={isLoading}>
|
|
104
|
+
{#if isLoading}<span class="loading loading-spinner"></span>{/if}
|
|
105
|
+
Execute SQL
|
|
106
|
+
</button>
|
|
107
|
+
{#if error}<pre class="alert flex-1 text-sm alert-error">{error.replaceAll(
|
|
108
|
+
'\\n',
|
|
109
|
+
'\n',
|
|
110
|
+
)}</pre>{/if}
|
|
111
|
+
{#if result}
|
|
112
|
+
<div class="alert flex flex-1 justify-between alert-success">
|
|
113
|
+
<span class="text-success-content">{result.took.toFixed(0)} ms</span>
|
|
114
|
+
<span class="text-success-content">{result.r.rowCount} rows</span>
|
|
115
|
+
</div>
|
|
116
|
+
{/if}
|
|
117
|
+
</div>
|
|
118
|
+
</form>
|
|
119
|
+
{#if result}
|
|
120
|
+
<div class="max-h-[600px] overflow-auto rounded-lg border border-base-300">
|
|
121
|
+
{#if result.r.rows && result.r.rows.length > 0}
|
|
122
|
+
<table class="table w-full table-zebra bg-base-200">
|
|
123
|
+
<thead class="sticky top-0 z-10 bg-base-300">
|
|
124
|
+
<tr
|
|
125
|
+
>{#each getHeaders(result.r.rows) as header, i (i)}<th>{header}</th>{/each}</tr
|
|
126
|
+
>
|
|
127
|
+
</thead>
|
|
128
|
+
<tbody>
|
|
129
|
+
{#each result.r.rows as row, r (r)}
|
|
130
|
+
<tr>
|
|
131
|
+
{#each Object.values(row) as cell, c (c)}
|
|
132
|
+
<td class="align-top">
|
|
133
|
+
{#if typeof cell === 'object'}<pre class="text-xs">{JSON.stringify(cell, null, 2)}</pre>
|
|
134
|
+
{:else}{cell === null ? 'null' : cell}{/if}
|
|
135
|
+
</td>
|
|
136
|
+
{/each}
|
|
137
|
+
</tr>
|
|
138
|
+
{/each}
|
|
139
|
+
</tbody>
|
|
140
|
+
</table>
|
|
141
|
+
{:else}
|
|
142
|
+
<div class="alert alert-info">No rows returned</div>
|
|
143
|
+
{/if}
|
|
144
|
+
</div>
|
|
145
|
+
{/if}
|
|
146
|
+
</div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "firstly",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Firstly, an opinionated Remult setup!",
|
|
6
6
|
"funding": "https://github.com/sponsors/jycouet",
|
|
@@ -98,6 +98,15 @@
|
|
|
98
98
|
"./carbone/server": {
|
|
99
99
|
"types": "./esm/carbone/server/index.d.ts",
|
|
100
100
|
"default": "./esm/carbone/server/index.js"
|
|
101
|
+
},
|
|
102
|
+
"./sqlAdmin": {
|
|
103
|
+
"types": "./esm/sqlAdmin/index.d.ts",
|
|
104
|
+
"svelte": "./esm/sqlAdmin/index.js",
|
|
105
|
+
"default": "./esm/sqlAdmin/index.js"
|
|
106
|
+
},
|
|
107
|
+
"./sqlAdmin/server": {
|
|
108
|
+
"types": "./esm/sqlAdmin/server/index.d.ts",
|
|
109
|
+
"default": "./esm/sqlAdmin/server/index.js"
|
|
101
110
|
}
|
|
102
111
|
},
|
|
103
112
|
"keywords": [
|