@bakery-framework/plugin-db-explorer 2.0.0-alpha.11
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 +42 -0
- package/src/access.ts +188 -0
- package/src/client/api.ts +279 -0
- package/src/client/bulk.ts +357 -0
- package/src/client/cell.ts +139 -0
- package/src/client/confirm.ts +203 -0
- package/src/client/csv-commit.ts +185 -0
- package/src/client/csv-map.ts +274 -0
- package/src/client/csv-model.ts +420 -0
- package/src/client/csv-pick.ts +54 -0
- package/src/client/csv-preview.ts +89 -0
- package/src/client/csv.ts +104 -0
- package/src/client/dom.ts +164 -0
- package/src/client/edit-session.ts +219 -0
- package/src/client/editors.ts +283 -0
- package/src/client/filter-builder.ts +198 -0
- package/src/client/fk.ts +269 -0
- package/src/client/grid-body.ts +106 -0
- package/src/client/grid-header.ts +65 -0
- package/src/client/grid-rowbar.ts +64 -0
- package/src/client/grid.ts +468 -0
- package/src/client/meta.ts +185 -0
- package/src/client/page.ts +332 -0
- package/src/client/panel.ts +296 -0
- package/src/client/relations.ts +205 -0
- package/src/client/save.ts +205 -0
- package/src/client/sidebar.ts +110 -0
- package/src/client/state.ts +218 -0
- package/src/client/statusbar.ts +130 -0
- package/src/client/structure.ts +234 -0
- package/src/client/tabs.ts +219 -0
- package/src/client/tabstrip.ts +127 -0
- package/src/client.ts +409 -0
- package/src/endpoints/common.ts +122 -0
- package/src/endpoints/graph.ts +148 -0
- package/src/endpoints/import.ts +89 -0
- package/src/endpoints/read.ts +175 -0
- package/src/endpoints/rows.ts +435 -0
- package/src/identity.ts +401 -0
- package/src/index.ts +68 -0
- package/src/policy.ts +45 -0
- package/src/preview.ts +53 -0
- package/src/setup.ts +147 -0
- package/src/shared/coerce.ts +399 -0
- package/src/shared/csv.ts +186 -0
- package/src/shared/filters.ts +200 -0
- package/src/shared/plan.ts +173 -0
- package/src/shell.ts +187 -0
- package/src/validate.ts +295 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bakery-framework/plugin-db-explorer",
|
|
3
|
+
"version": "2.0.0-alpha.11",
|
|
4
|
+
"description": "Bakery database explorer plugin — browse and edit rows — no raw SQL, no DDL.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bakery",
|
|
7
|
+
"bun",
|
|
8
|
+
"database",
|
|
9
|
+
"explorer",
|
|
10
|
+
"plugin"
|
|
11
|
+
],
|
|
12
|
+
"author": "obillekyle",
|
|
13
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/obillekyle/bakery.git",
|
|
17
|
+
"directory": "packages/plugins/db-explorer"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://bakery.okyle.dev",
|
|
20
|
+
"bugs": "https://github.com/obillekyle/bakery/issues",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./src/index.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"!src/**/*.test.ts",
|
|
33
|
+
"!src/tests"
|
|
34
|
+
],
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@bakery-framework/core": "^2.0.0-alpha.11",
|
|
37
|
+
"@bakery-framework/orm": "^2.0.0-alpha.11"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"bun": ">=1.4.0"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/access.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import {
|
|
3
|
+
credentialMatches,
|
|
4
|
+
readCredential,
|
|
5
|
+
} from '@bakery-framework/core/utils/http'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Who may do what in the explorer.
|
|
9
|
+
*
|
|
10
|
+
* The explorer used to be read-only by construction, so a boolean answered the
|
|
11
|
+
* whole question. It edits rows now, and "may this request in" and "may this
|
|
12
|
+
* request *write*" are different questions — so the predicate returns a level
|
|
13
|
+
* rather than a yes.
|
|
14
|
+
*
|
|
15
|
+
* Two doors, and an application may use either or both:
|
|
16
|
+
*
|
|
17
|
+
* - `users` — named credentials, for people and scripts that have no session
|
|
18
|
+
* (an on-call engineer with a key, a seeding job).
|
|
19
|
+
* - `authorize` — a predicate over the request, for applications that already
|
|
20
|
+
* know who their users are and would rather not keep a second list.
|
|
21
|
+
*
|
|
22
|
+
* Either can admit and the **higher** level wins, because they answer about the
|
|
23
|
+
* same caller: a session admin presenting a read-only key is still an admin.
|
|
24
|
+
*
|
|
25
|
+
* Everything fails closed. Nothing configured admits nobody — the same default
|
|
26
|
+
* the read-only explorer had, and the reason there is no `writes: true` flag to
|
|
27
|
+
* leave set by accident.
|
|
28
|
+
*
|
|
29
|
+
* This deliberately does not reuse core's `isAuthorized`, which is boolean and
|
|
30
|
+
* stays correct for the dashboard and analytics. It copies its discipline
|
|
31
|
+
* instead: an exact match, never a truthy one, and a throw is a denial.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** What a caller may do. Ordered: `write` implies `read`. */
|
|
35
|
+
export type Access = 'read' | 'write'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* An application's own access check. Return `'write'`, `'read'`, or `false`.
|
|
39
|
+
*
|
|
40
|
+
* Anything else — including `true` — is a denial. The predicate is application
|
|
41
|
+
* code and this return type is only advice; an untyped or transpiled one can
|
|
42
|
+
* hand back anything, and admission is the expensive direction to get wrong.
|
|
43
|
+
*/
|
|
44
|
+
export type AccessFn = (
|
|
45
|
+
req: Request,
|
|
46
|
+
) => Access | false | Promise<Access | false>
|
|
47
|
+
|
|
48
|
+
export interface ExplorerUser {
|
|
49
|
+
/** Presented as `x-db-key`, a Bearer token, or `?db-key=` on a read. */
|
|
50
|
+
credential: string
|
|
51
|
+
access: Access
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Named because a log line saying which key was used beats one saying "a key". */
|
|
55
|
+
export type ExplorerUsers = Record<string, ExplorerUser>
|
|
56
|
+
|
|
57
|
+
const DB_KEY = 'db-key'
|
|
58
|
+
|
|
59
|
+
/** Methods that cannot change state, and may therefore carry a URL credential. */
|
|
60
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
61
|
+
|
|
62
|
+
const RANK: Record<Access, number> = { read: 1, write: 2 }
|
|
63
|
+
|
|
64
|
+
/** The higher of two grants, or whichever is present. */
|
|
65
|
+
function higher(a: Access | false, b: Access | false): Access | false {
|
|
66
|
+
if (!a) return b
|
|
67
|
+
if (!b) return a
|
|
68
|
+
return RANK[a] >= RANK[b] ? a : b
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The credential this request presents, or `null`.
|
|
73
|
+
*
|
|
74
|
+
* Core's `readCredential` accepts three forms — `x-db-key`, a Bearer token, and
|
|
75
|
+
* `?db-key=` — and the query form is deliberate there: a human opening a URL
|
|
76
|
+
* cannot set a header.
|
|
77
|
+
*
|
|
78
|
+
* **On a state-changing request the query form is refused.** A credential in a
|
|
79
|
+
* URL is what makes a cross-site write possible: the browser will send it
|
|
80
|
+
* because it is in the link, and `checkCsrf` — which is an `Origin` check, not
|
|
81
|
+
* a token — passes when `Origin` is absent or literally `"null"`, as it is from
|
|
82
|
+
* a sandboxed iframe or some redirect chains. Requiring a header for writes
|
|
83
|
+
* means the caller had to run script on this origin.
|
|
84
|
+
*/
|
|
85
|
+
function presentedKey(req: Request): string | null {
|
|
86
|
+
const presented = readCredential(req, DB_KEY)
|
|
87
|
+
if (presented === null) return null
|
|
88
|
+
if (SAFE_METHODS.has(req.method)) return presented
|
|
89
|
+
|
|
90
|
+
// `readCredential` prefers header, then Bearer, then the query — so if
|
|
91
|
+
// neither header is present, the value it returned came from the URL.
|
|
92
|
+
const fromHeader =
|
|
93
|
+
req.headers.has(`x-${DB_KEY}`) || req.headers.has('authorization')
|
|
94
|
+
return fromHeader ? presented : null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The best level any configured user grants this request.
|
|
99
|
+
*
|
|
100
|
+
* **Every entry is compared, with no early exit.** Each comparison is constant
|
|
101
|
+
* time (core's `credentialMatches`), and stopping at the first match would make
|
|
102
|
+
* the response time depend on where in the map the matching key sits — which
|
|
103
|
+
* leaks the position of a valid key over enough samples.
|
|
104
|
+
*/
|
|
105
|
+
export function accessFromUsers(
|
|
106
|
+
req: Request,
|
|
107
|
+
users: ExplorerUsers | undefined,
|
|
108
|
+
): Access | false {
|
|
109
|
+
if (!users) return false
|
|
110
|
+
|
|
111
|
+
const presented = presentedKey(req)
|
|
112
|
+
if (!presented) return false
|
|
113
|
+
|
|
114
|
+
let granted: Access | false = false
|
|
115
|
+
for (const user of Object.values(users)) {
|
|
116
|
+
if (credentialMatches(user.credential, presented)) {
|
|
117
|
+
granted = higher(granted, user.access)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return granted
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The predicate's answer, with anything that is not an exact level denied. */
|
|
124
|
+
export async function accessFromPredicate(
|
|
125
|
+
req: Request,
|
|
126
|
+
authorize: AccessFn | undefined,
|
|
127
|
+
): Promise<Access | false> {
|
|
128
|
+
if (!authorize) return false
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const answer = await authorize(req)
|
|
132
|
+
// `=== `, never truthiness. `true` is a denial here on purpose: a predicate
|
|
133
|
+
// written against the old boolean API means "let them in" and cannot mean
|
|
134
|
+
// "let them write", and guessing which is exactly the mistake this type
|
|
135
|
+
// exists to prevent.
|
|
136
|
+
return answer === 'write' || answer === 'read' ? answer : false
|
|
137
|
+
} catch {
|
|
138
|
+
// An access check that errors is indeterminate, and indeterminate is a
|
|
139
|
+
// denial (convention 2).
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface AccessConfig {
|
|
145
|
+
users?: ExplorerUsers
|
|
146
|
+
authorize?: AccessFn
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The caller's level: the better of the two doors, `false` if neither admits. */
|
|
150
|
+
export async function resolveAccess(
|
|
151
|
+
req: Request,
|
|
152
|
+
config: AccessConfig,
|
|
153
|
+
): Promise<Access | false> {
|
|
154
|
+
// Both doors are consulted even when the first admits — the predicate may
|
|
155
|
+
// grant `write` where a key granted `read`, and the caller is one person.
|
|
156
|
+
const fromKey = accessFromUsers(req, config.users)
|
|
157
|
+
const fromPredicate = await accessFromPredicate(req, config.authorize)
|
|
158
|
+
return higher(fromKey, fromPredicate)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether `access` permits a write. Spelled once so no call site guesses. */
|
|
162
|
+
export function canWrite(access: Access | false): boolean {
|
|
163
|
+
return access === 'write'
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The current request's access level, for endpoints.
|
|
168
|
+
*
|
|
169
|
+
* `AsyncLocalStorage` rather than a module variable, for the same reason
|
|
170
|
+
* `hostStore` is one in core: a module variable is shared by every in-flight
|
|
171
|
+
* request, so under any concurrency at all one caller's level would decide
|
|
172
|
+
* another caller's write. It is also cheaper than re-resolving per endpoint,
|
|
173
|
+
* which would run an application's `authorize` predicate — possibly a session
|
|
174
|
+
* lookup — twice for one request.
|
|
175
|
+
*
|
|
176
|
+
* Outside a request it is `false`, which is the safe answer rather than a
|
|
177
|
+
* crash: an endpoint reached some other way has no caller to grant anything.
|
|
178
|
+
*/
|
|
179
|
+
export const accessStore = new AsyncLocalStorage<Access>()
|
|
180
|
+
|
|
181
|
+
export function currentAccess(): Access | false {
|
|
182
|
+
return accessStore.getStore() ?? false
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether the *current* request may write. The check every write endpoint makes. */
|
|
186
|
+
export function currentCanWrite(): boolean {
|
|
187
|
+
return canWrite(currentAccess())
|
|
188
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every request this client makes, and nothing that renders.
|
|
3
|
+
*
|
|
4
|
+
* The rule that keeps the grid's complexity down is mechanical: **no function
|
|
5
|
+
* both fetches and renders.** These return data or throw `ApiError`; the DOM
|
|
6
|
+
* modules take data and return nodes. The old `renderTable` did both and scored
|
|
7
|
+
* 34 against a maximum of 25 with a fraction of this feature set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { toWire } from '../shared/filters'
|
|
11
|
+
import type { SchemaGraph, SchemaReport, TablePage } from './meta'
|
|
12
|
+
import type { ViewState } from './state'
|
|
13
|
+
import { PAGE_SIZE } from './state'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A failed call, with the server's own envelope attached.
|
|
17
|
+
*
|
|
18
|
+
* `data` matters as much as the status: a 409 from `PATCH /api/_db/row` carries
|
|
19
|
+
* the row as it now stands, which is what makes *Keep mine / Take theirs*
|
|
20
|
+
* possible rather than "please try again".
|
|
21
|
+
*/
|
|
22
|
+
export class ApiError extends Error {
|
|
23
|
+
constructor(
|
|
24
|
+
message: string,
|
|
25
|
+
readonly status: number,
|
|
26
|
+
readonly data: unknown,
|
|
27
|
+
) {
|
|
28
|
+
super(message)
|
|
29
|
+
this.name = 'ApiError'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What to show a user when a call threw.
|
|
35
|
+
*
|
|
36
|
+
* Here rather than in `dom.ts` because the thing it knows about is `ApiError`,
|
|
37
|
+
* whose `message` is the server's own sentence from the envelope — the reason
|
|
38
|
+
* a failed write reads as "row was modified by someone else" instead of
|
|
39
|
+
* "Request failed (409)". `String(error)` is the fallback for the two throws
|
|
40
|
+
* that are not ours: an abort and a network failure.
|
|
41
|
+
*
|
|
42
|
+
* There used to be three of these — one exported from `bulk.ts`, a private
|
|
43
|
+
* near-copy in `save.ts`, and a third inlined at the `notify` call in
|
|
44
|
+
* `csv-commit.ts` — so improving the wording in one left the other two alone.
|
|
45
|
+
*/
|
|
46
|
+
export function messageOf(error: unknown): string {
|
|
47
|
+
const api = error as Partial<ApiError>
|
|
48
|
+
return api?.message ?? String(error)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const KEY_STORAGE = '__db_key'
|
|
52
|
+
const KEY_PARAM = 'db-key'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Take a `?db-key=` out of the address bar and keep it for the session.
|
|
56
|
+
*
|
|
57
|
+
* A human opening a link cannot set a header, so the credential arrives in the
|
|
58
|
+
* URL; leaving it there puts it in history, in the referrer of every outbound
|
|
59
|
+
* link, and in a screenshot. It is moved to `sessionStorage` and the URL is
|
|
60
|
+
* rewritten before anything else runs.
|
|
61
|
+
*/
|
|
62
|
+
export function adoptUrlKey(): void {
|
|
63
|
+
const url = new URL(location.href)
|
|
64
|
+
const key = url.searchParams.get(KEY_PARAM)
|
|
65
|
+
if (!key) return
|
|
66
|
+
sessionStorage.setItem(KEY_STORAGE, key)
|
|
67
|
+
url.searchParams.delete(KEY_PARAM)
|
|
68
|
+
history.replaceState(null, '', url)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The credential as a **header**.
|
|
73
|
+
*
|
|
74
|
+
* Never as a query parameter on a write: `access.ts` refuses a URL credential
|
|
75
|
+
* on any non-safe method, because a link is something a cross-site page can
|
|
76
|
+
* make the browser follow and `checkCsrf` passes when `Origin` is absent.
|
|
77
|
+
*/
|
|
78
|
+
function keyHeaders(): Record<string, string> {
|
|
79
|
+
const key = sessionStorage.getItem(KEY_STORAGE)
|
|
80
|
+
return key ? { 'x-db-key': key } : {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface Envelope {
|
|
84
|
+
status?: number
|
|
85
|
+
message?: string
|
|
86
|
+
data?: unknown
|
|
87
|
+
/** Milliseconds the server spent, filled in by `router.ts`. */
|
|
88
|
+
time?: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The data, plus what the server said it cost.
|
|
93
|
+
*
|
|
94
|
+
* `time` is the envelope's own field — `router.ts` fills it from `getElapsed`,
|
|
95
|
+
* so it is the server's measurement of its own work rather than a round trip
|
|
96
|
+
* timed in the browser. The status bar shows it, which is the one honest number
|
|
97
|
+
* available for "why is this slow": a filter that cannot use an index shows up
|
|
98
|
+
* here immediately.
|
|
99
|
+
*/
|
|
100
|
+
interface Timed<T> {
|
|
101
|
+
data: T
|
|
102
|
+
ms: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function unwrapEnvelope<T>(res: Response): Promise<Timed<T>> {
|
|
106
|
+
const body = (await res.json().catch(() => null)) as Envelope | null
|
|
107
|
+
const status = body?.status ?? res.status
|
|
108
|
+
if (!body || status < 200 || status >= 300) {
|
|
109
|
+
throw new ApiError(
|
|
110
|
+
body?.message || `Request failed (${status})`,
|
|
111
|
+
status,
|
|
112
|
+
body?.data,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
return { data: body.data as T, ms: body.time ?? 0 }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function unwrap<T>(res: Response): Promise<T> {
|
|
119
|
+
return (await unwrapEnvelope<T>(res)).data
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function apiGet<T>(
|
|
123
|
+
path: string,
|
|
124
|
+
params?: Record<string, string>,
|
|
125
|
+
signal?: AbortSignal,
|
|
126
|
+
): Promise<T> {
|
|
127
|
+
const query = params ? `?${new URLSearchParams(params)}` : ''
|
|
128
|
+
const res = await fetch(`/api/_db/${path}${query}`, {
|
|
129
|
+
headers: keyHeaders(),
|
|
130
|
+
signal,
|
|
131
|
+
})
|
|
132
|
+
return await unwrap<T>(res)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function apiSend<T>(
|
|
136
|
+
method: 'POST' | 'PATCH' | 'DELETE',
|
|
137
|
+
path: string,
|
|
138
|
+
body: unknown,
|
|
139
|
+
signal?: AbortSignal,
|
|
140
|
+
): Promise<T> {
|
|
141
|
+
const res = await fetch(`/api/_db/${path}`, {
|
|
142
|
+
method,
|
|
143
|
+
headers: { ...keyHeaders(), 'content-type': 'application/json' },
|
|
144
|
+
body: JSON.stringify(body),
|
|
145
|
+
signal,
|
|
146
|
+
})
|
|
147
|
+
return await unwrap<T>(res)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function fetchSchema(): Promise<SchemaReport> {
|
|
151
|
+
return await apiGet<SchemaReport>('schema')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function fetchGraph(): Promise<SchemaGraph> {
|
|
155
|
+
return await apiGet<SchemaGraph>('graph')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One page of a table, with the server's own timing.
|
|
160
|
+
*
|
|
161
|
+
* **`filters` carries an operator now.** It used to be a bare column→substring
|
|
162
|
+
* record, which meant `col LIKE '%value%'` and nothing else — so a filter could
|
|
163
|
+
* narrow a page but could never *name* a row, `1` matching `11` and `21`. With
|
|
164
|
+
* `eq` in the vocabulary it can, which is what removed `ViewState.focus` and
|
|
165
|
+
* turned a foreign-key jump into an ordinary filter.
|
|
166
|
+
*
|
|
167
|
+
* The scalar form is still what the endpoint accepts from anyone else; `toWire`
|
|
168
|
+
* always sends the object form from here.
|
|
169
|
+
*/
|
|
170
|
+
export async function fetchPage(view: ViewState): Promise<Timed<TablePage>> {
|
|
171
|
+
const params: Record<string, string> = {
|
|
172
|
+
tableName: view.table,
|
|
173
|
+
page: String(view.page),
|
|
174
|
+
pageSize: String(PAGE_SIZE),
|
|
175
|
+
sortOrder: view.sortOrder,
|
|
176
|
+
}
|
|
177
|
+
if (view.sortBy) params.sortBy = view.sortBy
|
|
178
|
+
const wire = toWire(view.filters)
|
|
179
|
+
if (Object.keys(wire).length) params.filters = JSON.stringify(wire)
|
|
180
|
+
|
|
181
|
+
const query = `?${new URLSearchParams(params)}`
|
|
182
|
+
const res = await fetch(`/api/_db/table-data${query}`, {
|
|
183
|
+
headers: keyHeaders(),
|
|
184
|
+
})
|
|
185
|
+
return await unwrapEnvelope<TablePage>(res)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface LookupRef {
|
|
189
|
+
table: string
|
|
190
|
+
key: Record<string, unknown>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface LookupResult {
|
|
194
|
+
table: string
|
|
195
|
+
key: Record<string, unknown>
|
|
196
|
+
row: Record<string, unknown> | null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Bounded at 200 by `policy.ts`; `fk.ts` never sends more than a page's worth. */
|
|
200
|
+
export async function lookupRefs(
|
|
201
|
+
refs: LookupRef[],
|
|
202
|
+
signal?: AbortSignal,
|
|
203
|
+
): Promise<LookupResult[]> {
|
|
204
|
+
const data = await apiSend<{ rows: LookupResult[] }>(
|
|
205
|
+
'POST',
|
|
206
|
+
'lookup',
|
|
207
|
+
{ refs },
|
|
208
|
+
signal,
|
|
209
|
+
)
|
|
210
|
+
return data.rows ?? []
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface UpdateResult {
|
|
214
|
+
changed: number
|
|
215
|
+
row: Record<string, unknown> | null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function patchRow(payload: {
|
|
219
|
+
table: string
|
|
220
|
+
key: Record<string, unknown>
|
|
221
|
+
set: Record<string, unknown>
|
|
222
|
+
expect: Record<string, unknown>
|
|
223
|
+
force?: boolean
|
|
224
|
+
}): Promise<UpdateResult> {
|
|
225
|
+
return await apiSend<UpdateResult>('PATCH', 'row', payload)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
interface BulkResult {
|
|
229
|
+
changed: number
|
|
230
|
+
conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function bulkEdit(payload: {
|
|
234
|
+
table: string
|
|
235
|
+
edits: { key: Record<string, unknown>; set: Record<string, unknown> }[]
|
|
236
|
+
dryRun?: boolean
|
|
237
|
+
}): Promise<BulkResult> {
|
|
238
|
+
return await apiSend<BulkResult>('POST', 'rows/bulk', payload)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface DeleteResult {
|
|
242
|
+
deleted: number
|
|
243
|
+
conflicts: { index: number; key: Record<string, unknown>; reason: string }[]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function deleteRows(payload: {
|
|
247
|
+
table: string
|
|
248
|
+
keys: Record<string, unknown>[]
|
|
249
|
+
dryRun?: boolean
|
|
250
|
+
}): Promise<DeleteResult> {
|
|
251
|
+
return await apiSend<DeleteResult>('DELETE', 'rows', payload)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface InsertResult {
|
|
255
|
+
inserted: number
|
|
256
|
+
rows?: Record<string, unknown>[]
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function insertRows(payload: {
|
|
260
|
+
table: string
|
|
261
|
+
rows: Record<string, unknown>[]
|
|
262
|
+
}): Promise<InsertResult> {
|
|
263
|
+
return await apiSend<InsertResult>('POST', 'rows', payload)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface ImportResult {
|
|
267
|
+
inserted: number
|
|
268
|
+
skipped: number
|
|
269
|
+
errors: { row: number; column: string; code: string; message: string }[]
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export async function importRows(payload: {
|
|
273
|
+
table: string
|
|
274
|
+
rows: Record<string, unknown>[]
|
|
275
|
+
onBadRow: 'stop' | 'skip'
|
|
276
|
+
dryRun?: boolean
|
|
277
|
+
}): Promise<ImportResult> {
|
|
278
|
+
return await apiSend<ImportResult>('POST', 'import', payload)
|
|
279
|
+
}
|