@bakery-framework/plugin-db-explorer 2.0.0-alpha.4 → 2.0.0-alpha.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.
Files changed (51) hide show
  1. package/package.json +4 -4
  2. package/src/access.ts +188 -0
  3. package/src/client/api.ts +261 -0
  4. package/src/client/bulk.ts +361 -0
  5. package/src/client/cell.ts +139 -0
  6. package/src/client/confirm.ts +201 -0
  7. package/src/client/csv-commit.ts +185 -0
  8. package/src/client/csv-map.ts +274 -0
  9. package/src/client/csv-model.ts +420 -0
  10. package/src/client/csv-pick.ts +54 -0
  11. package/src/client/csv-preview.ts +91 -0
  12. package/src/client/csv.ts +104 -0
  13. package/src/client/dom.ts +144 -0
  14. package/src/client/edit-session.ts +219 -0
  15. package/src/client/editors.ts +283 -0
  16. package/src/client/filter-builder.ts +198 -0
  17. package/src/client/fk.ts +242 -0
  18. package/src/client/grid-body.ts +103 -0
  19. package/src/client/grid-header.ts +65 -0
  20. package/src/client/grid-rowbar.ts +64 -0
  21. package/src/client/grid.ts +466 -0
  22. package/src/client/meta.ts +188 -0
  23. package/src/client/page.ts +332 -0
  24. package/src/client/panel.ts +296 -0
  25. package/src/client/relations.ts +205 -0
  26. package/src/client/save.ts +209 -0
  27. package/src/client/sidebar.ts +110 -0
  28. package/src/client/state.ts +218 -0
  29. package/src/client/statusbar.ts +130 -0
  30. package/src/client/structure.ts +231 -0
  31. package/src/client/tabs.ts +224 -0
  32. package/src/client/tabstrip.ts +127 -0
  33. package/src/client.ts +374 -160
  34. package/src/endpoints/common.ts +122 -0
  35. package/src/endpoints/graph.ts +0 -0
  36. package/src/endpoints/import.ts +89 -0
  37. package/src/endpoints/read.ts +173 -0
  38. package/src/endpoints/rows.ts +435 -0
  39. package/src/identity.ts +391 -0
  40. package/src/index.ts +42 -42
  41. package/src/policy.ts +45 -0
  42. package/src/preview.ts +53 -0
  43. package/src/setup.ts +63 -80
  44. package/src/shared/coerce.ts +399 -0
  45. package/src/shared/csv.ts +235 -0
  46. package/src/shared/filters.ts +200 -0
  47. package/src/shared/plan.ts +164 -0
  48. package/src/shell.ts +187 -0
  49. package/src/validate.ts +295 -0
  50. package/src/authorize.ts +0 -82
  51. package/src/endpoints.ts +0 -48
@@ -0,0 +1,235 @@
1
+ /**
2
+ * CSV, RFC 4180-ish, with the delimiter sniffed rather than assumed.
3
+ *
4
+ * **Pure** — see the note at the top of `coerce.ts`. The client parses the file
5
+ * the user dropped and the server re-parses whatever it is sent, and they have
6
+ * to agree, so there is one parser.
7
+ *
8
+ * ## Why not `parseCSVRows` from `orm/adapters/base.ts:1071`
9
+ *
10
+ * It was read first, and reusing it was the intent. Four things stop it, and
11
+ * the first two are correctness rather than taste:
12
+ *
13
+ * 1. **A quote anywhere opens a quoted field.** `he said "hi", ok` parses as
14
+ * one field `he said hi, ok` rather than two, because the branch is
15
+ * `if (c === '"') inQuotes = true` with no check that the field is empty.
16
+ * RFC 4180 only gives `"` meaning at the start of a field.
17
+ * 2. **No BOM handling.** A file saved by Excel begins ``, so the first
18
+ * header comes back as `id` and matches no column — the single most
19
+ * common import failure there is.
20
+ * 3. Comma only. A European export is `;`-delimited and a database dump is
21
+ * often tab-delimited; both parse as one column per row and then fail with
22
+ * a message about the header.
23
+ * 4. It returns `string[][]` with a blank final row dropped by a heuristic
24
+ * (`row.length === 1 && row[0] === ''`), which also drops a legitimate
25
+ * single-column row whose value is empty.
26
+ *
27
+ * Fixing it in place would change `importCSV`'s behaviour for every existing
28
+ * caller of the ORM, which is a separate decision from adding an importer to
29
+ * the explorer. This parser is the explorer's; if the ORM's is ever replaced,
30
+ * this is the implementation to move.
31
+ */
32
+
33
+ /** The delimiters `sniffDelimiter` will consider, in preference order. */
34
+ export const CANDIDATE_DELIMITERS = [',', ';', '\t', '|'] as const
35
+
36
+ export type Delimiter = (typeof CANDIDATE_DELIMITERS)[number]
37
+
38
+ export interface CSVTable {
39
+ headers: string[]
40
+ rows: string[][]
41
+ delimiter: string
42
+ /**
43
+ * Rows whose field count differs from the header count, as
44
+ * `{row, fields}` — 1-based over the data rows, so it lines up with what a
45
+ * spreadsheet shows. Ragged rows are still returned in `rows`, padded with
46
+ * `''` or truncated; refusing the whole file for one short line is not the
47
+ * importer's call to make.
48
+ */
49
+ ragged: { row: number; fields: number }[]
50
+ }
51
+
52
+ /** Strip a UTF-8 BOM. Excel writes one; nothing downstream expects it. */
53
+ export function stripBOM(text: string): string {
54
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
55
+ }
56
+
57
+ /**
58
+ * Which delimiter this file uses.
59
+ *
60
+ * Counts occurrences **outside quotes** in the first few lines and takes the
61
+ * candidate whose count is both non-zero and most consistent line to line. A
62
+ * naive `indexOf` count picks `,` for a `;`-delimited file the moment one
63
+ * quoted address contains a comma, which is the common case rather than an
64
+ * exotic one.
65
+ */
66
+ export function sniffDelimiter(text: string): Delimiter {
67
+ const sample = stripBOM(text)
68
+ let best: Delimiter = ','
69
+ let bestScore = -1
70
+
71
+ for (const candidate of CANDIDATE_DELIMITERS) {
72
+ const counts = countPerLine(sample, candidate, 20)
73
+ if (!counts.length || counts[0] === 0) continue
74
+ // Consistency first, frequency second: a delimiter that appears the same
75
+ // number of times on every line is a delimiter; one that appears a varying
76
+ // number of times is text.
77
+ const consistent = counts.every(n => n === counts[0])
78
+ const score = (consistent ? 1000 : 0) + counts[0]!
79
+ if (score > bestScore) {
80
+ bestScore = score
81
+ best = candidate
82
+ }
83
+ }
84
+ return best
85
+ }
86
+
87
+ /** Occurrences of `delimiter` per line, outside quotes, for the first `max` lines. */
88
+ function countPerLine(text: string, delimiter: string, max: number): number[] {
89
+ const counts: number[] = []
90
+ let inQuotes = false
91
+ let count = 0
92
+ let atFieldStart = true
93
+
94
+ for (let i = 0; i < text.length && counts.length < max; i++) {
95
+ const c = text[i]
96
+ if (inQuotes) {
97
+ if (c === '"' && text[i + 1] === '"') i++
98
+ else if (c === '"') inQuotes = false
99
+ continue
100
+ }
101
+ if (c === '"' && atFieldStart) {
102
+ inQuotes = true
103
+ atFieldStart = false
104
+ } else if (c === delimiter) {
105
+ count++
106
+ atFieldStart = true
107
+ } else if (c === '\n') {
108
+ counts.push(count)
109
+ count = 0
110
+ atFieldStart = true
111
+ } else if (c !== '\r') {
112
+ atFieldStart = false
113
+ }
114
+ }
115
+ if (count > 0 || counts.length === 0) counts.push(count)
116
+ return counts
117
+ }
118
+
119
+ /**
120
+ * Rows of fields, with nothing interpreted.
121
+ *
122
+ * No trimming and no type guessing: `"01"` and `01` are both the three-, then
123
+ * two-character strings they look like, and turning either into a number is
124
+ * `coerce.ts`'s job once a column is known. A field is only ever `''` when the
125
+ * file said so — this parser has no concept of NULL.
126
+ */
127
+ export function parseCSVRows(text: string, delimiter = ','): string[][] {
128
+ const csv = stripBOM(text)
129
+ const rows: string[][] = []
130
+ let row: string[] = []
131
+ let field = ''
132
+ let inQuotes = false
133
+ let atFieldStart = true
134
+ /** Did any field of the current row open with a quote? */
135
+ let rowQuoted = false
136
+ /** Has anything at all been read since the last row ended? */
137
+ let pending = false
138
+
139
+ const endField = () => {
140
+ row.push(field)
141
+ field = ''
142
+ atFieldStart = true
143
+ pending = true
144
+ }
145
+
146
+ const endRow = () => {
147
+ endField()
148
+ // A blank line is not a row: it is what a trailing newline, or a stray one
149
+ // in the middle of a file, leaves behind. One field, empty, and never
150
+ // quoted is the only shape it can take — a line reading `""` is a real row
151
+ // holding one empty value, which is why `rowQuoted` is tracked.
152
+ const blank = row.length === 1 && row[0] === '' && !rowQuoted
153
+ if (!blank) rows.push(row)
154
+ row = []
155
+ rowQuoted = false
156
+ pending = false
157
+ }
158
+
159
+ for (let i = 0; i < csv.length; i++) {
160
+ const c = csv[i]
161
+ if (inQuotes) {
162
+ if (c === '"' && csv[i + 1] === '"') {
163
+ field += '"'
164
+ i++
165
+ } else if (c === '"') {
166
+ inQuotes = false
167
+ } else {
168
+ // Includes newlines: a quoted field spans lines, which is the reason
169
+ // a line-splitting "parser" cannot be used for CSV at all.
170
+ field += c
171
+ }
172
+ continue
173
+ }
174
+
175
+ if (c === '"' && atFieldStart) {
176
+ inQuotes = true
177
+ rowQuoted = true
178
+ atFieldStart = false
179
+ pending = true
180
+ } else if (c === delimiter) {
181
+ endField()
182
+ } else if (c === '\n' || c === '\r') {
183
+ if (c === '\r' && csv[i + 1] === '\n') i++
184
+ endRow()
185
+ } else {
186
+ field += c
187
+ atFieldStart = false
188
+ pending = true
189
+ }
190
+ }
191
+
192
+ // Anything still buffered is a final row with no line terminator.
193
+ if (pending) endRow()
194
+
195
+ return rows
196
+ }
197
+
198
+ /**
199
+ * A whole file: headers, data rows squared off against them, and what the
200
+ * delimiter turned out to be.
201
+ */
202
+ export function parseCSV(
203
+ text: string,
204
+ options: { delimiter?: string } = {},
205
+ ): CSVTable {
206
+ const delimiter = options.delimiter ?? sniffDelimiter(text)
207
+ const all = parseCSVRows(text, delimiter)
208
+ if (!all.length) return { headers: [], rows: [], delimiter, ragged: [] }
209
+
210
+ // Headers are the one place trimming is right: a header is a name, and
211
+ // `" id "` naming the column `id` is what every spreadsheet means by it.
212
+ const headers = all[0]!.map(h => h.trim())
213
+ const ragged: { row: number; fields: number }[] = []
214
+ const rows = all.slice(1).map((fields, index) => {
215
+ if (fields.length !== headers.length) {
216
+ ragged.push({ row: index + 1, fields: fields.length })
217
+ }
218
+ const squared = fields.slice(0, headers.length)
219
+ while (squared.length < headers.length) squared.push('')
220
+ return squared
221
+ })
222
+
223
+ return { headers, rows, delimiter, ragged }
224
+ }
225
+
226
+ /** A parsed table as records, keyed by header. */
227
+ export function csvRecords(table: CSVTable): Record<string, string>[] {
228
+ return table.rows.map(fields => {
229
+ const record: Record<string, string> = {}
230
+ for (let i = 0; i < table.headers.length; i++) {
231
+ record[table.headers[i]!] = fields[i] ?? ''
232
+ }
233
+ return record
234
+ })
235
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The filter vocabulary, written once and read by both halves.
3
+ *
4
+ * **Pure, and deliberately in `shared/`** — the client builds filters and the
5
+ * endpoint validates them, and a vocabulary that lived on only one side would
6
+ * drift. Like the rest of `shared/`, this is compiled into the browser bundle,
7
+ * so it imports nothing from `@bakery-framework/*` (see `client/safety.test.ts`).
8
+ *
9
+ * The list mirrors `filterClause` in `packages/orm/src/adapters/base.ts`. That
10
+ * method ends with:
11
+ *
12
+ * > An operator this dialect does not know is dropped rather than guessed at
13
+ * > … the caller validates the vocabulary before it gets here.
14
+ *
15
+ * This is that caller. Validating matters more than it looks: a *dropped*
16
+ * filter widens the result set, so a typo'd operator would silently show more
17
+ * rows than were asked for — and the explorer's Delete acts on a selection made
18
+ * from exactly that view. Failing the request is the only safe direction
19
+ * (convention 2).
20
+ */
21
+
22
+ export type FilterOp =
23
+ | 'eq'
24
+ | 'ne'
25
+ | 'gt'
26
+ | 'gte'
27
+ | 'lt'
28
+ | 'lte'
29
+ | 'contains'
30
+ | 'starts'
31
+ | 'ends'
32
+ | 'null'
33
+ | 'notnull'
34
+
35
+ /** In menu order: equality, then ordering, then text, then the two nullary. */
36
+ export const FILTER_OPS: readonly FilterOp[] = [
37
+ 'eq',
38
+ 'ne',
39
+ 'gt',
40
+ 'gte',
41
+ 'lt',
42
+ 'lte',
43
+ 'contains',
44
+ 'starts',
45
+ 'ends',
46
+ 'null',
47
+ 'notnull',
48
+ ] as const
49
+
50
+ /**
51
+ * The two operators that bind nothing.
52
+ *
53
+ * `IS NULL` has no parameter, which is the whole reason a filter cannot be
54
+ * modelled as a plain column/value pair — the value input is *hidden* for
55
+ * these rather than ignored, because an input whose contents do nothing is a
56
+ * lie about what the query will do.
57
+ */
58
+ const NULLARY: ReadonlySet<string> = new Set<string>(['null', 'notnull'])
59
+
60
+ export function isFilterOp(value: unknown): value is FilterOp {
61
+ return (
62
+ typeof value === 'string' &&
63
+ (FILTER_OPS as readonly string[]).includes(value)
64
+ )
65
+ }
66
+
67
+ export function opTakesValue(op: FilterOp): boolean {
68
+ return !NULLARY.has(op)
69
+ }
70
+
71
+ /** How a chip reads in the UI. Same order as `FILTER_OPS`. */
72
+ export const OP_LABELS: Readonly<Record<FilterOp, string>> = {
73
+ eq: '=',
74
+ ne: '≠',
75
+ gt: '>',
76
+ gte: '≥',
77
+ lt: '<',
78
+ lte: '≤',
79
+ contains: 'contains',
80
+ starts: 'starts with',
81
+ ends: 'ends with',
82
+ null: 'is NULL',
83
+ notnull: 'is not NULL',
84
+ }
85
+
86
+ /** One filter as the user built it. `value` is unused when the op is nullary. */
87
+ export interface Filter {
88
+ column: string
89
+ op: FilterOp
90
+ value: string
91
+ }
92
+
93
+ /** One filter as it crosses the wire. */
94
+ export interface WireFilter {
95
+ op: FilterOp
96
+ value?: string
97
+ }
98
+
99
+ export function filter(
100
+ column: string,
101
+ op: FilterOp = 'eq',
102
+ value = '',
103
+ ): Filter {
104
+ return { column, op, value }
105
+ }
106
+
107
+ /**
108
+ * Filters as `getData` wants them: keyed by column.
109
+ *
110
+ * **The wire shape is a record, so a column can carry only one filter** — the
111
+ * ORM iterates `Object.entries(options.filters)`. Two chips on one column
112
+ * therefore cannot both be sent, and the last one wins rather than the first,
113
+ * because the last is the one the user just touched. `duplicateColumns` exists
114
+ * so the builder can say so out loud instead of quietly dropping half of what
115
+ * is on screen.
116
+ *
117
+ * A value-taking op with an empty value is omitted entirely: that is a chip the
118
+ * user has added but not filled in, and sending `col LIKE '%%'` would match
119
+ * every row while looking like a filter.
120
+ */
121
+ export function toWire(filters: readonly Filter[]): Record<string, WireFilter> {
122
+ const wire: Record<string, WireFilter> = {}
123
+ for (const entry of filters) {
124
+ if (!entry.column) continue
125
+ if (!opTakesValue(entry.op)) {
126
+ wire[entry.column] = { op: entry.op }
127
+ continue
128
+ }
129
+ if (entry.value === '') continue
130
+ wire[entry.column] = { op: entry.op, value: entry.value }
131
+ }
132
+ return wire
133
+ }
134
+
135
+ /** Columns named by more than one filter — the ones the wire cannot carry. */
136
+ export function duplicateColumns(filters: readonly Filter[]): string[] {
137
+ const seen = new Set<string>()
138
+ const twice = new Set<string>()
139
+ for (const entry of filters) {
140
+ if (!entry.column) continue
141
+ if (seen.has(entry.column)) twice.add(entry.column)
142
+ seen.add(entry.column)
143
+ }
144
+ return [...twice]
145
+ }
146
+
147
+ export type ParsedFilters =
148
+ | { ok: true; filters: Record<string, WireFilter | string> }
149
+ | { ok: false; error: string }
150
+
151
+ /**
152
+ * Validate what arrived on the query string.
153
+ *
154
+ * Both forms are accepted, because both are in use: a **bare scalar** means
155
+ * `contains` and is what every caller sent before operators existed, and
156
+ * `{op, value}` is the new one. An unknown operator is an error rather than a
157
+ * dropped clause, for the reason in this module's header.
158
+ */
159
+ export function parseFilters(raw: unknown): ParsedFilters {
160
+ if (raw === null || raw === undefined) return { ok: true, filters: {} }
161
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
162
+ return { ok: false, error: 'filters must be an object' }
163
+ }
164
+
165
+ const filters: Record<string, WireFilter | string> = {}
166
+ for (const [column, value] of Object.entries(
167
+ raw as Record<string, unknown>,
168
+ )) {
169
+ const parsed = parseOne(column, value)
170
+ if ('error' in parsed) return { ok: false, error: parsed.error }
171
+ if (parsed.filter !== null) filters[column] = parsed.filter
172
+ }
173
+ return { ok: true, filters }
174
+ }
175
+
176
+ function parseOne(
177
+ column: string,
178
+ value: unknown,
179
+ ): { filter: WireFilter | string | null } | { error: string } {
180
+ // The pre-operator form. An empty string is "no filter", which is what a
181
+ // cleared text box has always sent.
182
+ if (value === null || typeof value !== 'object') {
183
+ if (value === null || value === undefined || value === '') {
184
+ return { filter: null }
185
+ }
186
+ return { filter: String(value) }
187
+ }
188
+
189
+ const { op, value: operand } = value as { op?: unknown; value?: unknown }
190
+ if (!isFilterOp(op)) {
191
+ return {
192
+ error: `unknown filter operator ${JSON.stringify(op)} on ${column} — expected one of ${FILTER_OPS.join(', ')}`,
193
+ }
194
+ }
195
+ if (!opTakesValue(op)) return { filter: { op } }
196
+ if (operand === null || operand === undefined || operand === '') {
197
+ return { filter: null }
198
+ }
199
+ return { filter: { op, value: String(operand) } }
200
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Turning a spreadsheet into statements: which CSV column feeds which database
3
+ * column, what stops an import before it starts, and what one row edit changes.
4
+ *
5
+ * **Pure** — see the note at the top of `coerce.ts`. The import dialog runs
6
+ * `autoMap` and `blockingIssues` in the browser to show the mapping before
7
+ * anything is sent, and the server runs the same two functions on what arrives,
8
+ * because a client-side check is a convenience and never a guarantee.
9
+ */
10
+
11
+ import { type ColumnMeta, omittableOnInsert, sameValue } from './coerce'
12
+
13
+ /** A column as both sides refer to it: the raw database name, plus its meta. */
14
+ export interface PlanColumn {
15
+ name: string
16
+ meta: ColumnMeta
17
+ }
18
+
19
+ /** CSV header → database column name, or `null` for "do not import". */
20
+ export type ColumnMapping = Record<string, string | null>
21
+
22
+ /**
23
+ * Match headers to columns without asking the user first.
24
+ *
25
+ * Exact name wins, then a case- and separator-insensitive match, so
26
+ * `Courier Name`, `courier_name` and `courierName` all find `courier_name`.
27
+ * Nothing fuzzier: a near-miss that silently loads the wrong column is worse
28
+ * than an unmapped one the dialog can ask about.
29
+ *
30
+ * A database column is claimed at most once. Two headers that normalise to the
31
+ * same name would otherwise both map to it, and the second would quietly win.
32
+ */
33
+ export function autoMap(
34
+ csvHeaders: readonly string[],
35
+ dbColumns: readonly string[],
36
+ ): ColumnMapping {
37
+ const mapping: ColumnMapping = {}
38
+ const claimed = new Set<string>()
39
+
40
+ const byExact = new Map(dbColumns.map(c => [c, c]))
41
+ const byNormal = new Map<string, string>()
42
+ for (const c of dbColumns) {
43
+ const key = normalize(c)
44
+ // First declaration wins, so the mapping does not depend on column order
45
+ // among columns that normalise alike.
46
+ if (!byNormal.has(key)) byNormal.set(key, c)
47
+ }
48
+
49
+ for (const header of csvHeaders) {
50
+ const match = byExact.get(header) ?? byNormal.get(normalize(header)) ?? null
51
+ mapping[header] = match && !claimed.has(match) ? match : null
52
+ if (mapping[header]) claimed.add(match!)
53
+ }
54
+ return mapping
55
+ }
56
+
57
+ function normalize(name: string): string {
58
+ return name.toLowerCase().replace(/[\s_-]+/g, '')
59
+ }
60
+
61
+ export interface PlanIssue {
62
+ column: string
63
+ code: 'required_unmapped' | 'unknown_column' | 'duplicate_target'
64
+ message: string
65
+ }
66
+
67
+ /**
68
+ * What makes this mapping unrunnable, as opposed to merely lossy.
69
+ *
70
+ * A column left out of the mapping is fine when the database can fill it in —
71
+ * an auto-increment key, a default, or a nullable column. A NOT NULL column
72
+ * with no default and no mapping is not: every row would fail at the database,
73
+ * one at a time, after the import had already started.
74
+ */
75
+ export function blockingIssues(
76
+ mapping: ColumnMapping,
77
+ columns: readonly PlanColumn[],
78
+ ): PlanIssue[] {
79
+ const issues: PlanIssue[] = []
80
+ const known = new Set(columns.map(c => c.name))
81
+ const targets = Object.values(mapping).filter(Boolean) as string[]
82
+
83
+ for (const target of new Set(targets)) {
84
+ if (!known.has(target)) {
85
+ issues.push({
86
+ column: target,
87
+ code: 'unknown_column',
88
+ message: `no column named ${target}`,
89
+ })
90
+ }
91
+ if (targets.filter(t => t === target).length > 1) {
92
+ issues.push({
93
+ column: target,
94
+ code: 'duplicate_target',
95
+ message: `two headers are mapped to ${target}`,
96
+ })
97
+ }
98
+ }
99
+
100
+ const mapped = new Set(targets)
101
+ for (const column of columns) {
102
+ if (mapped.has(column.name)) continue
103
+ if (omittableOnInsert(column.meta)) continue
104
+ issues.push({
105
+ column: column.name,
106
+ code: 'required_unmapped',
107
+ message: `${column.name} cannot be null and has no default, so it must be mapped`,
108
+ })
109
+ }
110
+
111
+ return issues
112
+ }
113
+
114
+ export interface UpdatePlanInput {
115
+ /** The row as the editor last saw it, keyed by raw column name. */
116
+ original: Record<string, unknown>
117
+ /** Edited values. **An absent key means unchanged** — see `coerce.ts`. */
118
+ edits: Record<string, unknown>
119
+ /** The identity columns, from `describeIdentity`. */
120
+ identity: readonly string[]
121
+ }
122
+
123
+ export interface UpdatePlanResult {
124
+ /** Columns whose value actually differs, and the value to write. */
125
+ set: Record<string, unknown>
126
+ /** The identity predicate, taken from `original`. */
127
+ where: Record<string, unknown>
128
+ /** Edited keys that matched what was already there. */
129
+ unchanged: string[]
130
+ /** Identity columns `original` does not carry — the plan is unusable. */
131
+ missingIdentity: string[]
132
+ }
133
+
134
+ /**
135
+ * One row edit, as the two halves of an UPDATE.
136
+ *
137
+ * `where` comes from `original` and never from `edits`, which is the point of
138
+ * separating them: editing a primary key has to find the row by its *old* value
139
+ * and set the new one, and a plan built from the edited row would look for a
140
+ * row that does not exist yet.
141
+ *
142
+ * Unchanged columns are dropped from `set`. That is not only economy — a `set`
143
+ * carrying every column makes two people editing different columns of the same
144
+ * row collide, and the whole reason to send a narrow statement is that they
145
+ * should not.
146
+ */
147
+ export function updatePlan(input: UpdatePlanInput): UpdatePlanResult {
148
+ const set: Record<string, unknown> = {}
149
+ const unchanged: string[] = []
150
+
151
+ for (const [column, value] of Object.entries(input.edits)) {
152
+ if (sameValue(input.original[column], value)) unchanged.push(column)
153
+ else set[column] = value
154
+ }
155
+
156
+ const where: Record<string, unknown> = {}
157
+ const missingIdentity: string[] = []
158
+ for (const column of input.identity) {
159
+ if (!(column in input.original)) missingIdentity.push(column)
160
+ else where[column] = input.original[column]
161
+ }
162
+
163
+ return { set, where, unchanged, missingIdentity }
164
+ }