@mbc-cqrs-serverless/core
Version:
CQRS and event base core
326 lines • 15.6 kB
JavaScript
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var Repository_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.Repository = void 0;
const common_1 = require("@nestjs/common");
const constants_1 = require("../constants");
const user_1 = require("../context/user");
const session_service_1 = require("../data-store/session.service");
const key_1 = require("../helpers/key");
const transform_1 = require("../helpers/transform");
const interfaces_1 = require("../interfaces");
const command_module_definition_1 = require("./command.module-definition");
const command_service_1 = require("./command.service");
const data_service_1 = require("./data.service");
let Repository = Repository_1 = class Repository {
constructor(options, dataService, commandService, sessionService) {
this.options = options;
this.dataService = dataService;
this.commandService = commandService;
this.sessionService = sessionService;
this.logger = new common_1.Logger(Repository_1.name);
this.moduleTableName = this.options.tableName;
}
/**
* Get a single data item, merging a pending async command when a session exists.
*/
async getItem(key, options) {
const userContext = (0, user_1.getUserContext)(options.invokeContext);
const userId = userContext?.userId;
const tenantCode = (0, key_1.getTenantCode)(key.pk);
if (userId && tenantCode) {
const itemId = (0, key_1.generateId)(key.pk, key.sk);
const session = await this.sessionService.get(userId, tenantCode, this.moduleTableName, itemId);
if (session) {
this.logger.debug(`getItem session merge — version ${session.version}`, key);
// Fetch existing data FIRST to check if the session is stale
const existing = await this.dataService.getItem(key);
/**
* If the data table version is greater than or equal to the session version,
* the read model has already absorbed the user's specific write (or a newer one).
* We purge the stale session in the background and return the persisted data.
*/
if (existing && existing.version >= session.version) {
this.sessionService
.delete(userId, tenantCode, this.moduleTableName, itemId)
.catch(() => { }); // Fire-and-forget: do not block the critical read path
return existing;
}
// Otherwise, data is still lagging. Fetch the command to project it.
const cmd = await this.commandService.getItem({
pk: key.pk,
sk: (0, key_1.addSortKeyVersion)(key.sk, session.version),
});
if (cmd) {
return (0, transform_1.transformCommandToData)(cmd, existing);
}
}
}
return this.dataService.getItem(key);
}
/**
* List items from the DynamoDB data table with an optional merge of pending async commands.
*
* When `mergeOptions.latestFlg` is true, the result is augmented with any
* pending writes the current user has not yet seen reflected in the data table:
*
* - **update** — the existing item is replaced in-place, preserving its
* position in the list.
* - **delete** — the item is removed from the result.
* - **create-new** — the item is prepended to the top of the list, sorted by
* `updatedAt` descending when there are multiple pending creates.
*
* > **Sort-order note**: Prepended create-new items are not integrated into
* > the caller's sort order (e.g. by `name` or `code`). They will appear
* > at the top of the result until the async DynamoDB Stream sync completes and
* > the next read returns fully sorted data. This is intentional — the
* > guarantee is visibility, not sort position.
*/
async listItemsByPk(pk, opts, mergeOptions, options) {
const baseResult = await this.dataService.listItemsByPk(pk, opts);
if (!mergeOptions?.latestFlg || !options) {
return baseResult;
}
const userContext = (0, user_1.getUserContext)(options.invokeContext);
const userId = userContext?.userId;
if (!userId)
return baseResult;
const tenantCode = (0, key_1.getTenantCode)(pk);
if (!tenantCode) {
return baseResult;
}
const sessions = await this.sessionService.listByUser(userId, tenantCode, this.moduleTableName);
if (!sessions.length)
return baseResult;
this.logger.debug(`listItemsByPk merge — ${sessions.length} sessions`, {
pk,
});
// Map preserves insertion order for existing items
const itemMap = new Map(baseResult.items.map((item) => [item.id, item]));
// Separate array to collect newly created items so they can be prepended
const newItems = [];
const skPrefix = `${this.moduleTableName}${constants_1.KEY_SEPARATOR}`;
for (const session of sessions) {
if (!session.sk.startsWith(skPrefix)) {
continue;
}
const itemId = session.sk.slice(skPrefix.length);
const existing = itemMap.get(itemId);
// If the item in the list already caught up to the session, clear the session and skip merge
if (existing && existing.version >= session.version) {
this.sessionService
.delete(userId, tenantCode, this.moduleTableName, itemId)
.catch(() => { });
continue;
}
const cmdPk = pk;
let skBase;
if (existing?.sk) {
skBase = (0, key_1.removeSortKeyVersion)(existing.sk);
}
else {
// Since we already know the exact PK from the method parameter,
// we can safely extract the skBase without strict 2-segment assumptions.
skBase = (0, key_1.sortKeyBaseFromId)(pk, itemId);
if (!skBase) {
continue;
}
}
const cmd = await this.commandService.getItem({
pk: cmdPk,
sk: (0, key_1.addSortKeyVersion)(skBase, session.version),
});
if (!cmd)
continue;
const transformed = (0, transform_1.transformCommandToData)(cmd, existing);
if (cmd.isDeleted) {
// Delete the item from the map
itemMap.delete(itemId);
}
else if (itemMap.has(itemId)) {
// Update: preserves the original sort order in the Map
itemMap.set(itemId, new interfaces_1.DataEntity(transformed));
}
else {
// Create-new: push to the newItems array
newItems.push(new interfaces_1.DataEntity(transformed));
}
}
// Sort newly created items by createdAt descending
if (newItems.length > 1) {
newItems.sort((a, b) => {
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return timeB - timeA;
});
}
return new interfaces_1.DataListEntity({
lastSk: baseResult.lastSk,
// Prepend newly created items to the top of the list
items: [...newItems, ...itemMap.values()],
});
}
/**
* List items from an external source (e.g., RDS/Elasticsearch) with an
* optional merge of pending async commands.
*
* When `mergeOptions.latestFlg` is true, the result is augmented with any
* pending writes the current user has not yet seen reflected in the query
* source:
*
* - **update** — the existing item is replaced in-place via `transformCommand`,
* preserving its position. `existing` is passed to the transformer so join
* data (e.g. related RDS rows) can be carried over.
* - **delete** — the item is removed and `total` is decremented.
* - **create-new** — the item is transformed, optionally filtered by
* `matchesFilter`, and prepended to the top of the result sorted by
* `updatedAt` descending. `total` is incremented.
*
* > **Sort-order note**: Prepended create-new items are not integrated into
* > the caller's sort order (e.g. by `name` or `code`). They will appear
* > at the top of the result until the async Stream → RDS sync completes and
* > the next read returns fully sorted data. This is intentional — the
* > guarantee is visibility, not sort position.
*
* > **Pagination note**: `total` reflects the adjusted count after merge.
* > However, prepended items sit outside the RDS `LIMIT`/`OFFSET` window, so
* > on page 2+ the caller may see a one-item overlap or gap until sync
* > completes. For most UX patterns (redirect-after-write, optimistic UI)
* > this is not noticeable.
*/
async listItems(query, mergeOptions, options) {
const baseResult = await query();
if (!mergeOptions?.latestFlg || !options) {
return baseResult;
}
const userContext = (0, user_1.getUserContext)(options.invokeContext);
const userId = userContext?.userId;
if (!userId)
return baseResult;
const tenantCode = userContext.tenantCode;
if (!tenantCode)
return baseResult;
const sessions = await this.sessionService.listByUser(userId, tenantCode, this.moduleTableName);
if (!sessions.length)
return baseResult;
this.logger.debug(`listItems merge — ${sessions.length} sessions`, {
tenantCode,
});
const itemMap = new Map(baseResult.items.map((item) => [item.id, item]));
const skPrefix = `${this.moduleTableName}${constants_1.KEY_SEPARATOR}`;
/**
* Parallelize session checks to avoid N+1 sequential database roundtrips.
* For each session, we determine if we need to merge a command or skip if synced.
*/
const sessionResults = await Promise.all(sessions.map(async (session) => {
if (!session.sk.startsWith(skPrefix))
return null;
const itemId = session.sk.slice(skPrefix.length);
const existing = itemMap.get(itemId);
let cmdPk;
let skBase;
const existingSk = existing?.sk;
const existingPk = existing?.pk;
if (existing && existingSk && existingPk) {
cmdPk = existingPk;
skBase = (0, key_1.removeSortKeyVersion)(existingSk);
}
else {
// Fallback: Strictly parse the ID assuming a 2-segment PK format ({type}#{tenantCode})
const parsed = (0, key_1.parseTwoSegmentPkSkFromId)(itemId);
if (!parsed)
return null;
cmdPk = parsed.pk;
skBase = parsed.skBase;
}
if (!cmdPk || !skBase)
return null;
// Optimization: check item version provided by the external source first
const currentVersion = existing
? mergeOptions.getVersion?.(existing)
: undefined;
if (currentVersion !== undefined && currentVersion >= session.version) {
this.sessionService
.delete(userId, tenantCode, this.moduleTableName, itemId)
.catch(() => { });
return { type: 'synced', itemId };
}
// Verify state against DynamoDB Data Table
const dataItem = await this.dataService.getItem({
pk: cmdPk,
sk: skBase,
});
if (dataItem && dataItem.version >= session.version) {
this.sessionService
.delete(userId, tenantCode, this.moduleTableName, itemId)
.catch(() => { });
return { type: 'synced', itemId };
}
const cmd = await this.commandService.getItem({
pk: cmdPk,
sk: (0, key_1.addSortKeyVersion)(skBase, session.version),
});
return cmd ? { type: 'command', cmd, existing, itemId } : null;
}));
const newItems = [];
let adjustedTotal = baseResult.total;
for (const res of sessionResults) {
if (!res || res.type === 'synced')
continue;
const { cmd, existing, itemId } = res;
const transformed = mergeOptions.transformCommand(cmd, existing);
if (cmd.isDeleted) {
// Delete the item from the map
if (itemMap.has(itemId)) {
itemMap.delete(itemId);
adjustedTotal--;
}
}
else if (itemMap.has(itemId)) {
// Update: preserves original position
itemMap.set(itemId, transformed);
}
else {
// Create-new: check if it satisfies the current query filters
if (!mergeOptions.matchesFilter ||
mergeOptions.matchesFilter(transformed)) {
newItems.push(transformed);
adjustedTotal++;
}
}
}
// Sort newly created items by createdAt descending (safely fallback to 0 if field is missing)
if (newItems.length > 1) {
newItems.sort((a, b) => {
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return timeB - timeA;
});
}
return {
total: adjustedTotal,
items: [...newItems, ...itemMap.values()],
};
}
};
exports.Repository = Repository;
exports.Repository = Repository = Repository_1 = __decorate([
(0, common_1.Injectable)(),
__param(0, (0, common_1.Inject)(command_module_definition_1.MODULE_OPTIONS_TOKEN)),
__metadata("design:paramtypes", [Object, data_service_1.DataService,
command_service_1.CommandService,
session_service_1.SessionService])
], Repository);
//# sourceMappingURL=repository.js.map