@bernierllc/content-storage-adapter 1.2.5 → 1.4.0

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.
@@ -1,85 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import { IDatabaseAdapter, IQueryResult } from '@bernierllc/database-adapter-core';
10
-
11
- /**
12
- * Mock database adapter for testing
13
- */
14
- export class MockDatabaseAdapter implements IDatabaseAdapter {
15
- private queryResults: Map<string, IQueryResult<Record<string, unknown>>> = new Map();
16
- private connected = false;
17
-
18
- async connect(): Promise<void> {
19
- this.connected = true;
20
- }
21
-
22
- async disconnect(): Promise<void> {
23
- this.connected = false;
24
- }
25
-
26
- async query<T = Record<string, unknown>>(
27
- sql: string,
28
- _params?: unknown[]
29
- ): Promise<IQueryResult<T>> {
30
- if (!this.connected) {
31
- throw new Error('Database not connected');
32
- }
33
-
34
- // Find matching mock result
35
- const key = this.generateKey(sql);
36
- const result = this.queryResults.get(key);
37
-
38
- if (!result) {
39
- // Return empty result for unregistered queries
40
- return {
41
- rows: [],
42
- rowCount: 0
43
- } as IQueryResult<T>;
44
- }
45
-
46
- return result as IQueryResult<T>;
47
- }
48
-
49
- async beginTransaction(): Promise<void> {
50
- // Mock implementation
51
- }
52
-
53
- async commit(): Promise<void> {
54
- // Mock implementation
55
- }
56
-
57
- async rollback(): Promise<void> {
58
- // Mock implementation
59
- }
60
-
61
- /**
62
- * Set mock result for a query pattern
63
- */
64
- setQueryResult<T = Record<string, unknown>>(
65
- sqlPattern: string,
66
- result: IQueryResult<T>
67
- ): void {
68
- const key = this.generateKey(sqlPattern);
69
- this.queryResults.set(key, result as IQueryResult<Record<string, unknown>>);
70
- }
71
-
72
- /**
73
- * Clear all mock results
74
- */
75
- clearMocks(): void {
76
- this.queryResults.clear();
77
- }
78
-
79
- /**
80
- * Generate key from SQL pattern (normalize whitespace)
81
- */
82
- private generateKey(sql: string): string {
83
- return sql.replace(/\s+/g, ' ').trim().toLowerCase();
84
- }
85
- }
package/jest.config.cjs DELETED
@@ -1,30 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- module.exports = {
10
- preset: 'ts-jest',
11
- testEnvironment: '<rootDir>/../../../jest-environment-node-fixed.cjs',
12
- roots: ['<rootDir>/__tests__'],
13
- testMatch: ['**/*.test.ts'],
14
- collectCoverageFrom: [
15
- 'src/**/*.ts',
16
- '!src/**/*.d.ts',
17
- '!src/**/index.ts'
18
- ],
19
- coverageThreshold: {
20
- global: {
21
- branches: 90,
22
- functions: 90,
23
- lines: 90,
24
- statements: 90
25
- }
26
- },
27
- moduleNameMapper: {
28
- '^@/(.*)$': '<rootDir>/src/$1'
29
- }
30
- };
@@ -1,72 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import { ContentStorageAdapter } from './ContentStorageAdapter';
10
- import { ContentStatus } from './models/IContent';
11
-
12
- /**
13
- * Batch operations for content management
14
- */
15
- export class ContentBatchOperations {
16
- constructor(private adapter: ContentStorageAdapter) {}
17
-
18
- /**
19
- * Bulk update status for multiple content items
20
- */
21
- async bulkUpdateStatus(
22
- ids: string[],
23
- status: ContentStatus
24
- ): Promise<number> {
25
- let updated = 0;
26
-
27
- for (const id of ids) {
28
- try {
29
- await this.adapter.update(id, { status });
30
- updated++;
31
- } catch (error) {
32
- // Continue on individual failures
33
- }
34
- }
35
-
36
- return updated;
37
- }
38
-
39
- /**
40
- * Bulk soft delete
41
- */
42
- async bulkDelete(ids: string[]): Promise<number> {
43
- const db = (this.adapter as unknown as { db: { query: (sql: string, params: unknown[]) => Promise<{ rowCount: number }> } }).db;
44
- const result = await db.query(
45
- `
46
- UPDATE content
47
- SET deleted_at = NOW()
48
- WHERE id = ANY($1) AND deleted_at IS NULL
49
- `,
50
- [ids]
51
- );
52
-
53
- return result.rowCount;
54
- }
55
-
56
- /**
57
- * Bulk restore
58
- */
59
- async bulkRestore(ids: string[]): Promise<number> {
60
- const db = (this.adapter as unknown as { db: { query: (sql: string, params: unknown[]) => Promise<{ rowCount: number }> } }).db;
61
- const result = await db.query(
62
- `
63
- UPDATE content
64
- SET deleted_at = NULL
65
- WHERE id = ANY($1) AND deleted_at IS NOT NULL
66
- `,
67
- [ids]
68
- );
69
-
70
- return result.rowCount;
71
- }
72
- }
@@ -1,476 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- import { IDatabaseAdapter } from '@bernierllc/database-adapter-core';
10
- import { generateSlug } from '@bernierllc/slug-generator';
11
- import {
12
- IContent,
13
- IContentCreateData,
14
- IContentUpdateData,
15
- ContentType,
16
- ContentFormat,
17
- ContentStatus
18
- } from './models/IContent';
19
- import { IContentVersion } from './models/IContentVersion';
20
- import { IContentFilters, IPaginatedResult } from './models/IContentFilters';
21
- import { ContentNotFoundError } from './errors/ContentNotFoundError';
22
- import { OptimisticLockError } from './errors/OptimisticLockError';
23
-
24
- /**
25
- * Content storage adapter
26
- * Provides type-safe CRUD operations for content management
27
- */
28
- export class ContentStorageAdapter {
29
- constructor(private db: IDatabaseAdapter) {}
30
-
31
- /**
32
- * Create new content with automatic slug generation
33
- */
34
- async create(data: IContentCreateData): Promise<IContent> {
35
- // Generate unique slug from title
36
- const baseSlug = generateSlug(data.title);
37
- const slug = await this.ensureUniqueSlug(baseSlug);
38
-
39
- const result = await this.db.query<Record<string, unknown>>(
40
- `
41
- INSERT INTO content (
42
- user_id, title, slug, body, excerpt, type, format, status,
43
- meta_title, meta_description, keywords,
44
- ai_reviewed, view_count, share_count, generated_from_commit
45
- ) VALUES (
46
- $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
47
- )
48
- RETURNING *
49
- `,
50
- [
51
- data.userId,
52
- data.title,
53
- slug,
54
- data.body,
55
- data.excerpt || null,
56
- data.type || ContentType.BLOG,
57
- data.format || ContentFormat.MARKDOWN,
58
- data.status || ContentStatus.DRAFT,
59
- data.metaTitle || null,
60
- data.metaDescription || null,
61
- data.keywords || [],
62
- false, // ai_reviewed
63
- 0, // view_count
64
- 0, // share_count
65
- false // generated_from_commit
66
- ]
67
- );
68
-
69
- return this.mapRowToContent(result.rows[0]);
70
- }
71
-
72
- /**
73
- * Get content by ID (with soft-delete check)
74
- */
75
- async getById(
76
- id: string,
77
- options: { includeDeleted?: boolean } = {}
78
- ): Promise<IContent | null> {
79
- const whereClause = options.includeDeleted
80
- ? 'id = $1'
81
- : 'id = $1 AND deleted_at IS NULL';
82
-
83
- const result = await this.db.query<Record<string, unknown>>(
84
- `SELECT * FROM content WHERE ${whereClause}`,
85
- [id]
86
- );
87
-
88
- return result.rows.length > 0
89
- ? this.mapRowToContent(result.rows[0])
90
- : null;
91
- }
92
-
93
- /**
94
- * Get content by slug
95
- */
96
- async getBySlug(slug: string): Promise<IContent | null> {
97
- const result = await this.db.query<Record<string, unknown>>(
98
- 'SELECT * FROM content WHERE slug = $1 AND deleted_at IS NULL',
99
- [slug]
100
- );
101
-
102
- return result.rows.length > 0
103
- ? this.mapRowToContent(result.rows[0])
104
- : null;
105
- }
106
-
107
- /**
108
- * Update content with optimistic locking
109
- */
110
- async update(
111
- id: string,
112
- data: IContentUpdateData,
113
- expectedUpdatedAt?: Date
114
- ): Promise<IContent> {
115
- // Build dynamic UPDATE query
116
- const updates: string[] = [];
117
- const params: unknown[] = [];
118
- let paramIndex = 1;
119
-
120
- if (data.title !== undefined) {
121
- updates.push(`title = $${paramIndex++}`);
122
- params.push(data.title);
123
- }
124
- if (data.body !== undefined) {
125
- updates.push(`body = $${paramIndex++}`);
126
- params.push(data.body);
127
- }
128
- if (data.excerpt !== undefined) {
129
- updates.push(`excerpt = $${paramIndex++}`);
130
- params.push(data.excerpt);
131
- }
132
- if (data.status !== undefined) {
133
- updates.push(`status = $${paramIndex++}`);
134
- params.push(data.status);
135
- }
136
- if (data.workflowStage !== undefined) {
137
- updates.push(`workflow_stage = $${paramIndex++}`);
138
- params.push(data.workflowStage);
139
- }
140
- if (data.assignedTo !== undefined) {
141
- updates.push(`assigned_to = $${paramIndex++}`);
142
- params.push(data.assignedTo);
143
- }
144
- if (data.publishedAt !== undefined) {
145
- updates.push(`published_at = $${paramIndex++}`);
146
- params.push(data.publishedAt);
147
- }
148
- if (data.scheduledFor !== undefined) {
149
- updates.push(`scheduled_for = $${paramIndex++}`);
150
- params.push(data.scheduledFor);
151
- }
152
- if (data.metaTitle !== undefined) {
153
- updates.push(`meta_title = $${paramIndex++}`);
154
- params.push(data.metaTitle);
155
- }
156
- if (data.metaDescription !== undefined) {
157
- updates.push(`meta_description = $${paramIndex++}`);
158
- params.push(data.metaDescription);
159
- }
160
- if (data.keywords !== undefined) {
161
- updates.push(`keywords = $${paramIndex++}`);
162
- params.push(data.keywords);
163
- }
164
-
165
- if (updates.length === 0) {
166
- throw new Error('No fields to update');
167
- }
168
-
169
- // Add optimistic locking check
170
- params.push(id);
171
- const idParam = paramIndex++;
172
-
173
- let whereClause = `id = $${idParam} AND deleted_at IS NULL`;
174
- if (expectedUpdatedAt) {
175
- params.push(expectedUpdatedAt);
176
- whereClause += ` AND updated_at = $${paramIndex++}`;
177
- }
178
-
179
- const sql = `
180
- UPDATE content
181
- SET ${updates.join(', ')}, updated_at = NOW()
182
- WHERE ${whereClause}
183
- RETURNING *
184
- `;
185
-
186
- const result = await this.db.query<Record<string, unknown>>(sql, params);
187
-
188
- if (result.rows.length === 0) {
189
- if (expectedUpdatedAt) {
190
- throw new OptimisticLockError(id);
191
- }
192
- throw new ContentNotFoundError(id);
193
- }
194
-
195
- return this.mapRowToContent(result.rows[0]);
196
- }
197
-
198
- /**
199
- * Soft delete content
200
- */
201
- async delete(id: string): Promise<void> {
202
- const result = await this.db.query(
203
- `
204
- UPDATE content
205
- SET deleted_at = NOW()
206
- WHERE id = $1 AND deleted_at IS NULL
207
- `,
208
- [id]
209
- );
210
-
211
- if (result.rowCount === 0) {
212
- throw new ContentNotFoundError(id);
213
- }
214
- }
215
-
216
- /**
217
- * Permanently delete content (use sparingly)
218
- */
219
- async hardDelete(id: string): Promise<void> {
220
- await this.db.query('DELETE FROM content WHERE id = $1', [id]);
221
- }
222
-
223
- /**
224
- * Restore soft-deleted content
225
- */
226
- async restore(id: string): Promise<IContent> {
227
- const result = await this.db.query<Record<string, unknown>>(
228
- `
229
- UPDATE content
230
- SET deleted_at = NULL
231
- WHERE id = $1 AND deleted_at IS NOT NULL
232
- RETURNING *
233
- `,
234
- [id]
235
- );
236
-
237
- if (result.rows.length === 0) {
238
- throw new Error('Content not found or not deleted');
239
- }
240
-
241
- return this.mapRowToContent(result.rows[0]);
242
- }
243
-
244
- /**
245
- * List content with filters and pagination
246
- */
247
- async list(
248
- filters: IContentFilters = {}
249
- ): Promise<IPaginatedResult<IContent>> {
250
- // Build WHERE clause
251
- const conditions: string[] = [];
252
- const params: unknown[] = [];
253
- let paramIndex = 1;
254
-
255
- if (!filters.includeDeleted) {
256
- conditions.push('deleted_at IS NULL');
257
- }
258
-
259
- if (filters.userId) {
260
- conditions.push(`user_id = $${paramIndex++}`);
261
- params.push(filters.userId);
262
- }
263
-
264
- if (filters.status) {
265
- if (Array.isArray(filters.status)) {
266
- conditions.push(`status = ANY($${paramIndex++})`);
267
- params.push(filters.status);
268
- } else {
269
- conditions.push(`status = $${paramIndex++}`);
270
- params.push(filters.status);
271
- }
272
- }
273
-
274
- if (filters.type) {
275
- if (Array.isArray(filters.type)) {
276
- conditions.push(`type = ANY($${paramIndex++})`);
277
- params.push(filters.type);
278
- } else {
279
- conditions.push(`type = $${paramIndex++}`);
280
- params.push(filters.type);
281
- }
282
- }
283
-
284
- if (filters.workflowStage) {
285
- conditions.push(`workflow_stage = $${paramIndex++}`);
286
- params.push(filters.workflowStage);
287
- }
288
-
289
- if (filters.publishedAfter) {
290
- conditions.push(`published_at >= $${paramIndex++}`);
291
- params.push(filters.publishedAfter);
292
- }
293
-
294
- if (filters.publishedBefore) {
295
- conditions.push(`published_at <= $${paramIndex++}`);
296
- params.push(filters.publishedBefore);
297
- }
298
-
299
- if (filters.searchQuery) {
300
- conditions.push(`(
301
- title ILIKE $${paramIndex} OR
302
- body ILIKE $${paramIndex} OR
303
- excerpt ILIKE $${paramIndex}
304
- )`);
305
- params.push(`%${filters.searchQuery}%`);
306
- paramIndex++;
307
- }
308
-
309
- const whereClause = conditions.length > 0
310
- ? `WHERE ${conditions.join(' AND ')}`
311
- : '';
312
-
313
- // Build ORDER BY
314
- const orderBy = filters.orderBy || 'createdAt';
315
- const orderDirection = filters.orderDirection || 'DESC';
316
- const orderClause = `ORDER BY ${this.mapOrderField(orderBy)} ${orderDirection}`;
317
-
318
- // Pagination
319
- const limit = filters.limit || 20;
320
- const offset = filters.offset || 0;
321
-
322
- // Get total count
323
- const countResult = await this.db.query<{ count: string }>(
324
- `SELECT COUNT(*) as count FROM content ${whereClause}`,
325
- params
326
- );
327
- const total = parseInt(countResult.rows[0].count);
328
-
329
- // Get data
330
- params.push(limit, offset);
331
- const dataResult = await this.db.query<Record<string, unknown>>(
332
- `
333
- SELECT * FROM content
334
- ${whereClause}
335
- ${orderClause}
336
- LIMIT $${paramIndex++} OFFSET $${paramIndex++}
337
- `,
338
- params
339
- );
340
-
341
- return {
342
- data: dataResult.rows.map(row => this.mapRowToContent(row)),
343
- total,
344
- limit,
345
- offset,
346
- hasMore: offset + limit < total
347
- };
348
- }
349
-
350
- /**
351
- * Get content versions
352
- */
353
- async getVersions(contentId: string): Promise<IContentVersion[]> {
354
- const result = await this.db.query<Record<string, unknown>>(
355
- `
356
- SELECT * FROM content_versions
357
- WHERE content_id = $1
358
- ORDER BY version_number DESC
359
- `,
360
- [contentId]
361
- );
362
-
363
- return result.rows.map(row => this.mapRowToVersion(row));
364
- }
365
-
366
- /**
367
- * Get specific version
368
- */
369
- async getVersion(
370
- contentId: string,
371
- versionNumber: number
372
- ): Promise<IContentVersion | null> {
373
- const result = await this.db.query<Record<string, unknown>>(
374
- `
375
- SELECT * FROM content_versions
376
- WHERE content_id = $1 AND version_number = $2
377
- `,
378
- [contentId, versionNumber]
379
- );
380
-
381
- return result.rows.length > 0
382
- ? this.mapRowToVersion(result.rows[0])
383
- : null;
384
- }
385
-
386
- /**
387
- * Ensure slug is unique by appending number if needed
388
- */
389
- private async ensureUniqueSlug(baseSlug: string): Promise<string> {
390
- let slug = baseSlug;
391
- let counter = 1;
392
-
393
- while (await this.slugExists(slug)) {
394
- slug = `${baseSlug}-${counter}`;
395
- counter++;
396
- }
397
-
398
- return slug;
399
- }
400
-
401
- /**
402
- * Check if slug exists
403
- */
404
- private async slugExists(slug: string): Promise<boolean> {
405
- const result = await this.db.query<{ exists: boolean }>(
406
- 'SELECT EXISTS(SELECT 1 FROM content WHERE slug = $1 AND deleted_at IS NULL) as exists',
407
- [slug]
408
- );
409
- return result.rows[0].exists;
410
- }
411
-
412
- /**
413
- * Map database row to content model
414
- */
415
- private mapRowToContent(row: Record<string, unknown>): IContent {
416
- return {
417
- id: row.id as string,
418
- userId: row.user_id as string,
419
- title: row.title as string,
420
- slug: row.slug as string,
421
- body: row.body as string,
422
- excerpt: row.excerpt as string | undefined,
423
- type: row.type as ContentType,
424
- format: row.format as ContentFormat,
425
- status: row.status as ContentStatus,
426
- workflowStage: row.workflow_stage as string | undefined,
427
- assignedTo: row.assigned_to as string | undefined,
428
- aiReviewed: row.ai_reviewed as boolean,
429
- aiReviewScore: row.ai_review_score as number | undefined,
430
- aiProvider: row.ai_provider as string | undefined,
431
- publishedAt: row.published_at as Date | undefined,
432
- scheduledFor: row.scheduled_for as Date | undefined,
433
- deletedAt: row.deleted_at as Date | undefined,
434
- metaTitle: row.meta_title as string | undefined,
435
- metaDescription: row.meta_description as string | undefined,
436
- keywords: row.keywords as string[] | undefined,
437
- viewCount: row.view_count as number,
438
- shareCount: row.share_count as number,
439
- generatedFromCommit: row.generated_from_commit as boolean,
440
- sourceCommitSha: row.source_commit_sha as string | undefined,
441
- sourceRepository: row.source_repository as string | undefined,
442
- createdAt: row.created_at as Date,
443
- updatedAt: row.updated_at as Date
444
- };
445
- }
446
-
447
- /**
448
- * Map database row to version model
449
- */
450
- private mapRowToVersion(row: Record<string, unknown>): IContentVersion {
451
- return {
452
- id: row.id as string,
453
- contentId: row.content_id as string,
454
- versionNumber: row.version_number as number,
455
- title: row.title as string,
456
- body: row.body as string,
457
- changeSummary: row.change_summary as string | undefined,
458
- isAutosave: row.is_autosave as boolean,
459
- createdAt: row.created_at as Date,
460
- createdBy: row.created_by as string
461
- };
462
- }
463
-
464
- /**
465
- * Map order field to database column
466
- */
467
- private mapOrderField(field: string): string {
468
- const fieldMap: Record<string, string> = {
469
- createdAt: 'created_at',
470
- updatedAt: 'updated_at',
471
- publishedAt: 'published_at',
472
- title: 'title'
473
- };
474
- return fieldMap[field] || 'created_at';
475
- }
476
- }
@@ -1,17 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- /**
10
- * Error thrown when content is not found
11
- */
12
- export class ContentNotFoundError extends Error {
13
- constructor(id: string) {
14
- super(`Content not found or already deleted: ${id}`);
15
- this.name = 'ContentNotFoundError';
16
- }
17
- }
@@ -1,17 +0,0 @@
1
- /*
2
- Copyright (c) 2025 Bernier LLC
3
-
4
- This file is licensed to the client under a limited-use license.
5
- The client may use and modify this code *only within the scope of the project it was delivered for*.
6
- Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
7
- */
8
-
9
- /**
10
- * Error thrown when slug already exists
11
- */
12
- export class DuplicateSlugError extends Error {
13
- constructor(slug: string) {
14
- super(`Slug already exists: ${slug}`);
15
- this.name = 'DuplicateSlugError';
16
- }
17
- }