UNPKG

@mbc-cqrs-serverless/core

Version:
326 lines 15.6 kB
"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