@actuate-media/cms-core 0.21.0 → 0.22.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 (105) hide show
  1. package/dist/__tests__/actions/document-crud.test.js +74 -2
  2. package/dist/__tests__/actions/document-crud.test.js.map +1 -1
  3. package/dist/__tests__/api/cache-headers.test.d.ts +2 -0
  4. package/dist/__tests__/api/cache-headers.test.d.ts.map +1 -0
  5. package/dist/__tests__/api/cache-headers.test.js +135 -0
  6. package/dist/__tests__/api/cache-headers.test.js.map +1 -0
  7. package/dist/__tests__/api/comments-routes.test.d.ts +2 -0
  8. package/dist/__tests__/api/comments-routes.test.d.ts.map +1 -0
  9. package/dist/__tests__/api/comments-routes.test.js +266 -0
  10. package/dist/__tests__/api/comments-routes.test.js.map +1 -0
  11. package/dist/__tests__/api/notifications-routes.test.d.ts +2 -0
  12. package/dist/__tests__/api/notifications-routes.test.d.ts.map +1 -0
  13. package/dist/__tests__/api/notifications-routes.test.js +225 -0
  14. package/dist/__tests__/api/notifications-routes.test.js.map +1 -0
  15. package/dist/__tests__/api/preview-and-scheduling.test.js +53 -0
  16. package/dist/__tests__/api/preview-and-scheduling.test.js.map +1 -1
  17. package/dist/__tests__/fields/relations.test.js +62 -8
  18. package/dist/__tests__/fields/relations.test.js.map +1 -1
  19. package/dist/__tests__/preview-event-bus.test.d.ts +2 -0
  20. package/dist/__tests__/preview-event-bus.test.d.ts.map +1 -0
  21. package/dist/__tests__/preview-event-bus.test.js +89 -0
  22. package/dist/__tests__/preview-event-bus.test.js.map +1 -0
  23. package/dist/__tests__/preview-sse-stream.test.d.ts +2 -0
  24. package/dist/__tests__/preview-sse-stream.test.d.ts.map +1 -0
  25. package/dist/__tests__/preview-sse-stream.test.js +117 -0
  26. package/dist/__tests__/preview-sse-stream.test.js.map +1 -0
  27. package/dist/__tests__/realtime/comments.test.d.ts +2 -0
  28. package/dist/__tests__/realtime/comments.test.d.ts.map +1 -0
  29. package/dist/__tests__/realtime/comments.test.js +508 -0
  30. package/dist/__tests__/realtime/comments.test.js.map +1 -0
  31. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts +2 -0
  32. package/dist/__tests__/realtime/gateway-ws-integration.test.d.ts.map +1 -0
  33. package/dist/__tests__/realtime/gateway-ws-integration.test.js +170 -0
  34. package/dist/__tests__/realtime/gateway-ws-integration.test.js.map +1 -0
  35. package/dist/__tests__/realtime/gateway.test.d.ts +2 -0
  36. package/dist/__tests__/realtime/gateway.test.d.ts.map +1 -0
  37. package/dist/__tests__/realtime/gateway.test.js +402 -0
  38. package/dist/__tests__/realtime/gateway.test.js.map +1 -0
  39. package/dist/__tests__/realtime/notifications.test.d.ts +2 -0
  40. package/dist/__tests__/realtime/notifications.test.d.ts.map +1 -0
  41. package/dist/__tests__/realtime/notifications.test.js +317 -0
  42. package/dist/__tests__/realtime/notifications.test.js.map +1 -0
  43. package/dist/__tests__/realtime/persistence.test.d.ts +2 -0
  44. package/dist/__tests__/realtime/persistence.test.d.ts.map +1 -0
  45. package/dist/__tests__/realtime/persistence.test.js +124 -0
  46. package/dist/__tests__/realtime/persistence.test.js.map +1 -0
  47. package/dist/__tests__/schema-preview-features.test.d.ts +2 -0
  48. package/dist/__tests__/schema-preview-features.test.d.ts.map +1 -0
  49. package/dist/__tests__/schema-preview-features.test.js +42 -0
  50. package/dist/__tests__/schema-preview-features.test.js.map +1 -0
  51. package/dist/actions.d.ts.map +1 -1
  52. package/dist/actions.js +71 -26
  53. package/dist/actions.js.map +1 -1
  54. package/dist/api/handlers.d.ts.map +1 -1
  55. package/dist/api/handlers.js +734 -188
  56. package/dist/api/handlers.js.map +1 -1
  57. package/dist/cache/__tests__/http.test.d.ts +2 -0
  58. package/dist/cache/__tests__/http.test.d.ts.map +1 -0
  59. package/dist/cache/__tests__/http.test.js +285 -0
  60. package/dist/cache/__tests__/http.test.js.map +1 -0
  61. package/dist/cache/http.d.ts +119 -0
  62. package/dist/cache/http.d.ts.map +1 -0
  63. package/dist/cache/http.js +188 -0
  64. package/dist/cache/http.js.map +1 -0
  65. package/dist/config/types.d.ts +92 -0
  66. package/dist/config/types.d.ts.map +1 -1
  67. package/dist/fields/relations.d.ts.map +1 -1
  68. package/dist/fields/relations.js +43 -11
  69. package/dist/fields/relations.js.map +1 -1
  70. package/dist/index.d.ts +5 -0
  71. package/dist/index.d.ts.map +1 -1
  72. package/dist/index.js +2 -0
  73. package/dist/index.js.map +1 -1
  74. package/dist/page-builder/schema.d.ts +2 -2
  75. package/dist/preview/event-bus.d.ts +39 -0
  76. package/dist/preview/event-bus.d.ts.map +1 -0
  77. package/dist/preview/event-bus.js +73 -0
  78. package/dist/preview/event-bus.js.map +1 -0
  79. package/dist/preview/sse-stream.d.ts +50 -0
  80. package/dist/preview/sse-stream.d.ts.map +1 -0
  81. package/dist/preview/sse-stream.js +120 -0
  82. package/dist/preview/sse-stream.js.map +1 -0
  83. package/dist/realtime/comments.d.ts +217 -0
  84. package/dist/realtime/comments.d.ts.map +1 -0
  85. package/dist/realtime/comments.js +282 -0
  86. package/dist/realtime/comments.js.map +1 -0
  87. package/dist/realtime/gateway.d.ts +107 -0
  88. package/dist/realtime/gateway.d.ts.map +1 -0
  89. package/dist/realtime/gateway.js +215 -0
  90. package/dist/realtime/gateway.js.map +1 -0
  91. package/dist/realtime/index.d.ts +18 -0
  92. package/dist/realtime/index.d.ts.map +1 -0
  93. package/dist/realtime/index.js +18 -0
  94. package/dist/realtime/index.js.map +1 -0
  95. package/dist/realtime/notifications.d.ts +209 -0
  96. package/dist/realtime/notifications.d.ts.map +1 -0
  97. package/dist/realtime/notifications.js +209 -0
  98. package/dist/realtime/notifications.js.map +1 -0
  99. package/dist/realtime/persistence.d.ts +72 -0
  100. package/dist/realtime/persistence.d.ts.map +1 -0
  101. package/dist/realtime/persistence.js +53 -0
  102. package/dist/realtime/persistence.js.map +1 -0
  103. package/package.json +13 -4
  104. package/prisma/cms-schema.prisma +135 -4
  105. package/prisma/schema.prisma +17 -3
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Per-user notifications driven by collaboration events.
3
+ *
4
+ * Owns the pure notification logic: kind discriminator, payload validation,
5
+ * permission gates, and a storage-agnostic CRUD surface. The HTTP layer
6
+ * (in `api/handlers.ts`) is a thin wrapper that handles auth + JSON
7
+ * envelope; the comments hook layer (slice 6, `notification-triggers.ts`)
8
+ * calls these helpers when a comment lifecycle event fires.
9
+ *
10
+ * Storage shape — see `packages/cms-core/prisma/cms-schema.prisma`, model
11
+ * `DocumentNotification`. We require only a minimal Prisma surface so the
12
+ * helpers can be tested against an in-memory fake.
13
+ *
14
+ * ## Discriminated payloads
15
+ *
16
+ * The `payload` Json column varies by `kind`. The TypeScript types here
17
+ * are the source of truth for each variant; the service helpers narrow
18
+ * by `kind` so callers can't accidentally insert a malformed payload.
19
+ *
20
+ * ## Mentions
21
+ *
22
+ * Slice 6 ships a deliberately simple mention parser: `@<userId>` tokens
23
+ * in a comment body trigger a `comment_mention` notification. A richer
24
+ * `@username` parser that resolves against the user table is a Phase 4
25
+ * follow-up — the storage shape already supports it.
26
+ */
27
+ /** Hard cap on returned notifications per `listForUser` call. */
28
+ export const MAX_NOTIFICATION_LIST_PAGE_SIZE = 200;
29
+ /** Default page size when the caller does not request one. */
30
+ export const DEFAULT_NOTIFICATION_LIST_PAGE_SIZE = 50;
31
+ export function toNotificationDTO(row) {
32
+ // The payload comes back as `unknown` from Prisma's Json column; we
33
+ // re-tag it with the `kind` discriminator so the DTO is strictly typed
34
+ // on the way out. Storage-level validation in `createNotification`
35
+ // guarantees the payload matches the kind.
36
+ const payload = {
37
+ kind: row.kind,
38
+ ...(row.payload ?? {}),
39
+ };
40
+ return {
41
+ id: row.id,
42
+ userId: row.userId,
43
+ kind: row.kind,
44
+ documentId: row.documentId,
45
+ commentId: row.commentId,
46
+ payload,
47
+ readAt: row.readAt ? row.readAt.toISOString() : null,
48
+ createdAt: row.createdAt.toISOString(),
49
+ };
50
+ }
51
+ const KIND_GUARDS = {
52
+ comment_reply: true,
53
+ comment_resolved: true,
54
+ comment_mention: true,
55
+ };
56
+ export function isNotificationKind(value) {
57
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(KIND_GUARDS, value);
58
+ }
59
+ function validatePayload(payload) {
60
+ if (!payload || typeof payload !== 'object') {
61
+ return { code: 'validation', message: 'payload must be an object' };
62
+ }
63
+ if (!isNotificationKind(payload.kind)) {
64
+ return { code: 'validation', message: `unknown kind "${String(payload.kind)}"` };
65
+ }
66
+ if (payload.kind === 'comment_reply' || payload.kind === 'comment_mention') {
67
+ if (typeof payload.excerpt !== 'string') {
68
+ return { code: 'validation', message: 'excerpt is required' };
69
+ }
70
+ }
71
+ if (payload.kind === 'comment_resolved') {
72
+ if (typeof payload.rootExcerpt !== 'string') {
73
+ return { code: 'validation', message: 'rootExcerpt is required' };
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ /**
79
+ * Insert a new notification. The payload's `kind` field is the source
80
+ * of truth; `kind` on the DB row is derived from it so callers can't
81
+ * drift them apart.
82
+ */
83
+ export async function createNotification(db, input) {
84
+ if (!input.userId || typeof input.userId !== 'string') {
85
+ return { ok: false, error: { code: 'validation', message: 'userId is required' } };
86
+ }
87
+ const validationError = validatePayload(input.payload);
88
+ if (validationError)
89
+ return { ok: false, error: validationError };
90
+ // Strip the kind tag from the stored JSON — it's already in the
91
+ // dedicated column.
92
+ const { kind, ...rest } = input.payload;
93
+ const row = await db.documentNotification.create({
94
+ data: {
95
+ userId: input.userId,
96
+ kind,
97
+ documentId: input.documentId ?? null,
98
+ commentId: input.commentId ?? null,
99
+ payload: rest,
100
+ readAt: null,
101
+ },
102
+ });
103
+ return { ok: true, value: toNotificationDTO(row) };
104
+ }
105
+ /** Resolve a notification by id, only if it belongs to `userId`. */
106
+ async function loadOwned(db, id, userId) {
107
+ const row = await db.documentNotification.findUnique({ where: { id } });
108
+ if (!row)
109
+ return { ok: false, error: { code: 'not_found', message: 'Notification not found' } };
110
+ if (row.userId !== userId) {
111
+ return { ok: false, error: { code: 'forbidden', message: 'Not your notification' } };
112
+ }
113
+ return { ok: true, value: row };
114
+ }
115
+ /**
116
+ * List notifications for a user, newest first. By default returns only
117
+ * unread; pass `includeRead: true` for the full feed (the bell dropdown
118
+ * uses both depending on the tab the user is on).
119
+ */
120
+ export async function listForUser(db, filter) {
121
+ if (!filter.userId) {
122
+ return { ok: false, error: { code: 'validation', message: 'userId is required' } };
123
+ }
124
+ const take = clampPageSize(filter.pageSize);
125
+ const where = { userId: filter.userId };
126
+ if (!filter.includeRead)
127
+ where.readAt = null;
128
+ const rows = await db.documentNotification.findMany({
129
+ where,
130
+ orderBy: { createdAt: 'desc' },
131
+ take,
132
+ });
133
+ return { ok: true, value: rows.map(toNotificationDTO) };
134
+ }
135
+ /** Count unread notifications for the bell badge. */
136
+ export async function unreadCount(db, userId) {
137
+ if (!userId) {
138
+ return { ok: false, error: { code: 'validation', message: 'userId is required' } };
139
+ }
140
+ const count = await db.documentNotification.count({
141
+ where: { userId, readAt: null },
142
+ });
143
+ return { ok: true, value: count };
144
+ }
145
+ /** Mark a single notification read (idempotent). */
146
+ export async function markRead(db, id, actor) {
147
+ if (!id) {
148
+ return { ok: false, error: { code: 'validation', message: 'id is required' } };
149
+ }
150
+ const loaded = await loadOwned(db, id, actor.userId);
151
+ if (!loaded.ok)
152
+ return loaded;
153
+ if (loaded.value.readAt) {
154
+ return { ok: true, value: toNotificationDTO(loaded.value) };
155
+ }
156
+ const updated = await db.documentNotification.update({
157
+ where: { id },
158
+ data: { readAt: new Date() },
159
+ });
160
+ return { ok: true, value: toNotificationDTO(updated) };
161
+ }
162
+ /** Mark every unread notification for the user as read. */
163
+ export async function markAllRead(db, userId) {
164
+ if (!userId) {
165
+ return { ok: false, error: { code: 'validation', message: 'userId is required' } };
166
+ }
167
+ const out = await db.documentNotification.updateMany({
168
+ where: { userId, readAt: null },
169
+ data: { readAt: new Date() },
170
+ });
171
+ return { ok: true, value: { count: out.count } };
172
+ }
173
+ function clampPageSize(requested) {
174
+ if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) {
175
+ return DEFAULT_NOTIFICATION_LIST_PAGE_SIZE;
176
+ }
177
+ return Math.min(Math.max(1, Math.floor(requested)), MAX_NOTIFICATION_LIST_PAGE_SIZE);
178
+ }
179
+ /**
180
+ * Cheap excerpt builder for notification payloads. Markdown is kept as-is;
181
+ * we just trim whitespace and clip to 120 chars so the bell can render
182
+ * a single-line preview.
183
+ */
184
+ export function excerptForNotification(body, max = 120) {
185
+ const cleaned = body.replace(/\s+/g, ' ').trim();
186
+ if (cleaned.length <= max)
187
+ return cleaned;
188
+ return `${cleaned.slice(0, max - 1)}…`;
189
+ }
190
+ /**
191
+ * Extract `@<userId>` mentions from a comment body. We intentionally
192
+ * match the *user id* form here (`@user_abc123`) — a slice-7 follow-up
193
+ * can wire up `@username` resolution against the User table.
194
+ *
195
+ * Returns a deduplicated array of mentioned user ids; the caller filters
196
+ * out the actor before issuing notifications.
197
+ */
198
+ export function extractMentionedUserIds(body) {
199
+ if (typeof body !== 'string' || body.length === 0)
200
+ return [];
201
+ const seen = new Set();
202
+ const re = /@([a-zA-Z0-9_-]{3,64})\b/g;
203
+ let match;
204
+ while ((match = re.exec(body)) !== null) {
205
+ seen.add(match[1]);
206
+ }
207
+ return Array.from(seen);
208
+ }
209
+ //# sourceMappingURL=notifications.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notifications.js","sourceRoot":"","sources":["../../src/realtime/notifications.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Prisma-backed `DocumentPersistence` for `@actuate-media/realtime`.
3
+ *
4
+ * Reads and writes the `DocumentCRDT` table (see
5
+ * `prisma/cms-schema.prisma`). The adapter is intentionally narrow — it only
6
+ * implements the two methods declared by the realtime package, so it can be
7
+ * swapped for an S3 / Redis / Postgres-LO adapter without touching the
8
+ * gateway code.
9
+ *
10
+ * The `save` path also keeps the `stateVector` column in sync so future
11
+ * versions of the gateway can answer `SyncStep1` without ever booting a
12
+ * `Y.Doc` (a future optimisation for hot-path late joins).
13
+ */
14
+ import type { DocumentPersistence } from '@actuate-media/realtime';
15
+ /** Minimal subset of the Prisma client the persistence adapter needs. */
16
+ export interface PrismaDocumentCRDTClient {
17
+ documentCRDT: {
18
+ findUnique(args: {
19
+ where: {
20
+ documentId: string;
21
+ };
22
+ select?: {
23
+ state?: boolean;
24
+ stateVector?: boolean;
25
+ version?: boolean;
26
+ };
27
+ }): Promise<{
28
+ state: Uint8Array;
29
+ stateVector?: Uint8Array;
30
+ version?: number;
31
+ } | null>;
32
+ upsert(args: {
33
+ where: {
34
+ documentId: string;
35
+ };
36
+ create: {
37
+ documentId: string;
38
+ state: Uint8Array;
39
+ stateVector: Uint8Array;
40
+ version: number;
41
+ lastUpdatedById?: string | null;
42
+ };
43
+ update: {
44
+ state: Uint8Array;
45
+ stateVector: Uint8Array;
46
+ version: {
47
+ increment: number;
48
+ };
49
+ lastUpdatedById?: string | null;
50
+ };
51
+ }): Promise<{
52
+ documentId: string;
53
+ version: number;
54
+ }>;
55
+ };
56
+ }
57
+ export interface PrismaDocumentPersistenceOptions {
58
+ /**
59
+ * Optional default editor id used for `lastUpdatedById` when the gateway
60
+ * does not supply one. In production the gateway derives this from the
61
+ * authenticated session per save.
62
+ */
63
+ lastUpdatedById?: string | null;
64
+ }
65
+ /**
66
+ * Create a `DocumentPersistence` backed by a `DocumentCRDT` Prisma table.
67
+ *
68
+ * The returned object satisfies the realtime package's contract so it can
69
+ * be passed straight to `bindPersistence(room, persistence)`.
70
+ */
71
+ export declare function createPrismaDocumentPersistence(db: PrismaDocumentCRDTClient, options?: PrismaDocumentPersistenceOptions): DocumentPersistence;
72
+ //# sourceMappingURL=persistence.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/realtime/persistence.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,yBAAyB,CAAA;AAGlE,yEAAyE;AACzE,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE;QACZ,UAAU,CAAC,IAAI,EAAE;YACf,KAAK,EAAE;gBAAE,UAAU,EAAE,MAAM,CAAA;aAAE,CAAA;YAC7B,MAAM,CAAC,EAAE;gBAAE,KAAK,CAAC,EAAE,OAAO,CAAC;gBAAC,WAAW,CAAC,EAAE,OAAO,CAAC;gBAAC,OAAO,CAAC,EAAE,OAAO,CAAA;aAAE,CAAA;SACvE,GAAG,OAAO,CAAC;YAAE,KAAK,EAAE,UAAU,CAAC;YAAC,WAAW,CAAC,EAAE,UAAU,CAAC;YAAC,OAAO,CAAC,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAC,CAAA;QACrF,MAAM,CAAC,IAAI,EAAE;YACX,KAAK,EAAE;gBAAE,UAAU,EAAE,MAAM,CAAA;aAAE,CAAA;YAC7B,MAAM,EAAE;gBACN,UAAU,EAAE,MAAM,CAAA;gBAClB,KAAK,EAAE,UAAU,CAAA;gBACjB,WAAW,EAAE,UAAU,CAAA;gBACvB,OAAO,EAAE,MAAM,CAAA;gBACf,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;aAChC,CAAA;YACD,MAAM,EAAE;gBACN,KAAK,EAAE,UAAU,CAAA;gBACjB,WAAW,EAAE,UAAU,CAAA;gBACvB,OAAO,EAAE;oBAAE,SAAS,EAAE,MAAM,CAAA;iBAAE,CAAA;gBAC9B,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;aAChC,CAAA;SACF,GAAG,OAAO,CAAC;YAAE,UAAU,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KACrD,CAAA;CACF;AAED,MAAM,WAAW,gCAAgC;IAC/C;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAChC;AAED;;;;;GAKG;AACH,wBAAgB,+BAA+B,CAC7C,EAAE,EAAE,wBAAwB,EAC5B,OAAO,GAAE,gCAAqC,GAC7C,mBAAmB,CAwCrB"}
@@ -0,0 +1,53 @@
1
+ import * as Y from 'yjs';
2
+ /**
3
+ * Create a `DocumentPersistence` backed by a `DocumentCRDT` Prisma table.
4
+ *
5
+ * The returned object satisfies the realtime package's contract so it can
6
+ * be passed straight to `bindPersistence(room, persistence)`.
7
+ */
8
+ export function createPrismaDocumentPersistence(db, options = {}) {
9
+ return {
10
+ async load(documentId) {
11
+ const row = await db.documentCRDT.findUnique({
12
+ where: { documentId },
13
+ select: { state: true },
14
+ });
15
+ if (!row)
16
+ return null;
17
+ // Defensive copy: callers may mutate the buffer once we return it
18
+ // (Prisma typically gives back a fresh Uint8Array, but we don't want
19
+ // to rely on internal guarantees here).
20
+ return copyBytes(row.state);
21
+ },
22
+ async save(documentId, state) {
23
+ // Compute the new state vector from the encoded state. Cheap — Yjs
24
+ // decodes the update structure but skips construction of clients.
25
+ const doc = new Y.Doc();
26
+ Y.applyUpdate(doc, state);
27
+ const stateVector = Y.encodeStateVector(doc);
28
+ doc.destroy();
29
+ await db.documentCRDT.upsert({
30
+ where: { documentId },
31
+ create: {
32
+ documentId,
33
+ state: copyBytes(state),
34
+ stateVector,
35
+ version: 1,
36
+ lastUpdatedById: options.lastUpdatedById ?? null,
37
+ },
38
+ update: {
39
+ state: copyBytes(state),
40
+ stateVector,
41
+ version: { increment: 1 },
42
+ lastUpdatedById: options.lastUpdatedById ?? null,
43
+ },
44
+ });
45
+ },
46
+ };
47
+ }
48
+ function copyBytes(src) {
49
+ const dst = new Uint8Array(src.length);
50
+ dst.set(src);
51
+ return dst;
52
+ }
53
+ //# sourceMappingURL=persistence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persistence.js","sourceRoot":"","sources":["../../src/realtime/persistence.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AAqCxB;;;;;GAKG;AACH,MAAM,UAAU,+BAA+B,CAC7C,EAA4B,EAC5B,UAA4C,EAAE;IAE9C,OAAO;QACL,KAAK,CAAC,IAAI,CAAC,UAAU;YACnB,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,YAAY,CAAC,UAAU,CAAC;gBAC3C,KAAK,EAAE,EAAE,UAAU,EAAE;gBACrB,MAAM,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE;aACxB,CAAC,CAAA;YACF,IAAI,CAAC,GAAG;gBAAE,OAAO,IAAI,CAAA;YACrB,kEAAkE;YAClE,qEAAqE;YACrE,wCAAwC;YACxC,OAAO,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QAC7B,CAAC;QAED,KAAK,CAAC,IAAI,CAAC,UAAU,EAAE,KAAK;YAC1B,mEAAmE;YACnE,kEAAkE;YAClE,MAAM,GAAG,GAAG,IAAI,CAAC,CAAC,GAAG,EAAE,CAAA;YACvB,CAAC,CAAC,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YACzB,MAAM,WAAW,GAAG,CAAC,CAAC,iBAAiB,CAAC,GAAG,CAAC,CAAA;YAC5C,GAAG,CAAC,OAAO,EAAE,CAAA;YAEb,MAAM,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC;gBAC3B,KAAK,EAAE,EAAE,UAAU,EAAE;gBACrB,MAAM,EAAE;oBACN,UAAU;oBACV,KAAK,EAAE,SAAS,CAAC,KAAK,CAAC;oBACvB,WAAW;oBACX,OAAO,EAAE,CAAC;oBACV,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,IAAI;iBACjD;gBACD,MAAM,EAAE;oBACN,KAAK,EAAE,SAAS,CAAC,KAAK,CAAC;oBACvB,WAAW;oBACX,OAAO,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE;oBACzB,eAAe,EAAE,OAAO,CAAC,eAAe,IAAI,IAAI;iBACjD;aACF,CAAC,CAAA;QACJ,CAAC;KACF,CAAA;AACH,CAAC;AAED,SAAS,SAAS,CAAC,GAAe;IAChC,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;IACtC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IACZ,OAAO,GAAG,CAAA;AACZ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-core",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -59,6 +59,11 @@
59
59
  "import": "./dist/page-builder/index.js",
60
60
  "default": "./dist/page-builder/index.js"
61
61
  },
62
+ "./realtime": {
63
+ "types": "./dist/realtime/index.d.ts",
64
+ "import": "./dist/realtime/index.js",
65
+ "default": "./dist/realtime/index.js"
66
+ },
62
67
  "./generated/client": {
63
68
  "types": "./generated/client.d.ts",
64
69
  "import": "./generated/client.js",
@@ -74,18 +79,22 @@
74
79
  "graphql-yoga": "^5.19.0",
75
80
  "jose": "^6.0.0",
76
81
  "pg": "^8.20.0",
77
- "sanitize-html": "^2.17.3",
82
+ "sanitize-html": "^2.17.4",
78
83
  "sharp": "^0.34.5",
79
- "zod": "^3.23.0"
84
+ "yjs": "^13.6.0",
85
+ "zod": "^3.23.0",
86
+ "@actuate-media/realtime": "0.1.0"
80
87
  },
81
88
  "devDependencies": {
82
89
  "@types/sanitize-html": "^2.16.1",
83
90
  "@types/sharp": "^0.32.0",
91
+ "@types/ws": "^8.5.10",
84
92
  "dotenv": "^17.3.1",
85
93
  "prisma": "^7.8.0",
86
94
  "tsx": "^4.0.0",
87
95
  "typescript": "^5.7.0",
88
- "vitest": "^3.0.0"
96
+ "vitest": "^3.0.0",
97
+ "ws": "^8.18.0"
89
98
  },
90
99
  "files": [
91
100
  "dist",
@@ -60,12 +60,14 @@ model User {
60
60
  updatedAt DateTime @updatedAt
61
61
 
62
62
  sessions Session[]
63
- documentsCreated Document[] @relation("DocumentCreatedBy")
64
- documentsUpdated Document[] @relation("DocumentUpdatedBy")
65
- documentsReviewed Document[] @relation("DocumentReviewer")
63
+ documentsCreated Document[] @relation("DocumentCreatedBy")
64
+ documentsUpdated Document[] @relation("DocumentUpdatedBy")
65
+ documentsReviewed Document[] @relation("DocumentReviewer")
66
66
  versions Version[]
67
- mediaUploaded Media[] @relation("MediaUploadedBy")
67
+ mediaUploaded Media[] @relation("MediaUploadedBy")
68
68
  auditLogs AuditLog[]
69
+ comments DocumentComment[]
70
+ notifications DocumentNotification[]
69
71
 
70
72
  @@index([role])
71
73
  @@index([isActive])
@@ -304,3 +306,132 @@ model SavedSection {
304
306
 
305
307
  @@index([category])
306
308
  }
309
+
310
+ /// Persisted Yjs state for a document, used by `@actuate-media/realtime`.
311
+ ///
312
+ /// Each row holds the encoded Yjs document (`Y.encodeStateAsUpdate`) plus a
313
+ /// version vector so consumers can serve incremental sync to late joiners
314
+ /// without replaying full history. There is at most one row per `documentId`;
315
+ /// it is upserted on every persistence flush from a live collaboration room.
316
+ ///
317
+ /// The model is *optional*: an Actuate deployment that does not use the
318
+ /// realtime collaboration features can leave this table empty. Cms-core only
319
+ /// touches it when the `/realtime/sync` endpoint is hit.
320
+ model DocumentCRDT {
321
+ /// References `Document.id`. We don't declare a Prisma relation here so
322
+ /// the realtime stack stays decoupled from the `Document` schema; the
323
+ /// constraint is enforced at the application layer.
324
+ documentId String @id
325
+ /// Yjs `encodeStateAsUpdate` bytes. Use bytea on Postgres.
326
+ state Bytes
327
+ /// Yjs `encodeStateVector` bytes — lets us reply to a `SyncStep1` without
328
+ /// decoding the full doc when persistence is hot but the room is cold.
329
+ stateVector Bytes
330
+ /// Monotonic counter for optimistic locking (incremented on every save).
331
+ version Int @default(0)
332
+ /// Wall-clock of the most recent flush.
333
+ lastUpdatedAt DateTime @default(now()) @updatedAt
334
+ /// User id of the most recent editor, if known.
335
+ lastUpdatedById String?
336
+ createdAt DateTime @default(now())
337
+
338
+ @@index([lastUpdatedAt])
339
+ }
340
+
341
+ /// Threaded inline comment anchored to a Yjs document range.
342
+ ///
343
+ /// Comments survive concurrent edits because the `anchor` field stores Yjs
344
+ /// *relative positions* (the binary blob you get from
345
+ /// `Y.createRelativePositionFromTypeIndex` followed by
346
+ /// `Y.encodeRelativePosition`) rather than absolute offsets. Slice 5 binds
347
+ /// these back to live ranges in the TipTap document.
348
+ ///
349
+ /// Threading is single-level: a top-level comment has `parentId == null`,
350
+ /// replies set `parentId` to the root comment's id. Deletes cascade so a
351
+ /// resolved or removed thread cleans up its replies.
352
+ ///
353
+ /// The model is *optional*. Deployments that don't enable Phase 3 simply
354
+ /// leave the table empty; the cms-core API stays serviceable without it.
355
+ model DocumentComment {
356
+ id String @id @default(cuid())
357
+ /// References `Document.id`. Like `DocumentCRDT`, we keep the foreign
358
+ /// constraint at the application layer so the realtime stack stays
359
+ /// decoupled from the core `Document` schema.
360
+ documentId String
361
+ /// `null` for top-level comments; the root comment's id for replies.
362
+ /// Replies cannot themselves be parents — slice 5 enforces this in the
363
+ /// API handler.
364
+ parentId String?
365
+ parent DocumentComment? @relation("CommentThread", fields: [parentId], references: [id], onDelete: Cascade)
366
+ replies DocumentComment[] @relation("CommentThread")
367
+ /// Author of the comment. We don't cascade on user deletion so an
368
+ /// orphaned comment is preserved with a null `userId`; the UI can then
369
+ /// render "deleted user" without losing thread history.
370
+ userId String?
371
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
372
+ /// Markdown body (sanitized server-side before render).
373
+ body String @db.Text
374
+ /// Yjs relative-position JSON: `{ from: number[]; to: number[] }` where
375
+ /// each entry is the Base64-encoded `encodeRelativePosition` bytes.
376
+ /// Stored as JSON (not Bytes) so the API can return it without an
377
+ /// extra encoding hop. May be null for document-level (un-anchored)
378
+ /// comments.
379
+ anchor Json?
380
+ /// When set, the thread is resolved. UI hides resolved threads by
381
+ /// default but they remain in the table for audit.
382
+ resolvedAt DateTime?
383
+ /// User that resolved the thread, if any.
384
+ resolvedById String?
385
+ /// Soft delete for moderation; row is preserved so replies don't lose
386
+ /// their parent.
387
+ deletedAt DateTime?
388
+ createdAt DateTime @default(now())
389
+ updatedAt DateTime @updatedAt
390
+
391
+ @@index([documentId])
392
+ @@index([documentId, resolvedAt])
393
+ @@index([parentId])
394
+ @@index([userId])
395
+ @@index([createdAt])
396
+ }
397
+
398
+ // ─── Notifications (slice 6 — Phase 3) ─────────────────────────────────────────
399
+ //
400
+ // Per-user notification feed driven by collaboration events. Slice 6 wires
401
+ // the comment lifecycle (reply, resolve, mention) into this table so editors
402
+ // see "someone replied to your thread" without polling the comments API.
403
+ //
404
+ // The model is intentionally generic — the `kind` discriminator + `payload`
405
+ // JSON blob keeps the schema stable while future slices add new notification
406
+ // types (e.g. publish workflow transitions, document share invites).
407
+
408
+ model DocumentNotification {
409
+ id String @id @default(cuid())
410
+ /// Recipient. Cascade-deletes on user removal — we don't want orphan
411
+ /// notifications pointing at gone-users in the dropdown.
412
+ userId String
413
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
414
+ /// Discriminator. Slice 6 ships:
415
+ /// - "comment_reply" — someone replied to a thread you authored
416
+ /// - "comment_resolved" — your thread was resolved by another user
417
+ /// - "comment_mention" — your @handle was mentioned in a comment body
418
+ /// Future slices can extend this without a migration.
419
+ kind String
420
+ /// Optional document scope so the bell can group / link notifications.
421
+ documentId String?
422
+ /// Optional comment scope (the comment that triggered the notification).
423
+ commentId String?
424
+ /// `{ actorId, actorName, excerpt?, ... }`. Shape depends on `kind`;
425
+ /// the service module documents each variant.
426
+ payload Json
427
+ /// `null` until the user marks the notification read. We keep read rows
428
+ /// in the table so the dropdown can show recent activity, but the bell
429
+ /// counts only `readAt = null` rows.
430
+ readAt DateTime?
431
+ createdAt DateTime @default(now())
432
+
433
+ @@index([userId, readAt])
434
+ @@index([userId, createdAt])
435
+ @@index([documentId])
436
+ @@index([commentId])
437
+ }
@@ -1,7 +1,21 @@
1
1
  generator client {
2
- provider = "prisma-client"
3
- output = "../generated"
4
- previewFeatures = ["fullTextSearchPostgres"]
2
+ provider = "prisma-client"
3
+ output = "../generated"
4
+ // `relationJoins` is the Preview-feature flag that makes
5
+ // `relationLoadStrategy: 'join'` a recognised argument on
6
+ // findUnique / findMany / findFirst. Without it the option is
7
+ // silently rejected as an "Unknown argument" by Prisma — which
8
+ // is how the optimisation in PR #132 (dashboard recent-docs +
9
+ // versions list + bench `doc_get`) went unnoticed in production:
10
+ // `safeFindMany` swallowed the rejection and returned `[]`, the
11
+ // versions endpoint returned 500, and the bench reported "100/100
12
+ // errors" with no attached cause. Phase 5.5 #5 surfaced the
13
+ // failure once the bench harness started recording the first
14
+ // error message.
15
+ //
16
+ // The feature has been Preview since Prisma 5.7 and is documented
17
+ // at https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries#relation-load-strategies
18
+ previewFeatures = ["fullTextSearchPostgres", "relationJoins"]
5
19
  }
6
20
 
7
21
  datasource db {