@dwtechs/antity-pgsql 0.14.0 → 0.15.1

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/README.md CHANGED
@@ -120,6 +120,7 @@ router.get("/", ..., entity.get);
120
120
  // Using substacks (recommended) - combines normalize, validate, and database operation
121
121
  router.post("/", ...entity.addArraySubstack);
122
122
  router.put("/", ...entity.updateArraySubstack);
123
+ router.put("/preferences", ...entity.syncArraySubstack);
123
124
 
124
125
  // Or manually chain middlewares
125
126
  router.post("/manual", entity.normalizeArray, entity.validateArray, ..., entity.add);
@@ -192,6 +193,7 @@ class SQLEntity {
192
193
  get updateOneSubstack(): SubstackTuple;
193
194
  get upsertArraySubstack(): SubstackTuple;
194
195
  get upsertOneSubstack(): SubstackTuple;
196
+ get syncArraySubstack(): SubstackTuple;
195
197
 
196
198
  query: {
197
199
  select: (
@@ -245,6 +247,7 @@ class SQLEntity {
245
247
  add: (req: Request, res: Response, next: NextFunction) => Promise<void>;
246
248
  update: (req: Request, res: Response, next: NextFunction) => Promise<void>;
247
249
  upsert: (req: Request, res: Response, next: NextFunction) => Promise<void>;
250
+ sync: (req: Request, res: Response, next: NextFunction) => Promise<void>;
248
251
  archive: (req: Request, res: Response, next: NextFunction) => Promise<void>;
249
252
  delete: (req: Request, res: Response, next: NextFunction) => Promise<void>;
250
253
  deleteArchive: (req: Request, res: Response, next: NextFunction) => void;
@@ -271,11 +274,13 @@ function execute(
271
274
 
272
275
  ### Middleware Methods for Express.js
273
276
 
274
- get(), add(), update(), upsert(), archive(), delete(), deleteArchive() and getHistory() methods are made to be used as Express.js middlewares.
277
+ get(), add(), update(), upsert(), sync(), archive(), delete(), deleteArchive() and getHistory() methods are made to be used as Express.js middlewares.
275
278
  Each method will look for data to work on in the **req.body.rows** parameter.
276
279
 
277
280
  The upsert() method additionally requires **req.body.conflictTarget** to specify which column(s) define uniqueness.
278
281
 
282
+ The sync() method accepts an optional **req.body.idField** (defaults to `'id'`) and optional **req.body.filters** to scope which existing rows are considered part of the managed set.
283
+
279
284
  ### Schema Qualification
280
285
 
281
286
  All SQL queries generated by Antity-pgsql use schema-qualified table names (e.g., `schema.table`). This provides:
@@ -295,6 +300,7 @@ Substacks are pre-composed middleware chains that combine normalization, validat
295
300
  - **updateOneSubstack**: Combines `normalizeOne`, `validateOne`, and `update`. Use this for PUT routes with `req.body` containing a single object.
296
301
  - **upsertArraySubstack**: Combines `normalizeArray`, `validateArray`, and `upsert`. Use this for upsert routes with `req.body.rows` containing multiple objects. Requires `req.body.conflictTarget`.
297
302
  - **upsertOneSubstack**: Combines `normalizeOne`, `validateOne`, and `upsert`. Use this for upsert routes with `req.body` containing a single object. Requires `req.body.conflictTarget`.
303
+ - **syncArraySubstack**: Combines `normalizeArray`, `validateArray`, and `sync`. Use this for bulk-sync routes with `req.body.rows` containing the full desired state. Rows are inserted, updated, or deleted as needed. Accepts optional `req.body.idField` and `req.body.filters`.
298
304
 
299
305
  Using substacks simplifies your route definitions and ensures consistent data processing.
300
306
 
@@ -303,12 +309,71 @@ Using substacks simplifies your route definitions and ensures consistent data pr
303
309
  - **query.select()**: Generates a SELECT query. When the `rows` parameter is provided (not null), pagination is automatically enabled and the query includes `COUNT(*) OVER () AS total` to return the total number of rows. The total count is extracted from results and returned separately from the row data.
304
310
  - **query.insert()**: Generates an INSERT query. Accepts an array of objects with properties matching the entity definition. Optionally appends `consumerId` and `consumerName` for history tracking. Supports `RETURNING` clause via the `rtn` parameter.
305
311
  - **query.update()**: Generates an UPDATE query using CASE statements. Accepts an array of objects with `id` property. Optionally appends `consumerId` and `consumerName` for history tracking.
306
- - **query.upsert()**: Generates an INSERT ... ON CONFLICT ... DO UPDATE query. Accepts an array of objects and a `conflictTarget` (single column name or array of column names) that defines uniqueness. If a conflict occurs on the specified column(s), the row is updated; otherwise, it is inserted. Properties are automatically included if they have both INSERT and UPDATE operations. Optionally appends `consumerId` and `consumerName` for history tracking. Supports `RETURNING` clause via the `rtn` parameter.
312
+ - **query.upsert()**: Generates an INSERT ... ON CONFLICT ... DO UPDATE query. (See [Upsert](#upsert-insert-or-update) section below.) Accepts an array of objects and a `conflictTarget` (single column name or array of column names) that defines uniqueness. If a conflict occurs on the specified column(s), the row is updated; otherwise, it is inserted. Properties are automatically included if they have both INSERT and UPDATE operations. Optionally appends `consumerId` and `consumerName` for history tracking. Supports `RETURNING` clause via the `rtn` parameter.
307
313
  - **query.archive()**: Generates a simplified `UPDATE ... SET archived = true WHERE id IN (...)` query. Accepts an array of objects with `id` property. Optionally appends `consumerId` and `consumerName` for history tracking. Does not require an `archived` field in the rows — it is set directly in the SQL.
314
+ - **sync()**: Atomically synchronises the table with the provided rows inside a single PostgreSQL transaction. Missing rows are inserted, existing rows are updated, and rows absent from the list are deleted. Accepts optional `idField` (default `'id'`) and `filters` to restrict the scope of managed rows. Stores the result in `res.locals.rows` and a summary `{ inserted, updated, deleted }` in `res.locals.sync`.
308
315
  - **delete()**: Deletes rows by their IDs. Expects `req.body.rows` to be an array of objects with `id` property: `[{id: 1}, {id: 2}]`
309
316
  - **deleteArchive()**: Deletes archived rows that were archived before a specific date using a PostgreSQL SECURITY DEFINER function. Expects `req.body.date` to be a Date object.
310
317
  - **getHistory()**: Retrieves modification history for rows from the `log.history` table. Expects `req.body.rows` to be an array of objects with `id` property. Returns all historical records for the specified entity IDs.
311
318
 
319
+ ### Bulk Sync
320
+
321
+ The sync functionality atomically replaces the managed set of rows in a table with the supplied list. It combines insert, update, and delete in a single PostgreSQL **transaction** — either all changes succeed or none do.
322
+
323
+ #### How It Works
324
+
325
+ 1. **Fetch existing IDs**: A `SELECT id FROM table` is issued, optionally scoped by `filters`.
326
+ 2. **Diff**: Incoming rows without an ID (or with an unknown ID) are inserted; rows with a known ID are updated; existing IDs absent from the incoming list are deleted — all within the same filter scope.
327
+ 3. **Transaction**: All three operations run inside `BEGIN` / `COMMIT`. A failure at any step triggers `ROLLBACK`.
328
+ 4. **Result**: `res.locals.rows` contains the full synced list (with generated IDs filled in for inserts). `res.locals.sync` contains `{ inserted, updated, deleted }` counts.
329
+
330
+ #### Usage Examples
331
+
332
+ **Using the middleware:**
333
+
334
+ ```javascript
335
+ // Route definition
336
+ router.put('/users/sync', ...entity.syncArraySubstack);
337
+
338
+ // Request body — send the entire desired state
339
+ {
340
+ rows: [
341
+ { id: 1, name: 'John Updated', email: 'john@example.com', age: 31 }, // update
342
+ { name: 'Jane New', email: 'jane@example.com', age: 25 } // insert
343
+ // id: 2 is absent → will be deleted
344
+ ],
345
+ idField: 'id' // optional, defaults to 'id'
346
+ }
347
+ ```
348
+
349
+ **Scoping with filters (only manage a subset of rows):**
350
+
351
+ ```javascript
352
+ // Only sync rows where age >= 18 — rows outside this filter are left untouched
353
+ {
354
+ rows: [
355
+ { id: 1, name: 'John', email: 'john@example.com', age: 30 }
356
+ ],
357
+ filters: {
358
+ age: { value: 18, matchMode: 'gte' }
359
+ }
360
+ }
361
+ ```
362
+
363
+ **Response locals after sync:**
364
+
365
+ ```javascript
366
+ res.locals.rows // full list of synced rows (inserts have their new id)
367
+ res.locals.sync // { inserted: 1, updated: 1, deleted: 1 }
368
+ ```
369
+
370
+ #### Important Notes
371
+
372
+ - **Atomic**: All insert / update / delete operations are wrapped in a single transaction.
373
+ - **Filter scope**: When `filters` are provided, only rows matching the filter are considered "managed". Rows outside the filter are never touched.
374
+ - **Property selection**: Insert uses `INSERT` properties; update uses `UPDATE` properties — same as the standalone `add` and `update` middlewares.
375
+ - **Consumer tracking**: `consumerId` and `consumerName` from `res.locals` are forwarded to inserts and updates for history tracking.
376
+
312
377
  ### Upsert (Insert or Update)
313
378
 
314
379
  The upsert functionality uses PostgreSQL's `INSERT ... ON CONFLICT ... DO UPDATE` syntax to insert rows or update them if they already exist based on a unique constraint.
@@ -117,6 +117,7 @@ export declare class SQLEntity extends Entity {
117
117
  get updateOneSubstack(): SubstackTuple;
118
118
  get upsertArraySubstack(): SubstackTuple;
119
119
  get upsertOneSubstack(): SubstackTuple;
120
+ get syncArraySubstack(): SubstackTuple;
120
121
 
121
122
  query: {
122
123
  select: (
@@ -183,6 +184,7 @@ export declare class SQLEntity extends Entity {
183
184
  add(req: Request, res: Response, next: NextFunction): Promise<void>;
184
185
  update(req: Request, res: Response, next: NextFunction): Promise<void>;
185
186
  upsert(req: Request, res: Response, next: NextFunction): Promise<void>;
187
+ sync(req: Request, res: Response, next: NextFunction): Promise<void>;
186
188
  archive(req: Request, res: Response, next: NextFunction): Promise<void>;
187
189
  delete(req: Request, res: Response, next: NextFunction): Promise<void>;
188
190
  deleteArchive(req: Request, res: Response, next: NextFunction): void;
@@ -166,6 +166,15 @@ function formatValue(value, matchMode) {
166
166
  return value;
167
167
  }
168
168
  }
169
+ function shouldSkipValue(value, matchMode) {
170
+ if (isString(value, "0"))
171
+ return true;
172
+ if (isArray(value, "0"))
173
+ return true;
174
+ if (value === null && matchMode !== 'is' && matchMode !== 'isNot')
175
+ return true;
176
+ return false;
177
+ }
169
178
  function add(filters) {
170
179
  var _a, _b;
171
180
  const conditions = [];
@@ -178,6 +187,8 @@ function add(filters) {
178
187
  const groupConditions = [];
179
188
  for (const filter of filterArray) {
180
189
  const { value, matchMode } = filter;
190
+ if (shouldSkipValue(value, matchMode))
191
+ continue;
181
192
  const indexes = isArray(value) ? value.map(() => i++) : [i++];
182
193
  const cond = addOne(k, indexes, matchMode);
183
194
  if (cond) {
@@ -880,6 +891,74 @@ class SQLEntity extends Entity {
880
891
  })
881
892
  .catch((err) => next(err));
882
893
  };
894
+ this.sync = (req, res, next) => __awaiter(this, void 0, void 0, function* () {
895
+ var _a;
896
+ const l = res.locals;
897
+ const rows = req.body.rows;
898
+ const idField = (_a = req.body.idField) !== null && _a !== void 0 ? _a : 'id';
899
+ const cId = l.consumerId;
900
+ const cName = l.consumerName;
901
+ if (!rows || !Array.isArray(rows)) {
902
+ return next({ status: 400, msg: "Missing or invalid rows array for sync operation" });
903
+ }
904
+ log.debug(`${LOGS_PREFIX}sync(rows=${rows.length}, idField=${idField}, consumerId=${cId})`);
905
+ const cleanedFilters = cleanFilters(req.body.filters, this.properties) || null;
906
+ const { conditions, args: filterArgs } = add(cleanedFilters);
907
+ const whereClause = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
908
+ const txClient = l.dbClient || (yield pool.connect());
909
+ let toInsert = [];
910
+ let toUpdate = [];
911
+ let idsToDelete = [];
912
+ try {
913
+ yield txClient.query('BEGIN');
914
+ const selectIdQuery = `SELECT ${quoteIfUppercase(idField)} FROM ${quoteIfUppercase(this._schema)}.${quoteIfUppercase(this._table)}${whereClause}`;
915
+ const existingDb = yield execute(selectIdQuery, filterArgs, txClient);
916
+ const existingIds = new Set(existingDb.rows.map(r => r[idField]));
917
+ const incomingIds = new Set(rows.filter(r => r[idField] != null).map(r => r[idField]));
918
+ toInsert = rows.filter(r => r[idField] == null || !existingIds.has(r[idField]));
919
+ toUpdate = rows.filter(r => r[idField] != null && existingIds.has(r[idField]));
920
+ idsToDelete = [...existingIds].filter(id => !incomingIds.has(id));
921
+ if (toInsert.length > 0) {
922
+ const rtn = this.ins.rtn(idField);
923
+ const chunks = chunk(toInsert);
924
+ for (const c of chunks) {
925
+ const { query, args } = this.ins.query(this._schema, this._table, c, cId, cName, rtn);
926
+ const db = yield execute(query, args, txClient);
927
+ const r = db.rows;
928
+ for (let i = 0; i < c.length; i++) {
929
+ c[i][idField] = r[i][idField];
930
+ }
931
+ }
932
+ }
933
+ if (toUpdate.length > 0) {
934
+ const chunks = chunk(toUpdate);
935
+ for (const c of chunks) {
936
+ const { query, args } = this.upd.query(this._schema, this._table, c, cId, cName);
937
+ yield execute(query, args, txClient);
938
+ }
939
+ }
940
+ if (idsToDelete.length > 0) {
941
+ const deleteArgs = [idsToDelete, ...filterArgs];
942
+ const scopedWhere = conditions.length
943
+ ? ` AND ${conditions.map(c => c.replace(/\$(\d+)/g, (_, n) => `$${parseInt(n) + 1}`)).join(' AND ')}`
944
+ : '';
945
+ const deleteQuery = `DELETE FROM ${quoteIfUppercase(this._schema)}.${quoteIfUppercase(this._table)} WHERE ${quoteIfUppercase(idField)} = ANY($1)${scopedWhere}`;
946
+ yield execute(deleteQuery, deleteArgs, txClient);
947
+ }
948
+ yield txClient.query('COMMIT');
949
+ }
950
+ catch (err) {
951
+ yield txClient.query('ROLLBACK');
952
+ return next(err);
953
+ }
954
+ finally {
955
+ if (!l.dbClient)
956
+ txClient.release();
957
+ }
958
+ l.rows = rows;
959
+ l.sync = { inserted: toInsert.length, updated: toUpdate.length, deleted: idsToDelete.length };
960
+ next();
961
+ });
883
962
  this._table = name;
884
963
  this._schema = schema;
885
964
  log.info(`${LOGS_PREFIX}Creating SQLEntity: "${name}"`);
@@ -925,6 +1004,9 @@ class SQLEntity extends Entity {
925
1004
  get upsertOneSubstack() {
926
1005
  return [this.normalizeOne, this.validateOne, this.upsert];
927
1006
  }
1007
+ get syncArraySubstack() {
1008
+ return [this.normalizeArray, this.validateArray, this.sync];
1009
+ }
928
1010
  mapProps(operations, key) {
929
1011
  let hasInsert = false;
930
1012
  let hasUpdate = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dwtechs/antity-pgsql",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Open source library to add PostgreSQL support to @dwtechs/Antity entities.",
5
5
  "keywords": [
6
6
  "entities"
@@ -40,7 +40,7 @@
40
40
  "@dwtechs/winstan": "0.5.0",
41
41
  "@dwtechs/antity": "0.16.0",
42
42
  "@dwtechs/sparray": "0.2.1",
43
- "pg": "8.13.1"
43
+ "pg": "8.20.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@babel/core": "7.26.0",