@stacksjs/defaults 0.74.33 → 0.74.34

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.
@@ -0,0 +1,432 @@
1
+ import type { StorageItemMetadata, StorageItemTask, StorageMetadataStore, StorageTaskKind, StorageTaskState } from './file-metadata'
2
+ import { db, sqlDateTime } from '@stacksjs/database'
3
+ import { isEmptyMetadata, isUnderPrefix, normalizeTags, repathUnderPrefix, STORAGE_ITEM_TYPE } from './file-metadata'
4
+
5
+ /**
6
+ * The database-backed {@link StorageMetadataStore} (stacksjs/stacks#2577).
7
+ *
8
+ * Split from `file-metadata.ts` so the reconciliation rules there can be tested
9
+ * without a database - the file manager makes the same split with its `Manager`
10
+ * parameter, and for the same reason.
11
+ *
12
+ * Two tables. `storage_items` holds the row per `(disk, path)`; tags go through
13
+ * `tags` and `taggable_models`, which is the vocabulary #2577 asks for by name -
14
+ * the one the dashboard's own tag manager writes and reads. `taggable_type` is
15
+ * `storage_items`, so a file's tags and a post's share the vocabulary without
16
+ * their pivot rows colliding.
17
+ *
18
+ * `tags`, not `taggables`, and the distinction is not cosmetic
19
+ * (stacksjs/stacks#2579). `taggables` is a different mechanism: the `taggable`
20
+ * trait writes tag names straight into it with no pivot row. Everything that
21
+ * writes `taggable_models` writes a `tags` id - the dashboard validates against
22
+ * `tags`, writes them, reads them back and counts them - so a join to
23
+ * `taggables` returns an empty set, or worse a row whose id happened to
24
+ * collide.
25
+ *
26
+ * Written against the query builder rather than the `StorageItem` model because
27
+ * every operation here is a set operation - one query for a subtree, one prefix
28
+ * update for a folder rename - and doing those a row at a time through the model
29
+ * would turn a folder of a thousand files into a thousand round trips.
30
+ */
31
+
32
+ interface StorageItemRow {
33
+ id: number
34
+ disk: string
35
+ path: string
36
+ favorite: number | boolean | null
37
+ }
38
+
39
+ function toBoolean(value: number | boolean | null | undefined): boolean {
40
+ // SQLite has no boolean type, so `favorite` comes back as 0/1 here and as a
41
+ // real boolean on Postgres and MySQL.
42
+ return value === true || value === 1
43
+ }
44
+
45
+ /**
46
+ * The rows for a subtree, keyed by path.
47
+ *
48
+ * `prefix` matches the path itself as well as everything under it, so a single
49
+ * path can be looked up with the same call the listing uses - which is what lets
50
+ * `setFavorite` read-before-write without a second query shape.
51
+ */
52
+ async function rowsUnder(disk: string, prefix: string): Promise<StorageItemRow[]> {
53
+ // Cast through `unknown`: the query builder types a `selectAll()` on a table
54
+ // it has no generated interface for as `Record<string, unknown>`, and the two
55
+ // shapes do not overlap enough for a direct assertion. The columns are the
56
+ // ones the migration creates.
57
+ const all = async (build: (q: ReturnType<typeof scoped>) => ReturnType<typeof scoped>): Promise<StorageItemRow[]> =>
58
+ await build(scoped(disk)).execute() as unknown as StorageItemRow[]
59
+
60
+ if (!prefix)
61
+ return await all(query => query)
62
+
63
+ // Two queries rather than one with an OR. The builder's `or` group takes a
64
+ // shape this table has no generated interface for, and an `orWhere` on the
65
+ // fluent chain would bind as `disk = ? AND path = ? OR path LIKE ?` - which
66
+ // reads rows from every other disk. Both of these are indexed, and a subtree
67
+ // read is not a hot path.
68
+ //
69
+ // `LIKE 'prefix/%'` plus the exact match, never `LIKE 'prefix%'`: the looser
70
+ // pattern also matches `reports-archive` when the prefix is `reports`, which
71
+ // is a different folder that happens to share a spelling.
72
+ const [exact, beneath] = await Promise.all([
73
+ all(query => query.where('path', '=', prefix)),
74
+ all(query => query.where('path', 'like', `${escapeLike(prefix)}/%`)),
75
+ ])
76
+
77
+ return [...exact, ...beneath]
78
+ }
79
+
80
+ /** The `storage_items` rows for one disk, before any path filter. */
81
+ function scoped(disk: string) {
82
+ return db.selectFrom('storage_items').selectAll().where('disk', '=', disk)
83
+ }
84
+
85
+ /**
86
+ * Escape the wildcards `LIKE` would otherwise interpret.
87
+ *
88
+ * A path may legally contain `%` or `_`, and an unescaped `_` matches any
89
+ * character - so a folder named `q_1` would sweep `q11` along with itself.
90
+ */
91
+ function escapeLike(value: string): string {
92
+ return value.replace(/[\\%_]/g, character => `\\${character}`)
93
+ }
94
+
95
+ async function tagsFor(ids: number[]): Promise<Map<number, string[]>> {
96
+ const byItem = new Map<number, string[]>()
97
+ if (ids.length === 0)
98
+ return byItem
99
+
100
+ const rows = await db
101
+ .selectFrom('taggable_models')
102
+ .innerJoin('tags', 'tags.id', '=', 'taggable_models.tag_id')
103
+ .select(['taggable_models.taggable_id as itemId', 'tags.name as name'])
104
+ .where('taggable_models.taggable_type', '=', STORAGE_ITEM_TYPE)
105
+ .where('taggable_models.taggable_id', 'in', ids)
106
+ .execute() as Array<{ itemId: number, name: string }>
107
+
108
+ for (const row of rows) {
109
+ const existing = byItem.get(row.itemId)
110
+ if (existing)
111
+ existing.push(row.name)
112
+ else
113
+ byItem.set(row.itemId, [row.name])
114
+ }
115
+
116
+ for (const [id, names] of byItem)
117
+ byItem.set(id, normalizeTags(names))
118
+
119
+ return byItem
120
+ }
121
+
122
+ /**
123
+ * The `tags` row for a name, created if this is the first thing to use it.
124
+ *
125
+ * Shared with posts rather than scoped to files, which is the point: a tag is a
126
+ * word somebody chose, and having "invoice" mean one thing on a post and a
127
+ * different thing on a file is how a tag list stops being useful. The pivot's
128
+ * `taggable_type` is what keeps the two sets of ATTACHMENTS apart.
129
+ */
130
+ async function tagIdFor(name: string): Promise<number> {
131
+ const existing = await db
132
+ .selectFrom('tags')
133
+ .select(['id'])
134
+ .where('name', '=', name)
135
+ .executeTakeFirst() as { id: number } | undefined
136
+
137
+ if (existing)
138
+ return existing.id
139
+
140
+ await db
141
+ .insertInto('tags')
142
+ .values({
143
+ name,
144
+ slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''),
145
+ created_at: sqlDateTime(),
146
+ updated_at: sqlDateTime(),
147
+ uuid: crypto.randomUUID(),
148
+ })
149
+ .execute()
150
+
151
+ // Re-selected rather than read from the write's return value, which on SQLite
152
+ // is only `{ changes, lastInsertRowid }`.
153
+ const created = await db
154
+ .selectFrom('tags')
155
+ .select(['id'])
156
+ .where('name', '=', name)
157
+ .executeTakeFirst() as { id: number } | undefined
158
+
159
+ if (!created)
160
+ throw new Error(`[file-metadata] failed to create the tag "${name}"`)
161
+
162
+ return created.id
163
+ }
164
+
165
+ async function itemIdFor(disk: string, path: string): Promise<number | undefined> {
166
+ const row = await db
167
+ .selectFrom('storage_items')
168
+ .select(['id'])
169
+ .where('disk', '=', disk)
170
+ .where('path', '=', path)
171
+ .executeTakeFirst() as { id: number } | undefined
172
+
173
+ return row?.id
174
+ }
175
+
176
+ interface StorageItemTaskRow {
177
+ id: number
178
+ disk: string
179
+ path: string
180
+ kind: StorageTaskKind
181
+ state: StorageTaskState
182
+ attempts: number | null
183
+ error: string | null
184
+ started_at: string | null
185
+ finished_at: string | null
186
+ }
187
+
188
+ /** The `storage_item_tasks` rows for a subtree, same prefix rules as above. */
189
+ async function taskRowsUnder(disk: string, prefix: string): Promise<StorageItemTaskRow[]> {
190
+ const scopedTasks = (): ReturnType<typeof db.selectFrom> =>
191
+ db.selectFrom('storage_item_tasks').selectAll().where('disk', '=', disk)
192
+
193
+ const all = async (build: (q: ReturnType<typeof scopedTasks>) => ReturnType<typeof scopedTasks>): Promise<StorageItemTaskRow[]> =>
194
+ await build(scopedTasks()).execute() as unknown as StorageItemTaskRow[]
195
+
196
+ if (!prefix)
197
+ return await all(query => query)
198
+
199
+ const [exact, beneath] = await Promise.all([
200
+ all(query => query.where('path', '=', prefix)),
201
+ all(query => query.where('path', 'like', `${escapeLike(prefix)}/%`)),
202
+ ])
203
+
204
+ return [...exact, ...beneath]
205
+ }
206
+
207
+ function toTask(row: StorageItemTaskRow): StorageItemTask {
208
+ return {
209
+ kind: row.kind,
210
+ state: row.state,
211
+ attempts: Number(row.attempts ?? 0),
212
+ error: row.error ?? undefined,
213
+ startedAt: row.started_at ?? undefined,
214
+ finishedAt: row.finished_at ?? undefined,
215
+ }
216
+ }
217
+
218
+ async function deleteItems(ids: number[]): Promise<void> {
219
+ if (ids.length === 0)
220
+ return
221
+
222
+ // Pivot rows first: a `storage_items` row removed while its pivot rows remain
223
+ // leaves tags attached to an id that will eventually be reused by a different
224
+ // file, which is how somebody else's tags appear on your upload.
225
+ await db
226
+ .deleteFrom('taggable_models')
227
+ .where('taggable_type', '=', STORAGE_ITEM_TYPE)
228
+ .where('taggable_id', 'in', ids)
229
+ .execute()
230
+
231
+ await db.deleteFrom('storage_items').where('id', 'in', ids).execute()
232
+ }
233
+
234
+ export const databaseMetadataStore: StorageMetadataStore = {
235
+ async under(disk, prefix) {
236
+ const rows = await rowsUnder(disk, prefix)
237
+ const tags = await tagsFor(rows.map(row => row.id))
238
+
239
+ const records = new Map<string, StorageItemMetadata>()
240
+ for (const row of rows) {
241
+ records.set(row.path, {
242
+ favorite: toBoolean(row.favorite),
243
+ tags: tags.get(row.id) ?? [],
244
+ })
245
+ }
246
+
247
+ return records
248
+ },
249
+
250
+ async write(disk, path, metadata) {
251
+ const existingId = await itemIdFor(disk, path)
252
+
253
+ if (isEmptyMetadata(metadata)) {
254
+ // Nothing left to say about this file, so the row goes. A row recording
255
+ // "not starred, no tags" is indistinguishable from no row, and keeping it
256
+ // would grow the table by one for every file anybody ever starred and
257
+ // then unstarred.
258
+ if (existingId !== undefined)
259
+ await deleteItems([existingId])
260
+ return
261
+ }
262
+
263
+ let id = existingId
264
+ if (id === undefined) {
265
+ await db
266
+ .insertInto('storage_items')
267
+ .values({
268
+ disk,
269
+ path,
270
+ favorite: metadata.favorite,
271
+ created_at: sqlDateTime(),
272
+ updated_at: sqlDateTime(),
273
+ uuid: crypto.randomUUID(),
274
+ })
275
+ .execute()
276
+
277
+ id = await itemIdFor(disk, path)
278
+ if (id === undefined)
279
+ throw new Error(`[file-metadata] failed to create the record for ${disk}:${path}`)
280
+ }
281
+ else {
282
+ await db
283
+ .updateTable('storage_items')
284
+ .set({ favorite: metadata.favorite, updated_at: sqlDateTime() })
285
+ .where('id', '=', id)
286
+ .execute()
287
+ }
288
+
289
+ // Tags are replaced rather than merged: the caller sends the whole set,
290
+ // because a UI that can add a tag can also remove one and there is no
291
+ // separate signal for that.
292
+ await db
293
+ .deleteFrom('taggable_models')
294
+ .where('taggable_type', '=', STORAGE_ITEM_TYPE)
295
+ .where('taggable_id', '=', id)
296
+ .execute()
297
+
298
+ for (const name of metadata.tags) {
299
+ await db
300
+ .insertInto('taggable_models')
301
+ .values({
302
+ tag_id: await tagIdFor(name),
303
+ taggable_id: id,
304
+ taggable_type: STORAGE_ITEM_TYPE,
305
+ created_at: sqlDateTime(),
306
+ updated_at: sqlDateTime(),
307
+ })
308
+ .execute()
309
+ }
310
+ },
311
+
312
+ async move(disk, from, to) {
313
+ const rows = await rowsUnder(disk, from)
314
+ if (rows.length === 0)
315
+ return 0
316
+
317
+ // Read, repath and write each row rather than one `UPDATE ... SET path =
318
+ // REPLACE(path, ...)`: the SQL form differs across the three engines this
319
+ // supports, and a folder rename is a handful of rows, not a hot path.
320
+ //
321
+ // A destination that already has a row is overwritten, matching the storage
322
+ // move it is following - `renameDashboardFile` has already replaced the file
323
+ // at `to`, so leaving its old metadata behind would describe the file that
324
+ // is gone.
325
+ for (const row of rows) {
326
+ const next = repathUnderPrefix(row.path, from, to)
327
+ if (next === row.path)
328
+ continue
329
+
330
+ const collision = await itemIdFor(disk, next)
331
+ if (collision !== undefined && collision !== row.id)
332
+ await deleteItems([collision])
333
+
334
+ await db
335
+ .updateTable('storage_items')
336
+ .set({ path: next, updated_at: sqlDateTime() })
337
+ .where('id', '=', row.id)
338
+ .execute()
339
+ }
340
+
341
+ // Tasks follow the file, or a transcode that finished before a rename
342
+ // reports as never having run (stacksjs/stacks#2578).
343
+ for (const task of await taskRowsUnder(disk, from)) {
344
+ const next = repathUnderPrefix(task.path, from, to)
345
+ if (next === task.path)
346
+ continue
347
+
348
+ // A task already recorded at the destination is replaced, matching the
349
+ // storage move this follows: the file that was there is gone.
350
+ const collisions = (await taskRowsUnder(disk, next)).filter(row => row.path === next && row.kind === task.kind && row.id !== task.id)
351
+ await deleteTasks(collisions.map(row => row.id))
352
+
353
+ await db
354
+ .updateTable('storage_item_tasks')
355
+ .set({ path: next, updated_at: sqlDateTime() })
356
+ .where('id', '=', task.id)
357
+ .execute()
358
+ }
359
+
360
+ return rows.length
361
+ },
362
+
363
+ async forget(disk, path) {
364
+ const rows = await rowsUnder(disk, path)
365
+ await deleteItems(rows.map(row => row.id))
366
+ await deleteTasks((await taskRowsUnder(disk, path)).map(row => row.id))
367
+ return rows.length
368
+ },
369
+
370
+ async sweep(disk, prefix, keep) {
371
+ const rows = await rowsUnder(disk, prefix)
372
+ const orphans = rows.filter(row => isUnderPrefix(row.path, prefix) && !keep.has(row.path))
373
+ await deleteItems(orphans.map(row => row.id))
374
+
375
+ const orphanTasks = (await taskRowsUnder(disk, prefix)).filter(row => !keep.has(row.path))
376
+ await deleteTasks(orphanTasks.map(row => row.id))
377
+
378
+ return orphans.length
379
+ },
380
+
381
+ async tasksUnder(disk, prefix) {
382
+ const byPath = new Map<string, StorageItemTask[]>()
383
+ for (const row of await taskRowsUnder(disk, prefix)) {
384
+ const existing = byPath.get(row.path)
385
+ if (existing)
386
+ existing.push(toTask(row))
387
+ else
388
+ byPath.set(row.path, [toTask(row)])
389
+ }
390
+ return byPath
391
+ },
392
+
393
+ async writeTask(disk, path, task) {
394
+ const existing = await db
395
+ .selectFrom('storage_item_tasks')
396
+ .select(['id'])
397
+ .where('disk', '=', disk)
398
+ .where('path', '=', path)
399
+ .where('kind', '=', task.kind)
400
+ .executeTakeFirst() as { id: number } | undefined
401
+
402
+ const values = {
403
+ state: task.state,
404
+ attempts: task.attempts,
405
+ error: task.error ?? null,
406
+ started_at: task.startedAt ?? null,
407
+ finished_at: task.finishedAt ?? null,
408
+ updated_at: sqlDateTime(),
409
+ }
410
+
411
+ // Replaced rather than appended: re-running a kind should leave one answer
412
+ // to "what happened to the transcode". The history of attempts belongs to
413
+ // the queue, not here.
414
+ if (existing) {
415
+ await db.updateTable('storage_item_tasks').set(values).where('id', '=', existing.id).execute()
416
+ return
417
+ }
418
+
419
+ await db
420
+ .insertInto('storage_item_tasks')
421
+ .values({ disk, path, kind: task.kind, ...values, created_at: sqlDateTime(), uuid: crypto.randomUUID() })
422
+ .execute()
423
+ },
424
+ }
425
+
426
+ async function deleteTasks(ids: number[]): Promise<void> {
427
+ if (ids.length === 0)
428
+ return
429
+ await db.deleteFrom('storage_item_tasks').where('id', 'in', ids).execute()
430
+ }
431
+
432
+ export type { StorageItemMetadata, StorageMetadataStore }