@bernierllc/content-storage-adapter 1.0.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.
Files changed (54) hide show
  1. package/.eslintrc.json +22 -0
  2. package/README.md +350 -0
  3. package/__tests__/ContentBatchOperations.test.ts +130 -0
  4. package/__tests__/ContentStorageAdapter.test.ts +604 -0
  5. package/__tests__/mocks/MockDatabaseAdapter.test.ts +97 -0
  6. package/__tests__/mocks/MockDatabaseAdapter.ts +85 -0
  7. package/dist/ContentBatchOperations.d.ts +22 -0
  8. package/dist/ContentBatchOperations.d.ts.map +1 -0
  9. package/dist/ContentBatchOperations.js +60 -0
  10. package/dist/ContentBatchOperations.js.map +1 -0
  11. package/dist/ContentStorageAdapter.d.ts +75 -0
  12. package/dist/ContentStorageAdapter.d.ts.map +1 -0
  13. package/dist/ContentStorageAdapter.js +382 -0
  14. package/dist/ContentStorageAdapter.js.map +1 -0
  15. package/dist/errors/ContentNotFoundError.d.ts +7 -0
  16. package/dist/errors/ContentNotFoundError.d.ts.map +1 -0
  17. package/dist/errors/ContentNotFoundError.js +21 -0
  18. package/dist/errors/ContentNotFoundError.js.map +1 -0
  19. package/dist/errors/DuplicateSlugError.d.ts +7 -0
  20. package/dist/errors/DuplicateSlugError.d.ts.map +1 -0
  21. package/dist/errors/DuplicateSlugError.js +21 -0
  22. package/dist/errors/DuplicateSlugError.js.map +1 -0
  23. package/dist/errors/OptimisticLockError.d.ts +7 -0
  24. package/dist/errors/OptimisticLockError.d.ts.map +1 -0
  25. package/dist/errors/OptimisticLockError.js +21 -0
  26. package/dist/errors/OptimisticLockError.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +27 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/models/IContent.d.ts +92 -0
  32. package/dist/models/IContent.d.ts.map +1 -0
  33. package/dist/models/IContent.js +42 -0
  34. package/dist/models/IContent.js.map +1 -0
  35. package/dist/models/IContentFilters.d.ts +29 -0
  36. package/dist/models/IContentFilters.d.ts.map +1 -0
  37. package/dist/models/IContentFilters.js +10 -0
  38. package/dist/models/IContentFilters.js.map +1 -0
  39. package/dist/models/IContentVersion.d.ts +15 -0
  40. package/dist/models/IContentVersion.d.ts.map +1 -0
  41. package/dist/models/IContentVersion.js +10 -0
  42. package/dist/models/IContentVersion.js.map +1 -0
  43. package/jest.config.cjs +30 -0
  44. package/package.json +38 -0
  45. package/src/ContentBatchOperations.ts +72 -0
  46. package/src/ContentStorageAdapter.ts +476 -0
  47. package/src/errors/ContentNotFoundError.ts +17 -0
  48. package/src/errors/DuplicateSlugError.ts +17 -0
  49. package/src/errors/OptimisticLockError.ts +17 -0
  50. package/src/index.ts +33 -0
  51. package/src/models/IContent.ts +122 -0
  52. package/src/models/IContentFilters.ts +38 -0
  53. package/src/models/IContentVersion.ts +22 -0
  54. package/tsconfig.json +26 -0
package/.eslintrc.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "parser": "@typescript-eslint/parser",
3
+ "extends": [
4
+ "eslint:recommended",
5
+ "plugin:@typescript-eslint/recommended"
6
+ ],
7
+ "plugins": ["@typescript-eslint"],
8
+ "parserOptions": {
9
+ "ecmaVersion": 2020,
10
+ "sourceType": "module"
11
+ },
12
+ "rules": {
13
+ "@typescript-eslint/no-explicit-any": "error",
14
+ "@typescript-eslint/explicit-function-return-type": "warn",
15
+ "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
16
+ "no-console": "warn"
17
+ },
18
+ "env": {
19
+ "node": true,
20
+ "jest": true
21
+ }
22
+ }
package/README.md ADDED
@@ -0,0 +1,350 @@
1
+ # @bernierllc/content-storage-adapter
2
+
3
+ Type-safe CRUD operations for content management with automatic soft-delete handling, version tracking, and optimistic locking.
4
+
5
+ ## Features
6
+
7
+ - **Type-Safe CRUD Operations** - Fully typed content models with TypeScript strict mode
8
+ - **Automatic Soft-Delete** - All deletes are logical (deleted_at timestamp)
9
+ - **Optimistic Locking** - Concurrent edit detection via updated_at
10
+ - **Version History** - Track content changes over time
11
+ - **Unique Slug Generation** - SEO-friendly URLs with automatic uniqueness
12
+ - **Advanced Filtering** - Search by status, type, date range, and more
13
+ - **Batch Operations** - Bulk update, delete, and restore
14
+ - **Pagination Support** - Efficient list operations with hasMore indicator
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install @bernierllc/content-storage-adapter
20
+ ```
21
+
22
+ ## Dependencies
23
+
24
+ - `@bernierllc/database-adapter-core` - Abstract database interface
25
+ - `@bernierllc/slug-generator` - SEO-friendly URL generation
26
+
27
+ ## Usage
28
+
29
+ ### Basic CRUD Operations
30
+
31
+ ```typescript
32
+ import { ContentStorageAdapter, ContentType, ContentStatus } from '@bernierllc/content-storage-adapter';
33
+ import { PostgreSQLAdapter } from '@bernierllc/database-adapter-postgresql';
34
+
35
+ // Initialize database adapter
36
+ const dbAdapter = new PostgreSQLAdapter(config);
37
+ await dbAdapter.connect();
38
+
39
+ // Create storage adapter
40
+ const storage = new ContentStorageAdapter(dbAdapter);
41
+
42
+ // Create content
43
+ const content = await storage.create({
44
+ userId: 'user-123',
45
+ title: 'My First Post',
46
+ body: 'Hello, world!',
47
+ excerpt: 'A brief introduction',
48
+ type: ContentType.BLOG,
49
+ status: ContentStatus.DRAFT
50
+ });
51
+
52
+ // Read content
53
+ const retrieved = await storage.getById(content.id);
54
+ const bySlug = await storage.getBySlug('my-first-post');
55
+
56
+ // Update content
57
+ const updated = await storage.update(content.id, {
58
+ status: ContentStatus.PUBLISHED,
59
+ publishedAt: new Date()
60
+ });
61
+
62
+ // Soft delete
63
+ await storage.delete(content.id);
64
+
65
+ // Restore deleted content
66
+ const restored = await storage.restore(content.id);
67
+ ```
68
+
69
+ ### Optimistic Locking
70
+
71
+ Prevent concurrent edit conflicts:
72
+
73
+ ```typescript
74
+ const content = await storage.getById('content-id');
75
+
76
+ try {
77
+ // Update with expected timestamp
78
+ const updated = await storage.update(
79
+ content.id,
80
+ { title: 'Updated Title' },
81
+ content.updatedAt
82
+ );
83
+ } catch (error) {
84
+ if (error instanceof OptimisticLockError) {
85
+ // Content was modified by another user
86
+ console.error('Conflict detected! Reload and try again.');
87
+ }
88
+ }
89
+ ```
90
+
91
+ ### Advanced Filtering
92
+
93
+ ```typescript
94
+ import { IContentFilters } from '@bernierllc/content-storage-adapter';
95
+
96
+ const filters: IContentFilters = {
97
+ userId: 'user-123',
98
+ status: [ContentStatus.PUBLISHED, ContentStatus.SCHEDULED],
99
+ type: ContentType.BLOG,
100
+ publishedAfter: new Date('2024-01-01'),
101
+ searchQuery: 'TypeScript',
102
+ limit: 20,
103
+ offset: 0,
104
+ orderBy: 'publishedAt',
105
+ orderDirection: 'DESC'
106
+ };
107
+
108
+ const result = await storage.list(filters);
109
+
110
+ console.log(`Found ${result.total} items`);
111
+ console.log(`Has more: ${result.hasMore}`);
112
+ result.data.forEach(item => console.log(item.title));
113
+ ```
114
+
115
+ ### Batch Operations
116
+
117
+ ```typescript
118
+ import { ContentBatchOperations } from '@bernierllc/content-storage-adapter';
119
+
120
+ const batchOps = new ContentBatchOperations(storage);
121
+
122
+ // Bulk update status
123
+ const updated = await batchOps.bulkUpdateStatus(
124
+ ['id1', 'id2', 'id3'],
125
+ ContentStatus.ARCHIVED
126
+ );
127
+ console.log(`Updated ${updated} items`);
128
+
129
+ // Bulk delete
130
+ const deleted = await batchOps.bulkDelete(['id4', 'id5']);
131
+
132
+ // Bulk restore
133
+ const restored = await batchOps.bulkRestore(['id4', 'id5']);
134
+ ```
135
+
136
+ ### Version History
137
+
138
+ ```typescript
139
+ // Get all versions
140
+ const versions = await storage.getVersions('content-id');
141
+ versions.forEach(v => {
142
+ console.log(`Version ${v.versionNumber}: ${v.title}`);
143
+ console.log(`Changed by ${v.createdBy} at ${v.createdAt}`);
144
+ });
145
+
146
+ // Get specific version
147
+ const version2 = await storage.getVersion('content-id', 2);
148
+ ```
149
+
150
+ ## API
151
+
152
+ ### ContentStorageAdapter
153
+
154
+ #### `create(data: IContentCreateData): Promise<IContent>`
155
+
156
+ Create new content with automatic slug generation.
157
+
158
+ #### `getById(id: string, options?: { includeDeleted?: boolean }): Promise<IContent | null>`
159
+
160
+ Get content by ID. Excludes soft-deleted content by default.
161
+
162
+ #### `getBySlug(slug: string): Promise<IContent | null>`
163
+
164
+ Get content by URL slug.
165
+
166
+ #### `update(id: string, data: IContentUpdateData, expectedUpdatedAt?: Date): Promise<IContent>`
167
+
168
+ Update content. Optional `expectedUpdatedAt` enables optimistic locking.
169
+
170
+ #### `delete(id: string): Promise<void>`
171
+
172
+ Soft delete content (sets deleted_at timestamp).
173
+
174
+ #### `hardDelete(id: string): Promise<void>`
175
+
176
+ Permanently delete content. Use sparingly!
177
+
178
+ #### `restore(id: string): Promise<IContent>`
179
+
180
+ Restore soft-deleted content.
181
+
182
+ #### `list(filters?: IContentFilters): Promise<IPaginatedResult<IContent>>`
183
+
184
+ List content with filtering, pagination, and ordering.
185
+
186
+ #### `getVersions(contentId: string): Promise<IContentVersion[]>`
187
+
188
+ Get all versions of content, ordered by version number descending.
189
+
190
+ #### `getVersion(contentId: string, versionNumber: number): Promise<IContentVersion | null>`
191
+
192
+ Get specific content version.
193
+
194
+ ### ContentBatchOperations
195
+
196
+ #### `bulkUpdateStatus(ids: string[], status: ContentStatus): Promise<number>`
197
+
198
+ Update status for multiple content items. Returns count of updated items.
199
+
200
+ #### `bulkDelete(ids: string[]): Promise<number>`
201
+
202
+ Soft delete multiple items. Returns count of deleted items.
203
+
204
+ #### `bulkRestore(ids: string[]): Promise<number>`
205
+
206
+ Restore multiple soft-deleted items. Returns count of restored items.
207
+
208
+ ## Types
209
+
210
+ ### ContentType
211
+
212
+ ```typescript
213
+ enum ContentType {
214
+ BLOG = 'blog',
215
+ ARTICLE = 'article',
216
+ PAGE = 'page',
217
+ SOCIAL = 'social'
218
+ }
219
+ ```
220
+
221
+ ### ContentFormat
222
+
223
+ ```typescript
224
+ enum ContentFormat {
225
+ MARKDOWN = 'markdown',
226
+ HTML = 'html',
227
+ PLAINTEXT = 'plaintext'
228
+ }
229
+ ```
230
+
231
+ ### ContentStatus
232
+
233
+ ```typescript
234
+ enum ContentStatus {
235
+ DRAFT = 'draft',
236
+ IN_REVIEW = 'in_review',
237
+ APPROVED = 'approved',
238
+ SCHEDULED = 'scheduled',
239
+ PUBLISHED = 'published',
240
+ ARCHIVED = 'archived'
241
+ }
242
+ ```
243
+
244
+ ## Error Handling
245
+
246
+ ```typescript
247
+ import {
248
+ ContentNotFoundError,
249
+ OptimisticLockError,
250
+ DuplicateSlugError
251
+ } from '@bernierllc/content-storage-adapter';
252
+
253
+ try {
254
+ await storage.update('invalid-id', { title: 'New Title' });
255
+ } catch (error) {
256
+ if (error instanceof ContentNotFoundError) {
257
+ console.error('Content not found or already deleted');
258
+ } else if (error instanceof OptimisticLockError) {
259
+ console.error('Content was modified by another user');
260
+ }
261
+ }
262
+ ```
263
+
264
+ ## Database Schema
265
+
266
+ The adapter expects the following PostgreSQL schema:
267
+
268
+ ```sql
269
+ CREATE TABLE content (
270
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
271
+ user_id VARCHAR(255) NOT NULL,
272
+ title TEXT NOT NULL,
273
+ slug VARCHAR(255) UNIQUE NOT NULL,
274
+ body TEXT NOT NULL,
275
+ excerpt TEXT,
276
+ type VARCHAR(50) NOT NULL,
277
+ format VARCHAR(50) NOT NULL,
278
+ status VARCHAR(50) NOT NULL,
279
+ workflow_stage VARCHAR(50),
280
+ assigned_to VARCHAR(255),
281
+ ai_reviewed BOOLEAN DEFAULT FALSE,
282
+ ai_review_score FLOAT,
283
+ ai_provider VARCHAR(100),
284
+ published_at TIMESTAMP,
285
+ scheduled_for TIMESTAMP,
286
+ deleted_at TIMESTAMP,
287
+ meta_title TEXT,
288
+ meta_description TEXT,
289
+ keywords TEXT[],
290
+ view_count INTEGER DEFAULT 0,
291
+ share_count INTEGER DEFAULT 0,
292
+ generated_from_commit BOOLEAN DEFAULT FALSE,
293
+ source_commit_sha VARCHAR(255),
294
+ source_repository VARCHAR(500),
295
+ created_at TIMESTAMP DEFAULT NOW(),
296
+ updated_at TIMESTAMP DEFAULT NOW()
297
+ );
298
+
299
+ CREATE TABLE content_versions (
300
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
301
+ content_id UUID NOT NULL REFERENCES content(id),
302
+ version_number INTEGER NOT NULL,
303
+ title TEXT NOT NULL,
304
+ body TEXT NOT NULL,
305
+ change_summary TEXT,
306
+ is_autosave BOOLEAN DEFAULT FALSE,
307
+ created_at TIMESTAMP DEFAULT NOW(),
308
+ created_by VARCHAR(255) NOT NULL,
309
+ UNIQUE(content_id, version_number)
310
+ );
311
+
312
+ CREATE INDEX idx_content_user_id ON content(user_id);
313
+ CREATE INDEX idx_content_slug ON content(slug);
314
+ CREATE INDEX idx_content_status ON content(status);
315
+ CREATE INDEX idx_content_deleted_at ON content(deleted_at);
316
+ CREATE INDEX idx_content_versions_content_id ON content_versions(content_id);
317
+ ```
318
+
319
+ ## Testing
320
+
321
+ ```bash
322
+ # Run tests
323
+ npm test
324
+
325
+ # Run tests without watch
326
+ npm run test:run
327
+
328
+ # Run tests with coverage
329
+ npm run test:coverage
330
+
331
+ # Build TypeScript
332
+ npm run build
333
+
334
+ # Lint code
335
+ npm run lint
336
+ ```
337
+
338
+ ## License
339
+
340
+ Copyright (c) 2025 Bernier LLC
341
+
342
+ This file is licensed to the client under a limited-use license.
343
+ The client may use and modify this code *only within the scope of the project it was delivered for*.
344
+ Redistribution or use in other products or commercial offerings is not permitted without written consent from Bernier LLC.
345
+
346
+ ## Related Packages
347
+
348
+ - [@bernierllc/database-adapter-core](../database-adapter-core) - Abstract database interface
349
+ - [@bernierllc/slug-generator](../slug-generator) - SEO-friendly URL generation
350
+ - [@bernierllc/content-storage-service](../../service/content-storage-service) - Higher-level storage orchestration
@@ -0,0 +1,130 @@
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 '../src/ContentStorageAdapter';
10
+ import { ContentBatchOperations } from '../src/ContentBatchOperations';
11
+ import { ContentStatus, ContentType, ContentFormat } from '../src/models/IContent';
12
+ import { MockDatabaseAdapter } from './mocks/MockDatabaseAdapter';
13
+ import { IDatabaseAdapter } from '@bernierllc/database-adapter-core';
14
+
15
+ describe('ContentBatchOperations', () => {
16
+ let batchOps: ContentBatchOperations;
17
+ let adapter: ContentStorageAdapter;
18
+ let dbAdapter: MockDatabaseAdapter;
19
+
20
+ beforeEach(async () => {
21
+ dbAdapter = new MockDatabaseAdapter();
22
+ await dbAdapter.connect();
23
+ adapter = new ContentStorageAdapter(dbAdapter as unknown as IDatabaseAdapter);
24
+ batchOps = new ContentBatchOperations(adapter);
25
+ });
26
+
27
+ afterEach(async () => {
28
+ await dbAdapter.disconnect();
29
+ dbAdapter.clearMocks();
30
+ });
31
+
32
+ describe('bulkUpdateStatus', () => {
33
+ it('should bulk update status', async () => {
34
+ const mockContent1 = {
35
+ id: '123',
36
+ user_id: 'user1',
37
+ title: 'Test Title',
38
+ slug: 'test-title',
39
+ body: 'Test body',
40
+ type: ContentType.BLOG,
41
+ format: ContentFormat.MARKDOWN,
42
+ status: ContentStatus.PUBLISHED,
43
+ ai_reviewed: false,
44
+ view_count: 0,
45
+ share_count: 0,
46
+ generated_from_commit: false,
47
+ created_at: new Date(),
48
+ updated_at: new Date()
49
+ };
50
+
51
+ // Mock each individual update query
52
+ dbAdapter.setQueryResult(
53
+ 'update content set status = $1, updated_at = now() where id = $2 and deleted_at is null returning *',
54
+ { rows: [mockContent1], rowCount: 1 }
55
+ );
56
+
57
+ const result = await batchOps.bulkUpdateStatus(
58
+ ['123', '456', '789'],
59
+ ContentStatus.PUBLISHED
60
+ );
61
+
62
+ expect(result).toBe(3);
63
+ });
64
+
65
+ it('should handle partial failures gracefully', async () => {
66
+ // First update succeeds
67
+ dbAdapter.setQueryResult(
68
+ 'update content set status = $1, updated_at = now() where id = $2 and deleted_at is null returning *',
69
+ { rows: [{ id: '123' }], rowCount: 1 }
70
+ );
71
+
72
+ const result = await batchOps.bulkUpdateStatus(
73
+ ['123', '456'],
74
+ ContentStatus.PUBLISHED
75
+ );
76
+
77
+ // Should continue even if some fail
78
+ expect(result).toBeGreaterThanOrEqual(0);
79
+ expect(result).toBeLessThanOrEqual(2);
80
+ });
81
+ });
82
+
83
+ describe('bulkDelete', () => {
84
+ it('should bulk delete', async () => {
85
+ dbAdapter.setQueryResult(
86
+ 'UPDATE content SET deleted_at = NOW() WHERE id = ANY($1) AND deleted_at IS NULL',
87
+ { rows: [], rowCount: 3 }
88
+ );
89
+
90
+ const result = await batchOps.bulkDelete(['123', '456', '789']);
91
+
92
+ expect(result).toBe(3);
93
+ });
94
+
95
+ it('should return count of deleted items', async () => {
96
+ dbAdapter.setQueryResult(
97
+ 'UPDATE content SET deleted_at = NOW() WHERE id = ANY($1) AND deleted_at IS NULL',
98
+ { rows: [], rowCount: 2 }
99
+ );
100
+
101
+ const result = await batchOps.bulkDelete(['123', '456']);
102
+
103
+ expect(result).toBe(2);
104
+ });
105
+ });
106
+
107
+ describe('bulkRestore', () => {
108
+ it('should bulk restore', async () => {
109
+ dbAdapter.setQueryResult(
110
+ 'UPDATE content SET deleted_at = NULL WHERE id = ANY($1) AND deleted_at IS NOT NULL',
111
+ { rows: [], rowCount: 3 }
112
+ );
113
+
114
+ const result = await batchOps.bulkRestore(['123', '456', '789']);
115
+
116
+ expect(result).toBe(3);
117
+ });
118
+
119
+ it('should return count of restored items', async () => {
120
+ dbAdapter.setQueryResult(
121
+ 'UPDATE content SET deleted_at = NULL WHERE id = ANY($1) AND deleted_at IS NOT NULL',
122
+ { rows: [], rowCount: 1 }
123
+ );
124
+
125
+ const result = await batchOps.bulkRestore(['123']);
126
+
127
+ expect(result).toBe(1);
128
+ });
129
+ });
130
+ });