@byline/db-postgres 3.10.1 → 3.11.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.
@@ -24,6 +24,15 @@ export declare class AuditQueries implements IAuditQueries {
24
24
  page?: number;
25
25
  page_size?: number;
26
26
  }): Promise<AuditLogPage>;
27
+ findAuditLog(params: {
28
+ actorId?: string;
29
+ collectionId?: string;
30
+ action?: string;
31
+ from?: Date;
32
+ to?: Date;
33
+ page?: number;
34
+ page_size?: number;
35
+ }): Promise<AuditLogPage>;
27
36
  }
28
37
  export declare function createAuditQueries(db: DatabaseConnection): AuditQueries;
29
38
  export {};
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
  import { desc, eq, sql } from 'drizzle-orm';
9
- import { auditLog } from '../../database/schema/index.js';
9
+ import { auditLog, documentVersions } from '../../database/schema/index.js';
10
10
  function toEntry(row) {
11
11
  return {
12
12
  id: row.id,
@@ -49,6 +49,78 @@ export class AuditQueries {
49
49
  meta: { total, page, pageSize, totalPages },
50
50
  };
51
51
  }
52
+ async findAuditLog(params) {
53
+ const page = params.page ?? 1;
54
+ const pageSize = params.page_size ?? 20;
55
+ const offset = (page - 1) * pageSize;
56
+ // The activity feed is the UNION of two disjoint event sources, normalised
57
+ // onto the audit-log column shape (see IAuditQueries.findAuditLog):
58
+ //
59
+ // 1. byline_document_versions — content saves. event_type maps to a
60
+ // `document.created` / `document.updated` action; created_by/created_at
61
+ // become actor_id/occurred_at; field/before/after are null. Restricted
62
+ // to create+update so any legacy 'delete' version rows can't surface
63
+ // (deletions live only in the audit log — the union double-counts
64
+ // nothing).
65
+ // 2. byline_audit_log — path/locale/status changes, deletions, and
66
+ // future admin-realm events, used as-is.
67
+ //
68
+ // Filters and ordering apply to the unioned result; occurred_at is the only
69
+ // cross-source sort key (the per-source UUIDv7 ids are separate sequences).
70
+ const union = sql `
71
+ SELECT id, document_id, collection_id, actor_id, actor_realm, action, field, before, after, occurred_at FROM (
72
+ SELECT
73
+ ${documentVersions.id} AS id,
74
+ ${documentVersions.document_id} AS document_id,
75
+ ${documentVersions.collection_id} AS collection_id,
76
+ ${documentVersions.created_by} AS actor_id,
77
+ CASE WHEN ${documentVersions.created_by} IS NULL THEN 'system' ELSE 'admin' END AS actor_realm,
78
+ CASE ${documentVersions.event_type}
79
+ WHEN 'create' THEN 'document.created'
80
+ WHEN 'update' THEN 'document.updated'
81
+ ELSE 'document.' || ${documentVersions.event_type}
82
+ END AS action,
83
+ NULL::varchar AS field,
84
+ NULL::jsonb AS before,
85
+ NULL::jsonb AS after,
86
+ ${documentVersions.created_at} AS occurred_at
87
+ FROM ${documentVersions}
88
+ WHERE ${documentVersions.event_type} IN ('create', 'update')
89
+ UNION ALL
90
+ SELECT
91
+ ${auditLog.id} AS id,
92
+ ${auditLog.document_id} AS document_id,
93
+ ${auditLog.collection_id} AS collection_id,
94
+ ${auditLog.actor_id} AS actor_id,
95
+ ${auditLog.actor_realm} AS actor_realm,
96
+ ${auditLog.action} AS action,
97
+ ${auditLog.field} AS field,
98
+ ${auditLog.before} AS before,
99
+ ${auditLog.after} AS after,
100
+ ${auditLog.occurred_at} AS occurred_at
101
+ FROM ${auditLog}
102
+ ) AS activity`;
103
+ const filters = [];
104
+ if (params.actorId)
105
+ filters.push(sql `actor_id = ${params.actorId}`);
106
+ if (params.collectionId)
107
+ filters.push(sql `collection_id = ${params.collectionId}`);
108
+ if (params.action)
109
+ filters.push(sql `action = ${params.action}`);
110
+ if (params.from)
111
+ filters.push(sql `occurred_at >= ${params.from}`);
112
+ if (params.to)
113
+ filters.push(sql `occurred_at <= ${params.to}`);
114
+ const whereClause = filters.length > 0 ? sql ` WHERE ${sql.join(filters, sql ` AND `)}` : sql ``;
115
+ const totalResult = await this.db.execute(sql `SELECT count(*) AS count FROM (${union}${whereClause}) AS filtered`);
116
+ const total = Number(totalResult.rows[0]?.count) || 0;
117
+ const totalPages = Math.ceil(total / pageSize);
118
+ const result = await this.db.execute(sql `${union}${whereClause} ORDER BY occurred_at DESC, id DESC LIMIT ${pageSize} OFFSET ${offset}`);
119
+ return {
120
+ entries: result.rows.map((row) => toEntry(row)),
121
+ meta: { total, page, pageSize, totalPages },
122
+ };
123
+ }
52
124
  }
53
125
  export function createAuditQueries(db) {
54
126
  return new AuditQueries(db);
@@ -0,0 +1,8 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ export {};
@@ -0,0 +1,175 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ /**
9
+ * Integration tests for the system-wide activity feed (docs/AUDIT.md — W4):
10
+ * `audit.findAuditLog`, the read-time UNION of the version stream (content
11
+ * saves → `document.created` / `document.updated`) and the audit log
12
+ * (everything else). Exercises both sources, the cross-source `occurred_at`
13
+ * ordering, every filter (actor / collection / action / date range), and
14
+ * pagination.
15
+ *
16
+ * The version-stream side requires real `collections` + `documents` FK rows,
17
+ * so the fixture seeds two collections and one document each before inserting
18
+ * version rows with controlled `created_at` timestamps.
19
+ */
20
+ import { v7 as uuidv7 } from 'uuid';
21
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
22
+ import { collections, documents, documentVersions } from '../../../database/schema/index.js';
23
+ import { setupTestDB, teardownTestDB } from '../../../lib/test-helper.js';
24
+ import { createAuditCommands } from '../audit-commands.js';
25
+ import { createAuditQueries } from '../audit-queries.js';
26
+ let auditCommands;
27
+ let auditQueries;
28
+ let db;
29
+ const colA = uuidv7();
30
+ const colB = uuidv7();
31
+ const docA = uuidv7();
32
+ const docB = uuidv7();
33
+ const actor1 = uuidv7();
34
+ const actor2 = uuidv7();
35
+ // Fixed version-save timestamps in the past, so the (now()-stamped) audit rows
36
+ // always sort ahead of them and the version rows have a deterministic order.
37
+ const t1 = new Date('2026-01-01T10:00:00.000Z'); // docA create
38
+ const t2 = new Date('2026-01-02T10:00:00.000Z'); // docB create
39
+ const t3 = new Date('2026-01-03T10:00:00.000Z'); // docA update
40
+ describe('audit findAuditLog (system activity union)', () => {
41
+ beforeAll(async () => {
42
+ const testDB = setupTestDB([]);
43
+ db = testDB.db;
44
+ auditCommands = createAuditCommands(testDB.dbManager);
45
+ auditQueries = createAuditQueries(testDB.db);
46
+ // Collection + document FK fixtures for the version-stream side.
47
+ await db.insert(collections).values([
48
+ {
49
+ id: colA,
50
+ path: 'articles',
51
+ singular: 'Article',
52
+ plural: 'Articles',
53
+ config: {},
54
+ version: 1,
55
+ },
56
+ { id: colB, path: 'pages', singular: 'Page', plural: 'Pages', config: {}, version: 1 },
57
+ ]);
58
+ await db.insert(documents).values([
59
+ { id: docA, collection_id: colA, source_locale: 'en' },
60
+ { id: docB, collection_id: colB, source_locale: 'en' },
61
+ ]);
62
+ // Version stream: two creates and one update. event_type drives the
63
+ // synthesised action; created_by/created_at become actor/occurred_at.
64
+ await db.insert(documentVersions).values([
65
+ {
66
+ id: uuidv7(),
67
+ document_id: docA,
68
+ collection_id: colA,
69
+ collection_version: 1,
70
+ event_type: 'create',
71
+ status: 'draft',
72
+ created_by: actor1,
73
+ created_at: t1,
74
+ },
75
+ {
76
+ id: uuidv7(),
77
+ document_id: docB,
78
+ collection_id: colB,
79
+ collection_version: 1,
80
+ event_type: 'create',
81
+ status: 'draft',
82
+ created_by: actor2,
83
+ created_at: t2,
84
+ },
85
+ {
86
+ id: uuidv7(),
87
+ document_id: docA,
88
+ collection_id: colA,
89
+ collection_version: 1,
90
+ event_type: 'update',
91
+ status: 'draft',
92
+ created_by: actor1,
93
+ created_at: t3,
94
+ },
95
+ ]);
96
+ // Audit log: a status change on docA (actor1) and a system deletion of docB.
97
+ await auditCommands.append({
98
+ documentId: docA,
99
+ collectionId: colA,
100
+ actorId: actor1,
101
+ actorRealm: 'admin',
102
+ action: 'document.status.changed',
103
+ field: 'status',
104
+ before: 'draft',
105
+ after: 'published',
106
+ });
107
+ await auditCommands.append({
108
+ documentId: docB,
109
+ collectionId: colB,
110
+ actorId: null,
111
+ actorRealm: 'system',
112
+ action: 'document.deleted',
113
+ });
114
+ });
115
+ afterAll(async () => {
116
+ await teardownTestDB();
117
+ });
118
+ it('unions version-stream + audit-log rows, newest first', async () => {
119
+ const page = await auditQueries.findAuditLog({});
120
+ expect(page.meta.total).toBe(5); // 3 version rows + 2 audit rows
121
+ // Audit rows are now()-stamped, so they lead; version rows follow by
122
+ // created_at DESC (t3 update, t2 docB create, t1 docA create).
123
+ const actions = page.entries.map((e) => e.action);
124
+ expect(actions.slice(-3)).toEqual([
125
+ 'document.updated', // t3
126
+ 'document.created', // t2 (docB)
127
+ 'document.created', // t1 (docA)
128
+ ]);
129
+ // A content-save row surfaces with the synthesised action, its actor, and
130
+ // no field/before/after diff.
131
+ const created = page.entries.find((e) => e.action === 'document.created' && e.documentId === docA);
132
+ expect(created?.actorId).toBe(actor1);
133
+ expect(created?.actorRealm).toBe('admin');
134
+ expect(created?.field).toBeNull();
135
+ expect(created?.before).toBeNull();
136
+ expect(created?.after).toBeNull();
137
+ });
138
+ it('filters by action', async () => {
139
+ const created = await auditQueries.findAuditLog({ action: 'document.created' });
140
+ expect(created.meta.total).toBe(2);
141
+ expect(created.entries.every((e) => e.action === 'document.created')).toBe(true);
142
+ const updated = await auditQueries.findAuditLog({ action: 'document.updated' });
143
+ expect(updated.meta.total).toBe(1);
144
+ });
145
+ it('filters by collection across both sources', async () => {
146
+ // colA: docA create + docA update (versions) + status change (audit) = 3.
147
+ const page = await auditQueries.findAuditLog({ collectionId: colA });
148
+ expect(page.meta.total).toBe(3);
149
+ expect(page.entries.every((e) => e.collectionId === colA)).toBe(true);
150
+ });
151
+ it('filters by actor across both sources', async () => {
152
+ // actor1: docA create + docA update (versions) + status change (audit) = 3.
153
+ const page = await auditQueries.findAuditLog({ actorId: actor1 });
154
+ expect(page.meta.total).toBe(3);
155
+ expect(page.entries.every((e) => e.actorId === actor1)).toBe(true);
156
+ });
157
+ it('filters by date range on occurred_at', async () => {
158
+ // Only the version rows fall in the fixed January window; the audit rows
159
+ // are now()-stamped and excluded.
160
+ const page = await auditQueries.findAuditLog({
161
+ from: new Date('2026-01-01T00:00:00.000Z'),
162
+ to: new Date('2026-01-31T23:59:59.000Z'),
163
+ });
164
+ expect(page.meta.total).toBe(3);
165
+ expect(page.entries.every((e) => e.action.startsWith('document.created') || e.action === 'document.updated')).toBe(true);
166
+ });
167
+ it('paginates', async () => {
168
+ const page1 = await auditQueries.findAuditLog({ page: 1, page_size: 2 });
169
+ expect(page1.entries).toHaveLength(2);
170
+ expect(page1.meta.total).toBe(5);
171
+ expect(page1.meta.totalPages).toBe(3);
172
+ const page3 = await auditQueries.findAuditLog({ page: 3, page_size: 2 });
173
+ expect(page3.entries).toHaveLength(1);
174
+ });
175
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@byline/db-postgres",
3
3
  "private": false,
4
4
  "license": "MPL-2.0",
5
- "version": "3.10.1",
5
+ "version": "3.11.0",
6
6
  "engines": {
7
7
  "node": ">=20.9.0"
8
8
  },
@@ -57,9 +57,9 @@
57
57
  "pg": "^8.21.0",
58
58
  "uuid": "^14.0.0",
59
59
  "zod": "^4.4.3",
60
- "@byline/admin": "3.10.1",
61
- "@byline/auth": "3.10.1",
62
- "@byline/core": "3.10.1"
60
+ "@byline/admin": "3.11.0",
61
+ "@byline/auth": "3.11.0",
62
+ "@byline/core": "3.11.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "2.4.15",