@crombolo/data 0.0.0-stage → 0.9.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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +31 -4
  4. package/src/change-report.ts +166 -0
  5. package/src/cluster.ts +108 -0
  6. package/src/database.ts +121 -0
  7. package/src/dialect.ts +128 -0
  8. package/src/index.ts +60 -0
  9. package/src/locks.ts +103 -0
  10. package/src/maintenance.ts +51 -0
  11. package/src/migrations.ts +431 -0
  12. package/src/node-ref.ts +107 -0
  13. package/src/pending.ts +25 -0
  14. package/src/repositories/bundles.ts +191 -0
  15. package/src/repositories/content-types.ts +831 -0
  16. package/src/repositories/data-types.ts +222 -0
  17. package/src/repositories/dictionary.ts +259 -0
  18. package/src/repositories/documents.ts +2389 -0
  19. package/src/repositories/domains.ts +108 -0
  20. package/src/repositories/folders.ts +120 -0
  21. package/src/repositories/form-entries.ts +314 -0
  22. package/src/repositories/form-workflows.ts +213 -0
  23. package/src/repositories/languages.ts +108 -0
  24. package/src/repositories/log-searches.ts +33 -0
  25. package/src/repositories/members.ts +427 -0
  26. package/src/repositories/nodes.ts +361 -0
  27. package/src/repositories/notifications.ts +54 -0
  28. package/src/repositories/public-access.ts +187 -0
  29. package/src/repositories/redirects.ts +274 -0
  30. package/src/repositories/references.ts +162 -0
  31. package/src/repositories/tags.ts +93 -0
  32. package/src/repositories/templates.ts +114 -0
  33. package/src/repositories/transfer-runs.ts +190 -0
  34. package/src/repositories/user-data.ts +90 -0
  35. package/src/repositories/users.ts +898 -0
  36. package/src/schema/assistant.ts +74 -0
  37. package/src/schema/built-in-content-types.ts +106 -0
  38. package/src/schema/built-in-media-types.ts +335 -0
  39. package/src/schema/built-in-member-types.ts +61 -0
  40. package/src/schema/bundle-components.ts +49 -0
  41. package/src/schema/bundles-rename.ts +32 -0
  42. package/src/schema/change-report.ts +46 -0
  43. package/src/schema/client-id-prefix.ts +43 -0
  44. package/src/schema/content-editing.ts +50 -0
  45. package/src/schema/content-seed.ts +586 -0
  46. package/src/schema/content.ts +263 -0
  47. package/src/schema/created-packages.ts +56 -0
  48. package/src/schema/elements.ts +62 -0
  49. package/src/schema/form-permission-prefix.ts +50 -0
  50. package/src/schema/form-permissions.ts +26 -0
  51. package/src/schema/form-workflows.ts +54 -0
  52. package/src/schema/forms.ts +63 -0
  53. package/src/schema/group-permissions.ts +20 -0
  54. package/src/schema/identity.ts +176 -0
  55. package/src/schema/log-viewer.ts +68 -0
  56. package/src/schema/member-property-type.ts +30 -0
  57. package/src/schema/members.ts +97 -0
  58. package/src/schema/plan.ts +73 -0
  59. package/src/schema/public-access.ts +56 -0
  60. package/src/schema/recycle-bin.ts +21 -0
  61. package/src/schema/redirects.ts +57 -0
  62. package/src/schema/rich-text-ui.ts +34 -0
  63. package/src/schema/seed.ts +298 -0
  64. package/src/schema/server-role.ts +21 -0
  65. package/src/schema/since-version.ts +26 -0
  66. package/src/schema/transfer-runs.ts +71 -0
  67. package/src/schema/upgrade-report.ts +43 -0
  68. package/src/schema/users-localisation.ts +85 -0
  69. package/src/schema/values.ts +108 -0
  70. package/src/schema-ops.ts +35 -0
  71. package/src/schema-state.ts +196 -0
  72. package/README.md +0 -3
@@ -0,0 +1,222 @@
1
+ /** Data types: the property editor plus its configuration. */
2
+ import {
3
+ type DataTypeModel,
4
+ normaliseUuid,
5
+ ObjectTypes,
6
+ type Page,
7
+ SystemNodes,
8
+ } from '@crombolo/core'
9
+ import type { Db } from '../database.ts'
10
+ import { NodeRepository } from './nodes.ts'
11
+
12
+ function parseValues(config: unknown): Array<{ alias: string; value: unknown }> {
13
+ if (typeof config !== 'string' || config.length === 0) return []
14
+ try {
15
+ const parsed = JSON.parse(config) as unknown
16
+ if (Array.isArray(parsed)) return parsed as Array<{ alias: string; value: unknown }>
17
+ if (parsed && typeof parsed === 'object') {
18
+ return Object.entries(parsed as Record<string, unknown>).map(([alias, value]) => ({
19
+ alias,
20
+ value,
21
+ }))
22
+ }
23
+ } catch {
24
+ // A malformed configuration should not make the data type unopenable.
25
+ }
26
+ return []
27
+ }
28
+
29
+ export class DataTypeRepository {
30
+ #db: Db
31
+ #nodes: NodeRepository
32
+
33
+ constructor(db: Db) {
34
+ this.#db = db
35
+ this.#nodes = new NodeRepository(db)
36
+ }
37
+
38
+ get nodes(): NodeRepository {
39
+ return this.#nodes
40
+ }
41
+
42
+ async byKey(key: string): Promise<DataTypeModel | undefined> {
43
+ const rows = await this.#db.query(
44
+ `SELECT n.unique_id, n.text, n.parent_id, d.alias, d.editor_alias, d.editor_ui_alias, d.db_type, d.config
45
+ FROM data_type d JOIN node n ON n.id = d.node_id
46
+ WHERE n.unique_id = ?`,
47
+ [normaliseUuid(key)],
48
+ )
49
+ const row = rows[0]
50
+ if (!row) return undefined
51
+ const parent =
52
+ Number(row.parent_id) > 0 ? await this.#nodes.byId(Number(row.parent_id)) : undefined
53
+ return {
54
+ key: normaliseUuid(String(row.unique_id)),
55
+ alias: (row.alias as string | null) ?? null,
56
+ name: String(row.text ?? ''),
57
+ editorAlias: String(row.editor_alias),
58
+ editorUiAlias: (row.editor_ui_alias as string | null) ?? null,
59
+ dbType: String(row.db_type),
60
+ values: parseValues(row.config),
61
+ parentKey: parent?.key ?? null,
62
+ }
63
+ }
64
+
65
+ async byAlias(alias: string): Promise<DataTypeModel | undefined> {
66
+ const rows = await this.#db.query<{ unique_id: string }>(
67
+ 'SELECT n.unique_id FROM data_type d JOIN node n ON n.id = d.node_id WHERE d.alias = ?',
68
+ [alias],
69
+ )
70
+ return rows[0] ? this.byKey(String(rows[0].unique_id)) : undefined
71
+ }
72
+
73
+ async byKeys(keys: readonly string[]): Promise<DataTypeModel[]> {
74
+ const found: DataTypeModel[] = []
75
+ for (const key of keys) {
76
+ const dataType = await this.byKey(key)
77
+ if (dataType) found.push(dataType)
78
+ }
79
+ return found
80
+ }
81
+
82
+ /** The storage type, which decides which `property_data` column a value uses. */
83
+ async storageTypeByPropertyTypeId(propertyTypeId: number): Promise<string | undefined> {
84
+ const rows = await this.#db.query<{ db_type: string }>(
85
+ `SELECT d.db_type FROM property_type p JOIN data_type d ON d.node_id = p.data_type_id
86
+ WHERE p.id = ?`,
87
+ [propertyTypeId],
88
+ )
89
+ return rows[0] ? String(rows[0].db_type) : undefined
90
+ }
91
+
92
+ async list(skip: number, take: number): Promise<Page<DataTypeModel>> {
93
+ const page = await this.#nodes.children(SystemNodes.Root, ObjectTypes.DataType, skip, take)
94
+ const items: DataTypeModel[] = []
95
+ for (const node of page.items) {
96
+ const dataType = await this.byKey(node.key)
97
+ if (dataType) items.push(dataType)
98
+ }
99
+ return { total: page.total, items }
100
+ }
101
+
102
+ async all(): Promise<DataTypeModel[]> {
103
+ const rows = await this.#db.query<{ unique_id: string }>(
104
+ 'SELECT n.unique_id FROM data_type d JOIN node n ON n.id = d.node_id ORDER BY n.text',
105
+ )
106
+ return this.byKeys(rows.map((r) => String(r.unique_id)))
107
+ }
108
+
109
+ /** The editor picker's filter: by name fragment and editor aliases. */
110
+ async filter(
111
+ criteria: { name?: string; editorAlias?: string; editorUiAlias?: string },
112
+ paging: { skip: number; take: number },
113
+ ): Promise<{ total: number; items: DataTypeModel[] }> {
114
+ const name = criteria.name?.toLowerCase()
115
+ const items = (await this.all()).filter(
116
+ (d) =>
117
+ (!name || d.name.toLowerCase().includes(name)) &&
118
+ (!criteria.editorAlias || d.editorAlias === criteria.editorAlias) &&
119
+ (!criteria.editorUiAlias || d.editorUiAlias === criteria.editorUiAlias),
120
+ )
121
+ return { total: items.length, items: items.slice(paging.skip, paging.skip + paging.take) }
122
+ }
123
+
124
+ /** Property types using this data type, with the type each belongs to. */
125
+ async referencedBy(key: string): Promise<
126
+ Array<{
127
+ propertyKey: string
128
+ alias: string
129
+ name: string
130
+ contentType: { key: string; alias: string; name: string; icon: string }
131
+ }>
132
+ > {
133
+ const node = await this.#nodes.byKey(key)
134
+ if (!node) return []
135
+ const rows = await this.#db.query(
136
+ `SELECT p.unique_id, p.alias, p.name, ctn.unique_id AS type_key, ct.alias AS type_alias, ctn.text AS type_name, ct.icon
137
+ FROM property_type p
138
+ JOIN content_type ct ON ct.node_id = p.content_type_id
139
+ JOIN node ctn ON ctn.id = ct.node_id
140
+ WHERE p.data_type_id = ? AND p.retired_at IS NULL AND ct.retired_at IS NULL
141
+ ORDER BY ctn.text, p.sort_order`,
142
+ [node.id],
143
+ )
144
+ return rows.map((row) => ({
145
+ propertyKey: normaliseUuid(String(row.unique_id)),
146
+ alias: String(row.alias),
147
+ name: String(row.name),
148
+ contentType: {
149
+ key: normaliseUuid(String(row.type_key)),
150
+ alias: String(row.type_alias),
151
+ name: String(row.type_name ?? ''),
152
+ icon: String(row.icon ?? 'icon-document'),
153
+ },
154
+ }))
155
+ }
156
+
157
+ async move(key: string, parentKey: string | null): Promise<boolean> {
158
+ const node = await this.#nodes.byKey(key)
159
+ if (!node || node.objectType !== ObjectTypes.DataType) return false
160
+ const parentId = parentKey ? (await this.#nodes.byKey(parentKey))?.id : SystemNodes.Root
161
+ if (parentId === undefined) return false
162
+ await this.#nodes.move(node.id, parentId)
163
+ return true
164
+ }
165
+
166
+ async copy(key: string, parentKey: string | null): Promise<DataTypeModel | undefined> {
167
+ const source = await this.byKey(key)
168
+ if (!source) return undefined
169
+ return this.save({
170
+ ...source,
171
+ key: crypto.randomUUID(),
172
+ alias: null,
173
+ name: `${source.name} (copy)`,
174
+ parentKey,
175
+ })
176
+ }
177
+
178
+ async save(model: DataTypeModel, userId?: number): Promise<DataTypeModel> {
179
+ return this.#db.transaction(async (tx) => {
180
+ const repo = new DataTypeRepository(tx)
181
+ const existing = await repo.nodes.byKey(model.key)
182
+ const config = JSON.stringify(
183
+ Object.fromEntries(model.values.map((value) => [value.alias, value.value])),
184
+ )
185
+ if (existing) {
186
+ await repo.nodes.rename(existing.id, model.name)
187
+ await tx.exec(
188
+ 'UPDATE data_type SET alias = ?, editor_alias = ?, editor_ui_alias = ?, db_type = ?, config = ? WHERE node_id = ?',
189
+ [model.alias, model.editorAlias, model.editorUiAlias, model.dbType, config, existing.id],
190
+ )
191
+ } else {
192
+ const parentId = model.parentKey
193
+ ? ((await repo.nodes.byKey(model.parentKey))?.id ?? SystemNodes.Root)
194
+ : SystemNodes.Root
195
+ const node = await repo.nodes.create({
196
+ key: model.key,
197
+ parentId,
198
+ objectType: ObjectTypes.DataType,
199
+ text: model.name,
200
+ userId,
201
+ })
202
+ await tx.exec(
203
+ 'INSERT INTO data_type (node_id, alias, editor_alias, editor_ui_alias, db_type, config) VALUES (?, ?, ?, ?, ?, ?)',
204
+ [node.id, model.alias, model.editorAlias, model.editorUiAlias, model.dbType, config],
205
+ )
206
+ }
207
+ const saved = await repo.byKey(model.key)
208
+ if (!saved) throw new Error('Data type could not be read back after saving.')
209
+ return saved
210
+ })
211
+ }
212
+
213
+ async delete(key: string): Promise<boolean> {
214
+ const node = await this.#nodes.byKey(key)
215
+ if (!node || node.objectType !== ObjectTypes.DataType) return false
216
+ return this.#db.transaction(async (tx) => {
217
+ await tx.exec('DELETE FROM data_type WHERE node_id = ?', [node.id])
218
+ await tx.exec('DELETE FROM node WHERE id = ?', [node.id])
219
+ return true
220
+ })
221
+ }
222
+ }
@@ -0,0 +1,259 @@
1
+ /** Dictionary items: a tree of keys, each with a translation per language. */
2
+ import { normaliseUuid } from '@crombolo/core'
3
+ import type { Db } from '../database.ts'
4
+
5
+ export interface DictionaryTranslation {
6
+ isoCode: string
7
+ translation: string
8
+ }
9
+
10
+ export interface DictionaryItem {
11
+ key: string
12
+ name: string
13
+ parentKey: string | null
14
+ translations: DictionaryTranslation[]
15
+ }
16
+
17
+ export type DictionaryWriteResult =
18
+ | 'Success'
19
+ | 'DuplicateItemKey'
20
+ | 'DuplicateKey'
21
+ | 'ParentNotFound'
22
+ | 'NotFound'
23
+ | 'InvalidParent'
24
+ | 'InvalidLanguage'
25
+
26
+ interface Row {
27
+ key: string
28
+ item_key: string
29
+ parent_key: string | null
30
+ }
31
+
32
+ export class DictionaryRepository {
33
+ #db: Db
34
+
35
+ constructor(db: Db) {
36
+ this.#db = db
37
+ }
38
+
39
+ async #rows(where = '', params: unknown[] = []): Promise<Row[]> {
40
+ return this.#db.query<Row>(
41
+ `SELECT key, item_key, parent_key FROM dictionary_item ${where} ORDER BY item_key`,
42
+ params,
43
+ )
44
+ }
45
+
46
+ async #withTranslations(rows: Row[]): Promise<DictionaryItem[]> {
47
+ if (rows.length === 0) return []
48
+ const keys = rows.map((r) => normaliseUuid(String(r.key)))
49
+ const texts = await this.#db.query<{ dictionary_key: string; iso_code: string; value: string }>(
50
+ `SELECT t.dictionary_key, l.iso_code, t.value FROM dictionary_text t
51
+ JOIN language l ON l.id = t.language_id
52
+ WHERE t.dictionary_key IN (${keys.map(() => '?').join(', ')})
53
+ ORDER BY l.iso_code`,
54
+ keys,
55
+ )
56
+ return rows.map((row) => {
57
+ const key = normaliseUuid(String(row.key))
58
+ return {
59
+ key,
60
+ name: String(row.item_key),
61
+ parentKey: row.parent_key ? normaliseUuid(String(row.parent_key)) : null,
62
+ translations: texts
63
+ .filter((t) => normaliseUuid(String(t.dictionary_key)) === key)
64
+ .map((t) => ({ isoCode: String(t.iso_code), translation: String(t.value) })),
65
+ }
66
+ })
67
+ }
68
+
69
+ async get(key: string): Promise<DictionaryItem | undefined> {
70
+ return (
71
+ await this.#withTranslations(await this.#rows('WHERE key = ?', [normaliseUuid(key)]))
72
+ )[0]
73
+ }
74
+
75
+ async byName(name: string): Promise<DictionaryItem | undefined> {
76
+ return (await this.#withTranslations(await this.#rows('WHERE item_key = ?', [name])))[0]
77
+ }
78
+
79
+ async many(keys: readonly string[]): Promise<DictionaryItem[]> {
80
+ const wanted = keys.filter((k) => /^[0-9a-f-]{32,36}$/i.test(k)).map(normaliseUuid)
81
+ if (wanted.length === 0) return []
82
+ return this.#withTranslations(
83
+ await this.#rows(`WHERE key IN (${wanted.map(() => '?').join(', ')})`, wanted),
84
+ )
85
+ }
86
+
87
+ /** Every item, or those whose key contains `filter`, in tree order by key. */
88
+ async all(filter?: string): Promise<DictionaryItem[]> {
89
+ const rows = filter
90
+ ? await this.#rows('WHERE LOWER(item_key) LIKE ?', [`%${filter.toLowerCase()}%`])
91
+ : await this.#rows()
92
+ return this.#withTranslations(rows)
93
+ }
94
+
95
+ async children(
96
+ parentKey: string | null,
97
+ ): Promise<Array<{ key: string; name: string; parentKey: string | null; hasChildren: boolean }>> {
98
+ const rows = parentKey
99
+ ? await this.#rows('WHERE parent_key = ?', [normaliseUuid(parentKey)])
100
+ : await this.#rows('WHERE parent_key IS NULL')
101
+ const parents = new Set(
102
+ (
103
+ await this.#db.query<{ parent_key: string }>(
104
+ 'SELECT DISTINCT parent_key FROM dictionary_item WHERE parent_key IS NOT NULL',
105
+ )
106
+ ).map((r) => normaliseUuid(String(r.parent_key))),
107
+ )
108
+ return rows.map((row) => {
109
+ const key = normaliseUuid(String(row.key))
110
+ return {
111
+ key,
112
+ name: String(row.item_key),
113
+ parentKey: row.parent_key ? normaliseUuid(String(row.parent_key)) : null,
114
+ hasChildren: parents.has(key),
115
+ }
116
+ })
117
+ }
118
+
119
+ /** The item and its ancestors, root first. */
120
+ async ancestry(key: string): Promise<DictionaryItem[]> {
121
+ const chain: DictionaryItem[] = []
122
+ let current = await this.get(key)
123
+ while (current) {
124
+ chain.unshift(current)
125
+ current = current.parentKey ? await this.get(current.parentKey) : undefined
126
+ }
127
+ return chain
128
+ }
129
+
130
+ async #descendantKeys(key: string): Promise<string[]> {
131
+ const out: string[] = []
132
+ const queue = [normaliseUuid(key)]
133
+ while (queue.length > 0) {
134
+ const next = queue.shift() as string
135
+ for (const child of await this.#rows('WHERE parent_key = ?', [next])) {
136
+ const childKey = normaliseUuid(String(child.key))
137
+ out.push(childKey)
138
+ queue.push(childKey)
139
+ }
140
+ }
141
+ return out
142
+ }
143
+
144
+ async #languageIds(
145
+ translations: readonly DictionaryTranslation[],
146
+ ): Promise<Map<string, number> | undefined> {
147
+ const languages = await this.#db.query<{ id: number; iso_code: string }>(
148
+ 'SELECT id, iso_code FROM language',
149
+ )
150
+ const byIso = new Map(languages.map((l) => [String(l.iso_code).toLowerCase(), Number(l.id)]))
151
+ const ids = new Map<string, number>()
152
+ for (const t of translations) {
153
+ const id = byIso.get(t.isoCode.toLowerCase())
154
+ if (id === undefined) return undefined
155
+ ids.set(t.isoCode, id)
156
+ }
157
+ return ids
158
+ }
159
+
160
+ async #writeTranslations(
161
+ key: string,
162
+ translations: readonly DictionaryTranslation[],
163
+ ids: Map<string, number>,
164
+ ): Promise<void> {
165
+ await this.#db.exec('DELETE FROM dictionary_text WHERE dictionary_key = ?', [key])
166
+ for (const t of translations)
167
+ await this.#db.exec(
168
+ 'INSERT INTO dictionary_text (dictionary_key, language_id, value) VALUES (?, ?, ?)',
169
+ [key, ids.get(t.isoCode), t.translation],
170
+ )
171
+ }
172
+
173
+ async create(item: DictionaryItem): Promise<DictionaryWriteResult> {
174
+ const key = normaliseUuid(item.key)
175
+ return this.#db.transaction(async (tx) => {
176
+ const repo = new DictionaryRepository(tx)
177
+ if (await repo.get(key)) return 'DuplicateKey'
178
+ if (await repo.byName(item.name)) return 'DuplicateItemKey'
179
+ if (item.parentKey && !(await repo.get(item.parentKey))) return 'ParentNotFound'
180
+ const ids = await repo.#languageIds(item.translations)
181
+ if (!ids) return 'InvalidLanguage'
182
+ await tx.exec('INSERT INTO dictionary_item (key, parent_key, item_key) VALUES (?, ?, ?)', [
183
+ key,
184
+ item.parentKey ? normaliseUuid(item.parentKey) : null,
185
+ item.name,
186
+ ])
187
+ await repo.#writeTranslations(key, item.translations, ids)
188
+ return 'Success'
189
+ })
190
+ }
191
+
192
+ async update(
193
+ key: string,
194
+ change: { name: string; translations: readonly DictionaryTranslation[] },
195
+ ): Promise<DictionaryWriteResult> {
196
+ const k = normaliseUuid(key)
197
+ return this.#db.transaction(async (tx) => {
198
+ const repo = new DictionaryRepository(tx)
199
+ if (!(await repo.get(k))) return 'NotFound'
200
+ const clash = await repo.byName(change.name)
201
+ if (clash && clash.key !== k) return 'DuplicateItemKey'
202
+ const ids = await repo.#languageIds(change.translations)
203
+ if (!ids) return 'InvalidLanguage'
204
+ await tx.exec('UPDATE dictionary_item SET item_key = ? WHERE key = ?', [change.name, k])
205
+ await repo.#writeTranslations(k, change.translations, ids)
206
+ return 'Success'
207
+ })
208
+ }
209
+
210
+ async move(key: string, parentKey: string | null): Promise<DictionaryWriteResult> {
211
+ const k = normaliseUuid(key)
212
+ return this.#db.transaction(async (tx) => {
213
+ const repo = new DictionaryRepository(tx)
214
+ if (!(await repo.get(k))) return 'NotFound'
215
+ if (parentKey) {
216
+ const parent = normaliseUuid(parentKey)
217
+ if (!(await repo.get(parent))) return 'ParentNotFound'
218
+ if (parent === k || (await repo.#descendantKeys(k)).includes(parent)) return 'InvalidParent'
219
+ }
220
+ await tx.exec('UPDATE dictionary_item SET parent_key = ? WHERE key = ?', [
221
+ parentKey ? normaliseUuid(parentKey) : null,
222
+ k,
223
+ ])
224
+ return 'Success'
225
+ })
226
+ }
227
+
228
+ /** Deletes the item and everything below it. */
229
+ async delete(key: string): Promise<DictionaryWriteResult> {
230
+ const k = normaliseUuid(key)
231
+ return this.#db.transaction(async (tx) => {
232
+ const repo = new DictionaryRepository(tx)
233
+ if (!(await repo.get(k))) return 'NotFound'
234
+ const doomed = [...(await repo.#descendantKeys(k)).reverse(), k]
235
+ for (const d of doomed) {
236
+ await tx.exec('DELETE FROM dictionary_text WHERE dictionary_key = ?', [d])
237
+ await tx.exec('DELETE FROM dictionary_item WHERE key = ?', [d])
238
+ }
239
+ return 'Success'
240
+ })
241
+ }
242
+
243
+ /** Every translation, keyed by item name then iso code, for rendering. */
244
+ async translations(): Promise<Map<string, Map<string, string>>> {
245
+ const rows = await this.#db.query<{ item_key: string; iso_code: string; value: string }>(
246
+ `SELECT d.item_key, l.iso_code, t.value FROM dictionary_item d
247
+ JOIN dictionary_text t ON t.dictionary_key = d.key
248
+ JOIN language l ON l.id = t.language_id`,
249
+ )
250
+ const out = new Map<string, Map<string, string>>()
251
+ for (const row of rows) {
252
+ const name = String(row.item_key)
253
+ const entry = out.get(name) ?? new Map<string, string>()
254
+ entry.set(String(row.iso_code), String(row.value))
255
+ out.set(name, entry)
256
+ }
257
+ return out
258
+ }
259
+ }