@stacksjs/cms 0.70.53 → 0.70.55

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,282 @@
1
+ import type { TaggableTable } from '@stacksjs/orm'
2
+ import { getDb } from '../database'
3
+ import { findOrCreate } from './store'
4
+
5
+ /**
6
+ * Fetch a tag by its ID
7
+ *
8
+ * @param id The ID of the tag to fetch
9
+ * @returns The tag record if found
10
+ */
11
+ export async function fetchTagById(id: number): Promise<TaggableTable | undefined> {
12
+ const db = await getDb()
13
+ try {
14
+ const result = await db
15
+ .selectFrom('taggables')
16
+ .where('id', '=', id)
17
+ .selectAll()
18
+ .executeTakeFirst()
19
+
20
+ if (!result) {
21
+ return undefined
22
+ }
23
+
24
+ return result as unknown as TaggableTable
25
+ }
26
+ catch (error) {
27
+ if (error instanceof Error) {
28
+ throw new TypeError(`Failed to fetch tag: ${error.message}`)
29
+ }
30
+
31
+ throw error
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Fetch all tags
37
+ *
38
+ * @returns An array of tag records
39
+ */
40
+ export async function fetchTags(): Promise<TaggableTable[]> {
41
+ const db = await getDb()
42
+ try {
43
+ return await db
44
+ .selectFrom('taggables')
45
+ .where('is_active', '=', true)
46
+ .selectAll()
47
+ .execute() as unknown as unknown as TaggableTable[]
48
+ }
49
+ catch (error) {
50
+ if (error instanceof Error) {
51
+ throw new TypeError(`Failed to fetch tags: ${error.message}`)
52
+ }
53
+
54
+ throw error
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Find a tag by name or create it if it doesn't exist
60
+ *
61
+ * @param name The name of the tag to find or create
62
+ * @param taggableType Type of the taggable entity
63
+ * @param description Optional description for the tag
64
+ * @returns The existing or newly created tag
65
+ */
66
+ export async function firstOrCreate(
67
+ name: string,
68
+ taggableType: string,
69
+ description?: string,
70
+ ): Promise<TaggableTable> {
71
+ try {
72
+ return await findOrCreate({
73
+ name,
74
+ taggable_type: taggableType,
75
+ description,
76
+ })
77
+ }
78
+ catch (error) {
79
+ if (error instanceof Error) {
80
+ throw new TypeError(`Failed to find or create tag: ${error.message}`)
81
+ }
82
+
83
+ throw error
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Count the number of posts that have been tagged
89
+ *
90
+ * @param taggableType The type of entity to count (e.g. 'posts')
91
+ * @returns The count of tagged posts
92
+ */
93
+ export async function countTaggedPosts(taggableType: string): Promise<number> {
94
+ const db = await getDb()
95
+ try {
96
+ const result = await db
97
+ .selectFrom('taggable_models')
98
+ .where('taggable_type', '=', taggableType)
99
+ .count()
100
+
101
+ return Number(result) || 0
102
+ }
103
+ catch (error) {
104
+ if (error instanceof Error) {
105
+ throw new TypeError(`Failed to count tagged posts: ${error.message}`)
106
+ }
107
+
108
+ throw error
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Count the total number of tags in the system
114
+ *
115
+ * @returns The total count of tags
116
+ */
117
+ export async function countTotalTags(): Promise<number> {
118
+ const db = await getDb()
119
+ try {
120
+ const result = await db
121
+ .selectFrom('taggables')
122
+ .count()
123
+
124
+ return Number(result) || 0
125
+ }
126
+ catch (error) {
127
+ if (error instanceof Error) {
128
+ throw new TypeError(`Failed to count total tags: ${error.message}`)
129
+ }
130
+
131
+ throw error
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Find the most used tag in the system
137
+ *
138
+ * @param taggableType Optional type to filter by (e.g. 'posts', 'articles')
139
+ * @returns The most used tag name and its count
140
+ */
141
+ export async function findMostUsedTag(taggableType?: string): Promise<{ name: string, count: number } | null> {
142
+ const db = await getDb()
143
+ try {
144
+ let query = db
145
+ .selectFrom('taggable_models')
146
+ .innerJoin('taggables', 'taggables.id', '=', 'taggable_models.tag_id')
147
+ .select([
148
+ 'taggables.name',
149
+ ])
150
+ .groupBy('taggables.name') as any
151
+
152
+ if (taggableType)
153
+ query = query.where('taggable_models.taggable_type', '=', taggableType)
154
+
155
+ const result = await query
156
+ .orderBy('taggables.name', 'asc')
157
+ .executeTakeFirst()
158
+
159
+ if (!result) {
160
+ return null
161
+ }
162
+
163
+ return {
164
+ name: (result as Record<string, unknown>).name as string,
165
+ count: Number((result as Record<string, unknown>).usage_count || 0),
166
+ }
167
+ }
168
+ catch (error) {
169
+ if (error instanceof Error) {
170
+ throw new TypeError(`Failed to find most used tag: ${error.message}`)
171
+ }
172
+
173
+ throw error
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Find the least used tag in the system
179
+ *
180
+ * @returns The least used tag name and its count
181
+ */
182
+ export async function findLeastUsedTag(): Promise<{ name: string, count: number } | null> {
183
+ const db = await getDb()
184
+ try {
185
+ const result = await (db
186
+ .selectFrom('taggable_models')
187
+ .innerJoin('taggables', 'taggables.id', '=', 'taggable_models.tag_id')
188
+ .select([
189
+ 'taggables.name',
190
+ ])
191
+ .groupBy('taggables.name') as any)
192
+ .orderBy('taggables.name', 'asc')
193
+ .executeTakeFirst()
194
+
195
+ if (!result) {
196
+ return null
197
+ }
198
+
199
+ return {
200
+ name: (result as Record<string, unknown>).name as string,
201
+ count: Number((result as Record<string, unknown>).usage_count || 0),
202
+ }
203
+ }
204
+ catch (error) {
205
+ if (error instanceof Error) {
206
+ throw new TypeError(`Failed to find least used tag: ${error.message}`)
207
+ }
208
+
209
+ throw error
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Fetch tags with their post counts
215
+ *
216
+ * @returns Array of tags with their post counts
217
+ */
218
+ export async function fetchTagsWithPostCounts(): Promise<Array<{ name: string, postCount: number }>> {
219
+ const db = await getDb()
220
+ try {
221
+ const result = await (db
222
+ .selectFrom('taggables')
223
+ .leftJoin('taggable_models', 'taggables.id', '=', 'taggable_models.tag_id')
224
+ .select([
225
+ 'taggables.name',
226
+ ])
227
+ .groupBy('taggables.name') as any)
228
+ .orderBy('taggables.name', 'desc')
229
+ .limit(10)
230
+ .execute()
231
+
232
+ return (result as Record<string, unknown>[]).map((row: Record<string, unknown>) => ({
233
+ name: row.name as string,
234
+ postCount: Number(row.post_count || 0),
235
+ }))
236
+ }
237
+ catch (error) {
238
+ if (error instanceof Error) {
239
+ throw new TypeError(`Failed to fetch tags with post counts: ${error.message}`)
240
+ }
241
+
242
+ throw error
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Fetch tag distribution data for a donut graph
248
+ *
249
+ * @returns Array of tags with their counts and percentages
250
+ */
251
+ export async function fetchTagDistribution(): Promise<Array<{ name: string, count: number, percentage: number }>> {
252
+ const db = await getDb()
253
+ try {
254
+ const result = await (db
255
+ .selectFrom('taggables')
256
+ .leftJoin('taggable_models', 'taggables.id', '=', 'taggable_models.tag_id')
257
+ .select([
258
+ 'taggables.name',
259
+ ])
260
+ .groupBy('taggables.name') as any)
261
+ .orderBy('taggables.name', 'desc')
262
+ .execute()
263
+
264
+ const typedResult = result as Record<string, unknown>[]
265
+
266
+ // Calculate total count for percentage calculation
267
+ const totalCount = typedResult.reduce((sum: number, row: Record<string, unknown>) => sum + Number(row.count || 0), 0)
268
+
269
+ return typedResult.map((row: Record<string, unknown>) => ({
270
+ name: row.name as string,
271
+ count: Number(row.count || 0),
272
+ percentage: totalCount > 0 ? (Number(row.count || 0) / totalCount) * 100 : 0,
273
+ }))
274
+ }
275
+ catch (error) {
276
+ if (error instanceof Error) {
277
+ throw new TypeError(`Failed to fetch tag distribution: ${error.message}`)
278
+ }
279
+
280
+ throw error
281
+ }
282
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ bulkDestroy,
3
+ destroy,
4
+ } from './destroy'
5
+
6
+ export {
7
+ fetchTagById,
8
+ fetchTags,
9
+ } from './fetch'
10
+
11
+ export {
12
+ store,
13
+ } from './store'
14
+
15
+ export {
16
+ update,
17
+ } from './update'
@@ -0,0 +1,122 @@
1
+ import type { TaggableTable } from '@stacksjs/orm'
2
+ import { getDb } from '../database'
3
+ import { slugify } from 'ts-slug'
4
+
5
+ interface TagData {
6
+ name: string
7
+ description?: string
8
+ is_active?: boolean
9
+ taggable_id?: number
10
+ taggable_type: string
11
+ }
12
+
13
+ /**
14
+ * Find or create multiple tags by their names
15
+ *
16
+ * @param names Array of tag names to process
17
+ * @param taggableType The type of model these tags belong to
18
+ * @returns Array of tag IDs
19
+ */
20
+ export async function findOrCreateMany(names: string[], taggableType: string): Promise<number[]> {
21
+ const tagIds: number[] = []
22
+
23
+ for (const name of names) {
24
+ const tag = await findOrCreate({ name, taggable_type: taggableType })
25
+ tagIds.push(tag.id!)
26
+ }
27
+
28
+ return tagIds
29
+ }
30
+
31
+ /**
32
+ * Find or create a single tag
33
+ *
34
+ * @param data The tag data
35
+ * @returns The found or created tag
36
+ */
37
+ export async function findOrCreate(data: TagData): Promise<TaggableTable> {
38
+ const db = await getDb()
39
+ try {
40
+ // Try to find existing tag
41
+ const existingTag = await db
42
+ .selectFrom('taggables')
43
+ .selectAll()
44
+ .where('name', '=', data.name)
45
+ .executeTakeFirst()
46
+
47
+ if (existingTag)
48
+ return existingTag as unknown as TaggableTable
49
+
50
+ // If not found, create new tag
51
+ return await store(data)
52
+ }
53
+ catch (error) {
54
+ if (error instanceof Error) {
55
+ throw new TypeError(`Failed to find or create tag: ${error.message}`)
56
+ }
57
+ throw error
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Create a new tag
63
+ *
64
+ * @param data The tag data to store
65
+ * @returns The newly created tag record
66
+ */
67
+ export async function store(data: TagData): Promise<TaggableTable> {
68
+ const db = await getDb()
69
+ try {
70
+ if (!data.name || data.name.trim() === '') {
71
+ throw new Error('Tag name is required')
72
+ }
73
+
74
+ if (!data.taggable_type || data.taggable_type.trim() === '') {
75
+ throw new Error('Tag taggable_type is required')
76
+ }
77
+
78
+ const slug = slugify(data.name)
79
+
80
+ // Enforce unique slugs
81
+ const existingSlug = await db
82
+ .selectFrom('taggables')
83
+ .selectAll()
84
+ .where('slug', '=', slug)
85
+ .executeTakeFirst()
86
+
87
+ if (existingSlug) {
88
+ throw new Error(`Tag with unique slug "${slug}" already exists`)
89
+ }
90
+
91
+ const tagData: Record<string, unknown> = {
92
+ name: data.name,
93
+ slug,
94
+ description: data.description,
95
+ is_active: data.is_active ?? true,
96
+ taggable_type: data.taggable_type,
97
+ }
98
+
99
+ if (data.taggable_id !== undefined) {
100
+ tagData.taggable_id = data.taggable_id
101
+ }
102
+
103
+ const result = await db
104
+ .insertInto('taggables')
105
+ .values(tagData)
106
+ .returningAll()
107
+ .executeTakeFirst()
108
+
109
+ if (!result) {
110
+ throw new Error('Failed to create tag')
111
+ }
112
+
113
+ return result as unknown as TaggableTable
114
+ }
115
+ catch (error) {
116
+ if (error instanceof Error) {
117
+ throw new TypeError(`Failed to store tag: ${error.message}`)
118
+ }
119
+
120
+ throw error
121
+ }
122
+ }
@@ -0,0 +1,74 @@
1
+ import type { TaggableTable } from '@stacksjs/orm'
2
+ import { getDb } from '../database'
3
+ import { uniqueSlug } from '@stacksjs/slug'
4
+
5
+ interface UpdateTagData {
6
+ id: number
7
+ name?: string
8
+ slug?: string
9
+ description?: string
10
+ is_active?: boolean
11
+ taggable_id?: number
12
+ taggable_type?: string
13
+ }
14
+
15
+ /**
16
+ * Update a tag
17
+ *
18
+ * @param id The tag id
19
+ * @param data The tag data to update
20
+ * @returns The updated tag record
21
+ */
22
+ export async function update(data: UpdateTagData): Promise<TaggableTable> {
23
+ const db = await getDb()
24
+ try {
25
+ const id = data.id
26
+
27
+ if (!id) {
28
+ throw new Error('Tag ID is required for update')
29
+ }
30
+
31
+ if (data.name !== undefined) {
32
+ if (data.name.trim() === '') {
33
+ throw new Error('Tag name cannot be empty')
34
+ }
35
+
36
+ const slug = await uniqueSlug(data.name, { table: 'taggables', column: 'slug' })
37
+
38
+ // Enforce unique slugs on update
39
+ const existingSlug = await db
40
+ .selectFrom('taggables')
41
+ .selectAll()
42
+ .where('slug', '=', slug)
43
+ .where('id', '!=', id)
44
+ .executeTakeFirst()
45
+
46
+ if (existingSlug) {
47
+ throw new Error(`Tag with unique slug "${slug}" already exists`)
48
+ }
49
+
50
+ data.slug = slug
51
+ }
52
+
53
+ // Remove id from the data to avoid updating it
54
+ const { id: _id, ...updateData } = data
55
+
56
+ const result = await db
57
+ .updateTable('taggables')
58
+ .set(updateData as unknown as Record<string, unknown>)
59
+ .where('id', '=', id)
60
+ .returningAll()
61
+ .executeTakeFirst()
62
+
63
+ if (!result)
64
+ throw new Error('Failed to update tag')
65
+
66
+ return result as unknown as TaggableTable
67
+ }
68
+ catch (error) {
69
+ if (error instanceof Error)
70
+ throw new TypeError(`Failed to update tag: ${error.message}`)
71
+
72
+ throw error
73
+ }
74
+ }
@@ -0,0 +1,66 @@
1
+ import { beforeEach, describe, expect, it } from 'bun:test'
2
+ import { refreshDatabase } from './setup'
3
+ import { destroy } from '../categorizables/destroy'
4
+ import { store } from '../categorizables/store'
5
+
6
+ beforeEach(async () => {
7
+ await refreshDatabase()
8
+ })
9
+
10
+ describe('Category Module', () => {
11
+ describe('store', () => {
12
+ it('should create multiple categories for the same post', async () => {
13
+ const firstCategory = await store({
14
+ name: 'Technology',
15
+ description: 'Technology related content',
16
+ categorizable_type: 'posts',
17
+ is_active: true,
18
+ })
19
+
20
+ const secondCategory = await store({
21
+ name: 'Programming',
22
+ description: 'Programming related content',
23
+ categorizable_type: 'posts',
24
+ is_active: true,
25
+ })
26
+
27
+ expect(firstCategory).toBeDefined()
28
+ expect(secondCategory).toBeDefined()
29
+ expect(firstCategory?.categorizable_type).toBe(secondCategory?.categorizable_type)
30
+ })
31
+
32
+ it('should throw an error when trying to create a category with invalid data', async () => {
33
+ const categoryData = {
34
+ name: '', // Empty name should fail
35
+ description: '',
36
+ categorizable_type: '',
37
+ is_active: false,
38
+ }
39
+
40
+ try {
41
+ await store(categoryData)
42
+ expect(true).toBe(false) // This line should not be reached
43
+ }
44
+ catch (error) {
45
+ expect(error).toBeDefined()
46
+ expect(error instanceof Error).toBe(true)
47
+ }
48
+ })
49
+ })
50
+
51
+ describe('destroy', () => {
52
+ it('should throw an error when trying to delete a non-existent category', async () => {
53
+ const nonExistentId = 99999999
54
+
55
+ try {
56
+ await destroy(nonExistentId)
57
+ expect(true).toBe(false) // This line should not be reached
58
+ }
59
+ catch (error) {
60
+ expect(error).toBeDefined()
61
+ expect(error instanceof Error).toBe(true)
62
+ expect((error as Error).message).toContain(`Category with ID ${nonExistentId} not found`)
63
+ }
64
+ })
65
+ })
66
+ })
@@ -0,0 +1,120 @@
1
+ /**
2
+ * CMS package test harness.
3
+ *
4
+ * This suite used to import `refreshDatabase` from
5
+ * `@stacksjs/testing/database`, which (pre-existing breakage) never
6
+ * worked from a package directory: `bun test` run from
7
+ * storage/framework/core/cms doesn't load the repo-root `.env`, so the
8
+ * driver fell back to mysql and the helper's dead kysely-era
9
+ * `sql\`...\`.execute(db)` calls threw in every `beforeEach`.
10
+ *
11
+ * Strategy (mirrors auth/tests/password-reset-revocation.test.ts): pin
12
+ * env to a throwaway SQLite file BEFORE any framework module loads,
13
+ * then hand-create the two trait tables the categorizables module
14
+ * touches. `categorizables`/`categorizable_models` are trait tables
15
+ * (created by the categorizable model trait, not a model definition),
16
+ * so there is no model file to derive them from.
17
+ *
18
+ * Wired up via this package's bunfig.toml `[test] preload`, so the
19
+ * env pin always lands before any test file's own imports evaluate.
20
+ */
21
+
22
+ import { existsSync, unlinkSync } from 'node:fs'
23
+ import { tmpdir } from 'node:os'
24
+ import { join } from 'node:path'
25
+ import process from 'node:process'
26
+
27
+ const DB_PATH = join(tmpdir(), `stacks-cms-${process.pid}.sqlite`)
28
+ process.env.DB_CONNECTION = 'sqlite'
29
+ process.env.DB_DATABASE_PATH = DB_PATH
30
+ process.env.APP_ENV = 'testing'
31
+
32
+ // Dynamic import AFTER the env pin so the lazy `db` proxy and the
33
+ // config loader can't capture a different connection first.
34
+ const { acquireDbConfigLock, db, ensureDatabaseConfigLoaded, initializeDbConfig } = await import('@stacksjs/database')
35
+
36
+ // Holds `initializeDbConfig`'s process-wide config mutex (stacksjs/stacks#1862)
37
+ // for this module's entire lifetime — no `describe`/`afterAll` boundary exists
38
+ // here (this is a shared fixture imported by many test files, not a test file
39
+ // itself), so it's released on process exit alongside the file cleanup below.
40
+ const releaseDbConfigLock = await acquireDbConfigLock()
41
+
42
+ /**
43
+ * Drain the one-shot async config reload, then force our temp SQLite
44
+ * config so a late-resolving override can't re-point the shared `db`
45
+ * proxy at a different database mid-test.
46
+ */
47
+ async function forceConfig(): Promise<void> {
48
+ await ensureDatabaseConfigLoaded()
49
+ initializeDbConfig({
50
+ app: { env: 'testing' },
51
+ database: {
52
+ default: 'sqlite',
53
+ connections: { sqlite: { database: DB_PATH, prefix: '' } },
54
+ },
55
+ })
56
+ }
57
+
58
+ // Stale file from a recycled pid would otherwise leak a previous run's
59
+ // schema/rows into this one.
60
+ for (const suffix of ['', '-wal', '-shm']) {
61
+ if (existsSync(`${DB_PATH}${suffix}`))
62
+ unlinkSync(`${DB_PATH}${suffix}`)
63
+ }
64
+
65
+ await forceConfig()
66
+
67
+ await db.unsafe(`
68
+ CREATE TABLE IF NOT EXISTS categorizables (
69
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ name VARCHAR(255) NOT NULL,
71
+ slug VARCHAR(255) NOT NULL,
72
+ description TEXT,
73
+ categorizable_type VARCHAR(255) NOT NULL,
74
+ is_active BOOLEAN NOT NULL DEFAULT 1,
75
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
76
+ updated_at TIMESTAMP
77
+ )
78
+ `).execute()
79
+
80
+ await db.unsafe(`
81
+ CREATE TABLE IF NOT EXISTS categorizable_models (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ category_id INTEGER NOT NULL,
84
+ categorizable_type VARCHAR(255) NOT NULL,
85
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
86
+ updated_at TIMESTAMP
87
+ )
88
+ `).execute()
89
+
90
+ const tableNames = ['categorizables', 'categorizable_models']
91
+
92
+ /**
93
+ * Wipe the trait tables between tests. DELETE (not DROP) keeps the
94
+ * schema warm.
95
+ */
96
+ export async function refreshDatabase(): Promise<void> {
97
+ await forceConfig()
98
+ for (const table of tableNames) {
99
+ await db.unsafe(`DELETE FROM ${table}`).execute()
100
+ }
101
+ }
102
+
103
+ // Cleanup on process exit, NOT in an `afterAll`: when this module is
104
+ // imported by a test file mid-run (e.g. `bun test tests/` from the
105
+ // repo root, where the path filter also matches this suite), an
106
+ // `afterAll` registered during module evaluation attaches to the FIRST
107
+ // importing file's scope and unlinks the shared DB while later files'
108
+ // connections still use it (SQLITE_IOERR_VNODE).
109
+ process.on('exit', () => {
110
+ for (const suffix of ['', '-wal', '-shm']) {
111
+ try {
112
+ if (existsSync(`${DB_PATH}${suffix}`))
113
+ unlinkSync(`${DB_PATH}${suffix}`)
114
+ }
115
+ catch {
116
+ // Best effort — pid-named file in tmpdir, the OS reclaims it.
117
+ }
118
+ }
119
+ releaseDbConfigLock()
120
+ })