@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 +8 -0
- package/integrations/repositories/process-repository-documentdb.js +68 -0
- package/integrations/repositories/process-repository-interface.js +46 -0
- package/integrations/repositories/process-repository-mongo.js +72 -0
- package/integrations/repositories/process-repository-postgres.js +163 -0
- package/integrations/repositories/process-update-ops-shared.js +112 -0
- package/integrations/use-cases/update-process-metrics.js +106 -102
- package/integrations/use-cases/update-process-state.js +58 -19
- package/package.json +5 -5
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
|
|
27
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* -
|
|
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] -
|
|
64
|
-
* @param {number} [metricsUpdate.success=0] -
|
|
65
|
-
* @param {number} [metricsUpdate.errors=0] -
|
|
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
|
-
//
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
83
|
+
pushSlice['results.aggregateData.errors'] = {
|
|
84
|
+
values: metricsUpdate.errorDetails,
|
|
85
|
+
keepLast: 100,
|
|
86
|
+
};
|
|
115
87
|
}
|
|
116
88
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
//
|
|
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
|
|
79
|
-
|
|
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.
|
|
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.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0--canary.580.
|
|
43
|
-
"@friggframework/test": "2.0.0--canary.580.
|
|
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": "
|
|
83
|
+
"gitHead": "235db2bdc62c375401507fe546c3b77d8cbc5bc7"
|
|
84
84
|
}
|