@friggframework/core 2.0.0--canary.580.4487187.0 → 2.0.0--canary.580.235db2b.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.
package/CLAUDE.md CHANGED
@@ -212,6 +212,14 @@ packages/core/
212
212
  - `integration-repository-mongo.js` - MongoDB implementation
213
213
  - `integration-repository-postgres.js` - PostgreSQL implementation
214
214
  - `integration-mapping-repository-*.js` - Mapping data persistence
215
+ - `process-repository-*.js` - Process (long-running job) persistence.
216
+ Implements `applyProcessUpdate(processId, ops)` — a race-safe alternative
217
+ to `update(id, patch)` that routes increments, sets, and bounded-array
218
+ pushes through each backend's native atomic primitive (PostgreSQL
219
+ `jsonb_set` / MongoDB `$inc`/`$set`/`$push`). Use this method any time
220
+ multiple queue workers may concurrently mutate the same Process row
221
+ (counters, flags, error history). The legacy `update(id, patch)`
222
+ remains available but is clobber-prone under concurrency.
215
223
 
216
224
  **Integration developers extend IntegrationBase**:
217
225
 
@@ -14,6 +14,7 @@ const {
14
14
  const {
15
15
  DocumentDBEncryptionService,
16
16
  } = require('../../database/documentdb-encryption-service');
17
+ const { validateOps } = require('./process-update-ops-shared');
17
18
 
18
19
  class ProcessRepositoryDocumentDB extends ProcessRepositoryInterface {
19
20
  constructor() {
@@ -155,6 +156,73 @@ class ProcessRepositoryDocumentDB extends ProcessRepositoryInterface {
155
156
  return this._mapProcess(decryptedProcess);
156
157
  }
157
158
 
159
+ /**
160
+ * Atomic process update — race-safe counterpart to `update()`.
161
+ *
162
+ * Uses DocumentDB's native $inc / $set / $push operators (Mongo-wire
163
+ * compatible) via findAndModify so increments, sets, and pushes land
164
+ * in one server-side write. Contention on the same document
165
+ * serializes at the DB level.
166
+ *
167
+ * DocumentDB compatibility notes:
168
+ * - $inc: supported since v3.6.
169
+ * - $set with dot-path: supported.
170
+ * - $push with $each + negative $slice: supported since v4.0.
171
+ * Clusters still on v3.6 must upgrade before using pushSlice.
172
+ *
173
+ * Process documents have no encrypted fields today; if that changes,
174
+ * the set-by-path payload here MUST route through
175
+ * `encryptionService.encryptFields` for any affected paths.
176
+ *
177
+ * @param {string} processId
178
+ * @param {import('./process-repository-interface').ProcessUpdateOps} ops
179
+ * @returns {Promise<Object|null>}
180
+ */
181
+ async applyProcessUpdate(processId, ops) {
182
+ const normalized = validateOps(ops);
183
+ const objectId = toObjectId(processId);
184
+
185
+ const update = {};
186
+ const $set = {};
187
+
188
+ if (Object.keys(normalized.increment).length > 0) {
189
+ update.$inc = { ...normalized.increment };
190
+ }
191
+ for (const [path, value] of Object.entries(normalized.set)) {
192
+ $set[path] = value;
193
+ }
194
+ if (normalized.newState !== null) {
195
+ $set.state = normalized.newState;
196
+ }
197
+ $set.updatedAt = new Date();
198
+ update.$set = $set;
199
+
200
+ if (Object.keys(normalized.pushSlice).length > 0) {
201
+ update.$push = {};
202
+ for (const [path, spec] of Object.entries(normalized.pushSlice)) {
203
+ update.$push[path] = {
204
+ $each: spec.values,
205
+ $slice: -spec.keepLast,
206
+ };
207
+ }
208
+ }
209
+
210
+ const result = await this.prisma.$runCommandRaw({
211
+ findAndModify: 'Process',
212
+ query: { _id: objectId },
213
+ update,
214
+ new: true,
215
+ });
216
+
217
+ const doc = result && result.value;
218
+ if (!doc) return null;
219
+ const decrypted = await this.encryptionService.decryptFields(
220
+ 'Process',
221
+ doc
222
+ );
223
+ return this._mapProcess(decrypted);
224
+ }
225
+
158
226
  async findByIntegrationAndType(integrationId, type) {
159
227
  const integrationObjectId = toObjectId(integrationId);
160
228
  const filter = {
@@ -47,6 +47,52 @@ class ProcessRepositoryInterface {
47
47
  throw new Error('Method update() must be implemented');
48
48
  }
49
49
 
50
+ /**
51
+ * Apply atomic mutations to a process record.
52
+ *
53
+ * Race-safe counterpart to `update()`. Where `update()` takes full
54
+ * JSON blobs and does read-modify-write at the ORM layer (clobber-
55
+ * prone under concurrent writers), `applyProcessUpdate()` describes
56
+ * the intent declaratively and each backend uses its native atomic
57
+ * primitive:
58
+ * - PostgreSQL: `jsonb_set` chain inside a single UPDATE ... RETURNING
59
+ * - MongoDB: `$inc` / `$set` / `$push` via findAndModify
60
+ * - DocumentDB: same operator set as MongoDB (with version caveats)
61
+ *
62
+ * All paths are dot-delimited and rooted in `context` or `results`
63
+ * (e.g. `context.processedRecords`,
64
+ * `results.aggregateData.totalSynced`). Paths MUST match
65
+ * `^(context|results)(\\.[a-zA-Z_][a-zA-Z0-9_]*)+$` — validated by
66
+ * each adapter before any SQL/command generation.
67
+ *
68
+ * Intended primary callers: UpdateProcessMetrics and
69
+ * UpdateProcessState. Other callers can use this directly when they
70
+ * need race-free cumulative updates.
71
+ *
72
+ * @typedef {Object} ProcessUpdateOps
73
+ * @property {Object.<string, number>} [increment] - Atomic numeric
74
+ * increments keyed by dot-path. e.g.
75
+ * `{ 'context.processedRecords': 1, 'results.aggregateData.totalSynced': 1 }`
76
+ * @property {Object.<string, *>} [set] - Atomic whole-subtree set
77
+ * keyed by dot-path. Replaces the value at the path (NOT deep
78
+ * merge). e.g. `{ 'context.fetchDone': true }`
79
+ * @property {Object.<string, {values: Array, keepLast: number}>} [pushSlice]
80
+ * Atomic array push with bounded retention (sliding window of the
81
+ * last `keepLast` items). Keys are dot-paths pointing to arrays.
82
+ * e.g. `{ 'results.aggregateData.errors': { values: [err], keepLast: 100 } }`
83
+ * @property {string} [newState] - Top-level `state` column update.
84
+ * Written alongside the JSON mutations in the same UPDATE so state
85
+ * + counters move together.
86
+ *
87
+ * @param {string} processId - Process ID to update
88
+ * @param {ProcessUpdateOps} ops - Atomic operations to apply
89
+ * @returns {Promise<Object|null>} Updated process record (post-
90
+ * mutation) or null if the process does not exist.
91
+ */
92
+ async applyProcessUpdate(processId, ops) {
93
+ throw new Error('Method applyProcessUpdate() must be implemented');
94
+ }
95
+
50
96
  /**
51
97
  * Find processes by integration and type
52
98
  * @param {string} integrationId - Integration ID
@@ -1,5 +1,6 @@
1
1
  const { prisma } = require('../../database/prisma');
2
2
  const { ProcessRepositoryInterface } = require('./process-repository-interface');
3
+ const { validateOps } = require('./process-update-ops-shared');
3
4
 
4
5
  /**
5
6
  * MongoDB Process Repository Adapter
@@ -92,6 +93,77 @@ class ProcessRepositoryMongo extends ProcessRepositoryInterface {
92
93
  return this._toPlainObject(process);
93
94
  }
94
95
 
96
+ /**
97
+ * Atomic process update — race-safe counterpart to `update()`.
98
+ *
99
+ * Uses `findAndModify` via `$runCommandRaw` so increments, sets, and
100
+ * pushes land in one server-side write. Contention on the same
101
+ * document serializes at the MongoDB level; no Node-side read-
102
+ * modify-write. Returns the post-update document.
103
+ *
104
+ * @param {string} processId
105
+ * @param {import('./process-repository-interface').ProcessUpdateOps} ops
106
+ * @returns {Promise<Object|null>}
107
+ */
108
+ async applyProcessUpdate(processId, ops) {
109
+ const normalized = validateOps(ops);
110
+
111
+ const update = {};
112
+ const $set = {};
113
+
114
+ if (Object.keys(normalized.increment).length > 0) {
115
+ update.$inc = { ...normalized.increment };
116
+ }
117
+ for (const [path, value] of Object.entries(normalized.set)) {
118
+ $set[path] = value;
119
+ }
120
+ if (normalized.newState !== null) {
121
+ $set.state = normalized.newState;
122
+ }
123
+ $set.updatedAt = new Date();
124
+ update.$set = $set;
125
+
126
+ if (Object.keys(normalized.pushSlice).length > 0) {
127
+ update.$push = {};
128
+ for (const [path, spec] of Object.entries(normalized.pushSlice)) {
129
+ update.$push[path] = {
130
+ $each: spec.values,
131
+ $slice: -spec.keepLast,
132
+ };
133
+ }
134
+ }
135
+
136
+ const result = await this.prisma.$runCommandRaw({
137
+ findAndModify: 'Process',
138
+ query: { _id: { $oid: processId } },
139
+ update,
140
+ new: true,
141
+ });
142
+
143
+ const doc = result && result.value;
144
+ if (!doc) return null;
145
+ return this._toPlainObject(this._hydrateRawMongoDoc(doc));
146
+ }
147
+
148
+ /**
149
+ * Shape a raw Mongo document (as returned by $runCommandRaw) to match
150
+ * Prisma's `findUnique` output so the existing `_toPlainObject` works
151
+ * without modification. EJSON round-trips give us `{$oid, $date}` wrappers
152
+ * that need unwrapping.
153
+ * @private
154
+ */
155
+ _hydrateRawMongoDoc(doc) {
156
+ const hydrated = { ...doc };
157
+ if (doc._id) hydrated.id = doc._id.$oid ?? doc._id;
158
+ for (const field of ['createdAt', 'updatedAt']) {
159
+ const raw = doc[field];
160
+ if (raw && typeof raw === 'object' && raw.$date) {
161
+ hydrated[field] = new Date(raw.$date);
162
+ }
163
+ }
164
+ return hydrated;
165
+ }
166
+
95
167
  /**
96
168
  * Find processes by integration and type
97
169
  * @param {string} integrationId - Integration ID
@@ -2,6 +2,7 @@ const { prisma } = require('../../database/prisma');
2
2
  const {
3
3
  ProcessRepositoryInterface,
4
4
  } = require('./process-repository-interface');
5
+ const { validateOps, splitPath } = require('./process-update-ops-shared');
5
6
 
6
7
  /**
7
8
  * PostgreSQL Process Repository Adapter
@@ -108,6 +109,168 @@ class ProcessRepositoryPostgres extends ProcessRepositoryInterface {
108
109
  return this._toPlainObject(process);
109
110
  }
110
111
 
112
+ /**
113
+ * Atomic process update — race-safe counterpart to `update()`.
114
+ *
115
+ * Compiles the `ProcessUpdateOps` into ONE `UPDATE "Process" ...
116
+ * RETURNING *` statement with nested `jsonb_set` calls for every
117
+ * context/results mutation. Postgres applies row-level locking
118
+ * during UPDATE, so concurrent callers on the same row serialize at
119
+ * the DB without any read-modify-write in Node.
120
+ *
121
+ * Path segments have been regex-validated upstream (see
122
+ * process-update-ops-shared.js); they are embedded directly into
123
+ * the SQL string. All values go through positional parameters.
124
+ *
125
+ * @param {string} processId
126
+ * @param {ProcessUpdateOps} ops
127
+ * @returns {Promise<Object|null>}
128
+ */
129
+ async applyProcessUpdate(processId, ops) {
130
+ const normalized = validateOps(ops);
131
+ const id = this._convertId(processId);
132
+
133
+ // Build the SQL expression for each JSON column. We start each
134
+ // column's expression from the column itself and wrap it in
135
+ // jsonb_set(...) calls — one wrap per operation targeting that
136
+ // column. If no op targets a column, we omit that SET clause so
137
+ // we don't issue a pointless self-assignment.
138
+ const params = [];
139
+ /** @type {(v:unknown)=>string} positional placeholder, 1-indexed */
140
+ const bind = (v) => {
141
+ params.push(v);
142
+ return `$${params.length}`;
143
+ };
144
+
145
+ const columnExpressions = this._buildColumnExpressions(
146
+ normalized,
147
+ bind
148
+ );
149
+ const setClauses = [];
150
+ for (const [column, expr] of Object.entries(columnExpressions)) {
151
+ setClauses.push(`"${column}" = ${expr}`);
152
+ }
153
+ if (normalized.newState !== null) {
154
+ setClauses.push(`"state" = ${bind(normalized.newState)}`);
155
+ }
156
+ setClauses.push(`"updatedAt" = NOW()`);
157
+
158
+ const idPlaceholder = bind(id);
159
+ const sql = `
160
+ UPDATE "Process"
161
+ SET ${setClauses.join(', ')}
162
+ WHERE "id" = ${idPlaceholder}
163
+ RETURNING *
164
+ `;
165
+
166
+ const rows = await this.prisma.$queryRawUnsafe(sql, ...params);
167
+ if (!rows || rows.length === 0) return null;
168
+ return this._toPlainObject(rows[0]);
169
+ }
170
+
171
+ /**
172
+ * Returns a map of column → SQL expression with all jsonb_set wraps
173
+ * applied. Used only by applyProcessUpdate.
174
+ * @private
175
+ */
176
+ _buildColumnExpressions(ops, bind) {
177
+ const byColumn = { context: null, results: null };
178
+
179
+ // Seed with the column itself (wrapped with COALESCE so that
180
+ // a NULL column doesn't break jsonb_set).
181
+ const seed = (col) =>
182
+ byColumn[col] ??
183
+ (byColumn[col] = `COALESCE("${col}", '{}'::jsonb)`);
184
+
185
+ /**
186
+ * Postgres `jsonb_set(target, path, value, create_missing=true)`
187
+ * only creates the LEAF segment if missing — intermediate segments
188
+ * that don't exist as objects cause the call to return `target`
189
+ * unchanged (silent no-op). For a path like `context.a.b.c` on a
190
+ * doc where `context.a` is missing, we'd bail on the write.
191
+ *
192
+ * This helper wraps `prev` in a chain of `jsonb_set` calls that
193
+ * ensure each intermediate prefix path is an object, preserving
194
+ * its contents if it's already present:
195
+ *
196
+ * ensureParents(prev, ['a','b','c'])
197
+ * ⇒ jsonb_set(
198
+ * jsonb_set(prev, '{a}', COALESCE(prev#>'{a}', '{}'::jsonb), true),
199
+ * '{a,b}', COALESCE(${that}#>'{a,b}', '{}'::jsonb), true)
200
+ *
201
+ * The caller then wraps this result with its own `jsonb_set` for
202
+ * the leaf segment. Depth-1 paths skip this entirely (no parents
203
+ * to synthesize).
204
+ */
205
+ const ensureParents = (prevExpr, segments) => {
206
+ let cur = prevExpr;
207
+ for (let i = 1; i < segments.length; i++) {
208
+ const parentPath = `'{${segments.slice(0, i).join(',')}}'`;
209
+ cur = `jsonb_set(${cur}, ${parentPath}, COALESCE(${cur} #> ${parentPath}, '{}'::jsonb), true)`;
210
+ }
211
+ return cur;
212
+ };
213
+
214
+ const wrapIncrement = (col, segments, delta) => {
215
+ const textPath = `'{${segments.join(',')}}'`;
216
+ const jsonbPath = `'{${segments.join(',')}}'`;
217
+ const prev = seed(col);
218
+ const guarded = ensureParents(prev, segments);
219
+ const nextValue = `to_jsonb(COALESCE((${guarded} #>> ${textPath})::numeric, 0) + ${bind(delta)})`;
220
+ byColumn[col] = `jsonb_set(${guarded}, ${jsonbPath}, ${nextValue}, true)`;
221
+ };
222
+
223
+ const wrapSet = (col, segments, value) => {
224
+ const jsonbPath = `'{${segments.join(',')}}'`;
225
+ const prev = seed(col);
226
+ const guarded = ensureParents(prev, segments);
227
+ // $n::jsonb — values are serialized to JSON by Prisma when
228
+ // passed as a parameter, then cast back into jsonb.
229
+ byColumn[col] = `jsonb_set(${guarded}, ${jsonbPath}, ${bind(JSON.stringify(value))}::jsonb, true)`;
230
+ };
231
+
232
+ const wrapPushSlice = (col, segments, spec) => {
233
+ const jsonbPath = `'{${segments.join(',')}}'`;
234
+ const prev = seed(col);
235
+ const guarded = ensureParents(prev, segments);
236
+ // Construct the sliced array in a CTE to evaluate `${newArr}`
237
+ // exactly ONCE (vs. the inline form that Postgres would still
238
+ // execute correctly but expand three times). Order is
239
+ // explicitly preserved by `jsonb_agg(... ORDER BY idx)`;
240
+ // without the ORDER BY, aggregate order is implementation-
241
+ // defined even with WITH ORDINALITY.
242
+ const sliced = `(
243
+ WITH combined AS (
244
+ SELECT COALESCE((${guarded} #> ${jsonbPath}), '[]'::jsonb) || ${bind(JSON.stringify(spec.values))}::jsonb AS arr
245
+ )
246
+ SELECT COALESCE(jsonb_agg(elem ORDER BY idx), '[]'::jsonb)
247
+ FROM combined,
248
+ jsonb_array_elements((SELECT arr FROM combined)) WITH ORDINALITY AS t(elem, idx)
249
+ WHERE idx > GREATEST(0, jsonb_array_length((SELECT arr FROM combined)) - ${bind(spec.keepLast)})
250
+ )`;
251
+ byColumn[col] = `jsonb_set(${guarded}, ${jsonbPath}, ${sliced}, true)`;
252
+ };
253
+
254
+ for (const [path, delta] of Object.entries(ops.increment)) {
255
+ const { column, segments } = splitPath(path);
256
+ wrapIncrement(column, segments, delta);
257
+ }
258
+ for (const [path, value] of Object.entries(ops.set)) {
259
+ const { column, segments } = splitPath(path);
260
+ wrapSet(column, segments, value);
261
+ }
262
+ for (const [path, spec] of Object.entries(ops.pushSlice)) {
263
+ const { column, segments } = splitPath(path);
264
+ wrapPushSlice(column, segments, spec);
265
+ }
266
+
267
+ const result = {};
268
+ for (const [col, expr] of Object.entries(byColumn)) {
269
+ if (expr !== null) result[col] = expr;
270
+ }
271
+ return result;
272
+ }
273
+
111
274
  /**
112
275
  * Find processes by integration and type
113
276
  * @param {string} integrationId - Integration ID
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Shared helpers for ProcessRepository.applyProcessUpdate() validation.
3
+ *
4
+ * These utilities are backend-agnostic: they enforce invariants on the
5
+ * `ProcessUpdateOps` shape BEFORE each adapter emits any SQL or database
6
+ * command. Keeping validation here means any bug we fix (e.g. tighter
7
+ * path regex, size cap) fixes all three adapters in one place.
8
+ *
9
+ * Imported by the Postgres, MongoDB, and DocumentDB adapters.
10
+ */
11
+
12
+ /**
13
+ * Allowed dot-path shape. Root must be `context` or `results`, and each
14
+ * segment after the first must be a JS-identifier-style token. Numeric
15
+ * segments (array indices) and bracket syntax are intentionally
16
+ * disallowed — array element mutation is exclusively handled via
17
+ * `pushSlice`, which targets a whole array at a path.
18
+ */
19
+ const PATH_REGEX = /^(context|results)(\.[a-zA-Z_][a-zA-Z0-9_]*)+$/;
20
+
21
+ /**
22
+ * Normalizes and validates a `ProcessUpdateOps` object. Returns a frozen
23
+ * copy with defaults applied and every key pre-validated. Throws synchronously
24
+ * on any shape error so adapters can fail fast before touching the DB.
25
+ *
26
+ * @param {Object} ops
27
+ * @returns {{
28
+ * increment: Record<string, number>,
29
+ * set: Record<string, unknown>,
30
+ * pushSlice: Record<string, { values: unknown[]; keepLast: number }>,
31
+ * newState: string|null,
32
+ * }}
33
+ */
34
+ function validateOps(ops) {
35
+ if (!ops || typeof ops !== 'object' || Array.isArray(ops)) {
36
+ throw new Error('applyProcessUpdate: ops must be an object');
37
+ }
38
+
39
+ const increment = ops.increment || {};
40
+ const set = ops.set || {};
41
+ const pushSlice = ops.pushSlice || {};
42
+ const newState = ops.newState ?? null;
43
+
44
+ for (const [path, delta] of Object.entries(increment)) {
45
+ assertPath(path, 'increment');
46
+ if (typeof delta !== 'number' || !Number.isFinite(delta)) {
47
+ throw new Error(
48
+ `applyProcessUpdate: increment['${path}'] must be a finite number, got ${typeof delta}`
49
+ );
50
+ }
51
+ }
52
+
53
+ for (const path of Object.keys(set)) {
54
+ assertPath(path, 'set');
55
+ }
56
+
57
+ for (const [path, spec] of Object.entries(pushSlice)) {
58
+ assertPath(path, 'pushSlice');
59
+ if (
60
+ !spec ||
61
+ typeof spec !== 'object' ||
62
+ !Array.isArray(spec.values) ||
63
+ typeof spec.keepLast !== 'number' ||
64
+ !Number.isInteger(spec.keepLast) ||
65
+ spec.keepLast <= 0
66
+ ) {
67
+ throw new Error(
68
+ `applyProcessUpdate: pushSlice['${path}'] must be { values: [], keepLast: positive integer }`
69
+ );
70
+ }
71
+ }
72
+
73
+ if (newState !== null && typeof newState !== 'string') {
74
+ throw new Error('applyProcessUpdate: newState must be a string');
75
+ }
76
+
77
+ const hasAnyOp =
78
+ Object.keys(increment).length > 0 ||
79
+ Object.keys(set).length > 0 ||
80
+ Object.keys(pushSlice).length > 0 ||
81
+ newState !== null;
82
+ if (!hasAnyOp) {
83
+ throw new Error(
84
+ 'applyProcessUpdate: at least one of increment/set/pushSlice/newState must be provided'
85
+ );
86
+ }
87
+
88
+ return Object.freeze({ increment, set, pushSlice, newState });
89
+ }
90
+
91
+ function assertPath(path, opName) {
92
+ if (!PATH_REGEX.test(path)) {
93
+ throw new Error(
94
+ `applyProcessUpdate: invalid path '${path}' in ${opName} (must match ${PATH_REGEX})`
95
+ );
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Splits a validated path into `{ column, segments }`.
101
+ * `'context.pagination.pageCount'` → `{ column: 'context', segments: ['pagination', 'pageCount'] }`.
102
+ */
103
+ function splitPath(path) {
104
+ const [column, ...segments] = path.split('.');
105
+ return { column, segments };
106
+ }
107
+
108
+ module.exports = {
109
+ PATH_REGEX,
110
+ validateOps,
111
+ splitPath,
112
+ };
@@ -1,37 +1,28 @@
1
- /**
2
- TODO:
3
- This implementation contains a race condition in the `execute` method. When multiple concurrent processes call this method on the same process record, they'll each read the current state, modify it independently, and then save - potentially overwriting each other's changes.
4
-
5
- For example:
6
- ```
7
- Thread 1: reads process with totalSynced=100
8
- Thread 2: reads process with totalSynced=100
9
- Thread 1: adds 50 → writes totalSynced=150
10
- Thread 2: adds 30 → writes totalSynced=130 (overwrites Thread 1's update!)
11
- ```
12
-
13
- Consider implementing one of these patterns:
14
- 1. Database transactions with row locking
15
- 2. Optimistic concurrency control with version numbers
16
- 3. Atomic update operations (e.g., `$inc` in MongoDB)
17
- 4. A FIFO queue for process updates (as described in the PROCESS_MANAGEMENT_QUEUE_SPEC.md)
18
-
19
- The current approach will lead to lost updates and inconsistent metrics during concurrent processing.
20
-
21
- */
22
-
23
1
  /**
24
2
  * UpdateProcessMetrics Use Case
25
3
  *
26
- * Updates process metrics, calculates aggregates, and computes estimated completion time.
27
- * Optionally broadcasts progress via WebSocket service if provided.
4
+ * Updates process metrics atomically via
5
+ * `processRepository.applyProcessUpdate`. This is the race-safe
6
+ * replacement for the original read-modify-write implementation — the
7
+ * long-standing TODO about lost updates under concurrent writers is
8
+ * now resolved.
9
+ *
10
+ * Split into two phases:
28
11
  *
29
- * Design Philosophy:
30
- * - Metrics are cumulative (add to existing counts)
31
- * - Performance metrics calculated automatically (duration, records/sec)
32
- * - ETA computed based on current progress
33
- * - Error history limited to last 100 entries
34
- * - WebSocket broadcasting is optional (DI pattern)
12
+ * 1. Atomic phase — counters and bounded error history. Uses
13
+ * $inc / $push+$slice (Mongo/DocumentDB) or jsonb_set with
14
+ * arithmetic expressions (Postgres) in a single UPDATE ... RETURNING
15
+ * so concurrent callers serialize at the DB layer.
16
+ *
17
+ * 2. Derived-fields phase duration, recordsPerSecond,
18
+ * estimatedCompletion. Computed from the post-atomic snapshot and
19
+ * written via the legacy (non-atomic) `update()` method.
20
+ * Intentionally best-effort: under concurrent writers they reflect
21
+ * "whichever handler wrote last" — the same semantics they had
22
+ * before and all they've ever guaranteed. Preserved for backward
23
+ * compatibility with consumers (UI, WebSocket listeners).
24
+ *
25
+ * Optionally broadcasts progress via WebSocket service if provided.
35
26
  *
36
27
  * @example
37
28
  * const updateMetrics = new UpdateProcessMetrics({ processRepository, websocketService });
@@ -60,15 +51,14 @@ class UpdateProcessMetrics {
60
51
  * Execute the use case to update process metrics
61
52
  * @param {string} processId - Process ID to update
62
53
  * @param {Object} metricsUpdate - Metrics to add/update
63
- * @param {number} [metricsUpdate.processed=0] - Number of records processed in this batch
64
- * @param {number} [metricsUpdate.success=0] - Number of successful records
65
- * @param {number} [metricsUpdate.errors=0] - Number of failed records
54
+ * @param {number} [metricsUpdate.processed=0] - Records processed in this batch
55
+ * @param {number} [metricsUpdate.success=0] - Successful records
56
+ * @param {number} [metricsUpdate.errors=0] - Failed records
66
57
  * @param {Array} [metricsUpdate.errorDetails=[]] - Error details array
67
58
  * @returns {Promise<Object>} Updated process record
68
59
  * @throws {Error} If process not found or update fails
69
60
  */
70
61
  async execute(processId, metricsUpdate) {
71
- // Validate inputs
72
62
  if (!processId || typeof processId !== 'string') {
73
63
  throw new Error('processId must be a non-empty string');
74
64
  }
@@ -76,87 +66,103 @@ class UpdateProcessMetrics {
76
66
  throw new Error('metricsUpdate must be an object');
77
67
  }
78
68
 
79
- // Retrieve current process
80
- const process = await this.processRepository.findById(processId);
81
- if (!process) {
82
- throw new Error(`Process not found: ${processId}`);
83
- }
84
-
85
- // Get current context and results
86
- const context = process.context || {};
87
- const results = process.results || { aggregateData: {} };
88
-
89
- // Initialize nested objects if not present
90
- if (!results.aggregateData) {
91
- results.aggregateData = {};
92
- }
93
-
94
- // Update context counters (cumulative)
95
- context.processedRecords =
96
- (context.processedRecords || 0) + (metricsUpdate.processed || 0);
69
+ // Phase 1: atomic increments + bounded error history.
70
+ const increment = {};
71
+ const processed = metricsUpdate.processed || 0;
72
+ const success = metricsUpdate.success || 0;
73
+ const errors = metricsUpdate.errors || 0;
74
+ if (processed) increment['context.processedRecords'] = processed;
75
+ if (success) increment['results.aggregateData.totalSynced'] = success;
76
+ if (errors) increment['results.aggregateData.totalFailed'] = errors;
97
77
 
98
- // Update results aggregates (cumulative)
99
- results.aggregateData.totalSynced =
100
- (results.aggregateData.totalSynced || 0) +
101
- (metricsUpdate.success || 0);
102
- results.aggregateData.totalFailed =
103
- (results.aggregateData.totalFailed || 0) +
104
- (metricsUpdate.errors || 0);
105
-
106
- // Append error details (limited to last 100)
78
+ const pushSlice = {};
107
79
  if (
108
- metricsUpdate.errorDetails &&
80
+ Array.isArray(metricsUpdate.errorDetails) &&
109
81
  metricsUpdate.errorDetails.length > 0
110
82
  ) {
111
- results.aggregateData.errors = [
112
- ...(results.aggregateData.errors || []),
113
- ...metricsUpdate.errorDetails,
114
- ].slice(-100); // Keep only last 100 errors
83
+ pushSlice['results.aggregateData.errors'] = {
84
+ values: metricsUpdate.errorDetails,
85
+ keepLast: 100,
86
+ };
115
87
  }
116
88
 
117
- // Calculate performance metrics
118
- const startTime = new Date(context.startTime || process.createdAt);
119
- const elapsed = Date.now() - startTime.getTime();
120
- results.aggregateData.duration = elapsed;
121
-
122
- if (elapsed > 0 && context.processedRecords > 0) {
123
- results.aggregateData.recordsPerSecond =
124
- context.processedRecords / (elapsed / 1000);
125
- } else {
126
- results.aggregateData.recordsPerSecond = 0;
127
- }
89
+ const hasAtomicWork =
90
+ Object.keys(increment).length > 0 ||
91
+ Object.keys(pushSlice).length > 0;
128
92
 
129
- // Calculate ETA if we know total
130
- if (context.totalRecords > 0 && context.processedRecords > 0) {
131
- const remaining = context.totalRecords - context.processedRecords;
132
- if (results.aggregateData.recordsPerSecond > 0) {
133
- const etaMs =
134
- (remaining / results.aggregateData.recordsPerSecond) * 1000;
135
- const eta = new Date(Date.now() + etaMs);
136
- context.estimatedCompletion = eta.toISOString();
137
- }
138
- }
139
-
140
- // Prepare updates
141
- const updates = {
142
- context,
143
- results,
144
- };
145
-
146
- // Persist updates
147
93
  let updatedProcess;
148
94
  try {
149
- updatedProcess = await this.processRepository.update(
150
- processId,
151
- updates
152
- );
95
+ if (hasAtomicWork) {
96
+ updatedProcess = await this.processRepository.applyProcessUpdate(
97
+ processId,
98
+ { increment, pushSlice }
99
+ );
100
+ } else {
101
+ // All-zero update (e.g., empty batch) — nothing to persist;
102
+ // just read current state for the derived-fields pass.
103
+ updatedProcess = await this.processRepository.findById(
104
+ processId
105
+ );
106
+ }
153
107
  } catch (error) {
154
108
  throw new Error(
155
109
  `Failed to update process metrics: ${error.message}`
156
110
  );
157
111
  }
158
112
 
159
- // Broadcast progress via WebSocket (if service provided)
113
+ if (!updatedProcess) {
114
+ throw new Error(`Process not found: ${processId}`);
115
+ }
116
+
117
+ // Phase 2: derived metrics (non-atomic, best-effort). Preserved
118
+ // for backward compatibility — these were always stale under
119
+ // concurrent writers even before this refactor.
120
+ const context = updatedProcess.context || {};
121
+ const results = updatedProcess.results || { aggregateData: {} };
122
+ if (!results.aggregateData) results.aggregateData = {};
123
+
124
+ if (context.processedRecords > 0 || context.totalRecords > 0) {
125
+ const startTime = new Date(
126
+ context.startTime || updatedProcess.createdAt
127
+ );
128
+ const elapsed = Date.now() - startTime.getTime();
129
+ results.aggregateData.duration = elapsed;
130
+
131
+ if (elapsed > 0 && context.processedRecords > 0) {
132
+ results.aggregateData.recordsPerSecond =
133
+ context.processedRecords / (elapsed / 1000);
134
+ } else {
135
+ results.aggregateData.recordsPerSecond = 0;
136
+ }
137
+
138
+ if (context.totalRecords > 0 && context.processedRecords > 0) {
139
+ const remaining =
140
+ context.totalRecords - context.processedRecords;
141
+ if (results.aggregateData.recordsPerSecond > 0) {
142
+ const etaMs =
143
+ (remaining / results.aggregateData.recordsPerSecond) *
144
+ 1000;
145
+ const eta = new Date(Date.now() + etaMs);
146
+ context.estimatedCompletion = eta.toISOString();
147
+ }
148
+ }
149
+
150
+ try {
151
+ updatedProcess = await this.processRepository.update(
152
+ processId,
153
+ { context, results }
154
+ );
155
+ } catch (error) {
156
+ // Derived-field write failures are NON-FATAL — atomic
157
+ // counters from phase 1 already landed. Log and return the
158
+ // post-atomic snapshot.
159
+ console.error(
160
+ '[UpdateProcessMetrics] derived-fields write failed (non-fatal):',
161
+ error.message
162
+ );
163
+ }
164
+ }
165
+
160
166
  if (this.websocketService) {
161
167
  await this._broadcastProgress(updatedProcess);
162
168
  }
@@ -167,7 +173,6 @@ class UpdateProcessMetrics {
167
173
  /**
168
174
  * Broadcast progress update via WebSocket
169
175
  * @private
170
- * @param {Object} process - Updated process record
171
176
  */
172
177
  async _broadcastProgress(process) {
173
178
  try {
@@ -192,7 +197,6 @@ class UpdateProcessMetrics {
192
197
  },
193
198
  });
194
199
  } catch (error) {
195
- // Log but don't fail the update if WebSocket broadcast fails
196
200
  console.error('Failed to broadcast process progress:', error);
197
201
  }
198
202
  }
@@ -54,30 +54,69 @@ class UpdateProcessState {
54
54
  throw new Error('contextUpdates must be an object');
55
55
  }
56
56
 
57
- // Retrieve current process
58
- const process = await this.processRepository.findById(processId);
59
- if (!process) {
60
- throw new Error(`Process not found: ${processId}`);
61
- }
57
+ // Route through the atomic path when the repo supports it AND we
58
+ // have context keys to set. The atomic path writes the state
59
+ // column + the context field-sets in one DB round trip without
60
+ // read-modify-write, so a concurrent counter bump from
61
+ // UpdateProcessMetrics can't clobber our flags (e.g. `fetchDone`)
62
+ // or vice versa.
63
+ //
64
+ // Each context update key becomes a `set` at path
65
+ // `context.<key>` — matching the prior semantics of a shallow
66
+ // top-level merge (sub-objects were and still are replaced
67
+ // whole, not deep-merged).
68
+ const hasContextKeys =
69
+ contextUpdates && Object.keys(contextUpdates).length > 0;
62
70
 
63
- // Prepare updates
64
- const updates = {
65
- state: newState,
66
- };
67
-
68
- // Merge context updates if provided
69
- if (contextUpdates && Object.keys(contextUpdates).length > 0) {
70
- updates.context = {
71
- ...process.context,
72
- ...contextUpdates,
73
- };
71
+ if (
72
+ hasContextKeys &&
73
+ typeof this.processRepository.applyProcessUpdate === 'function'
74
+ ) {
75
+ const set = {};
76
+ for (const [key, value] of Object.entries(contextUpdates)) {
77
+ set[`context.${key}`] = value;
78
+ }
79
+ try {
80
+ const updated = await this.processRepository.applyProcessUpdate(
81
+ processId,
82
+ { set, newState }
83
+ );
84
+ if (!updated) {
85
+ throw new Error(`Process not found: ${processId}`);
86
+ }
87
+ return updated;
88
+ } catch (error) {
89
+ throw new Error(
90
+ `Failed to update process state: ${error.message}`
91
+ );
92
+ }
74
93
  }
75
94
 
76
- // Persist updates
95
+ // Legacy path (no contextUpdates or repo lacks applyProcessUpdate):
96
+ // preserve the original read-merge-write semantics for backward
97
+ // compatibility with any custom repos. Wrap the full read+write
98
+ // in try/catch so a findById error surfaces under the same
99
+ // "Failed to update process state" message as a write failure.
77
100
  try {
78
- const updatedProcess = await this.processRepository.update(processId, updates);
79
- return updatedProcess;
101
+ const process = await this.processRepository.findById(processId);
102
+ if (!process) {
103
+ throw new Error(`Process not found: ${processId}`);
104
+ }
105
+
106
+ const updates = { state: newState };
107
+ if (hasContextKeys) {
108
+ updates.context = {
109
+ ...process.context,
110
+ ...contextUpdates,
111
+ };
112
+ }
113
+
114
+ return await this.processRepository.update(processId, updates);
80
115
  } catch (error) {
116
+ // Re-throw "Process not found" as-is; wrap other errors.
117
+ if (error.message && error.message.startsWith('Process not found')) {
118
+ throw error;
119
+ }
81
120
  throw new Error(`Failed to update process state: ${error.message}`);
82
121
  }
83
122
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.580.4487187.0",
4
+ "version": "2.0.0--canary.580.235db2b.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -38,9 +38,9 @@
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "@friggframework/eslint-config": "2.0.0--canary.580.4487187.0",
42
- "@friggframework/prettier-config": "2.0.0--canary.580.4487187.0",
43
- "@friggframework/test": "2.0.0--canary.580.4487187.0",
41
+ "@friggframework/eslint-config": "2.0.0--canary.580.235db2b.0",
42
+ "@friggframework/prettier-config": "2.0.0--canary.580.235db2b.0",
43
+ "@friggframework/test": "2.0.0--canary.580.235db2b.0",
44
44
  "@prisma/client": "^6.17.0",
45
45
  "@types/lodash": "4.17.15",
46
46
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -80,5 +80,5 @@
80
80
  "publishConfig": {
81
81
  "access": "public"
82
82
  },
83
- "gitHead": "44871876975b9e507ed635e9df4075b2215685d1"
83
+ "gitHead": "235db2bdc62c375401507fe546c3b77d8cbc5bc7"
84
84
  }