diff --git a/keyserver/src/updaters/entry-updaters.js b/keyserver/src/updaters/entry-updaters.js index 746b4a53f..2ff93c486 100644 --- a/keyserver/src/updaters/entry-updaters.js +++ b/keyserver/src/updaters/entry-updaters.js @@ -1,287 +1,287 @@ // @flow import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import { rawEntryInfoWithinCalendarQuery, calendarQueryDifference, } from 'lib/shared/entry-utils.js'; import { type SaveEntryRequest, type SaveEntryResponse, type RawEntryInfo, type CalendarQuery, defaultCalendarQuery, } from 'lib/types/entry-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { updateTypes } from 'lib/types/update-types-enum.js'; import { type ServerCreateUpdatesResponse } from 'lib/types/update-types.js'; import { dateString } from 'lib/utils/date-utils.js'; import { ServerError } from 'lib/utils/errors.js'; import { values } from 'lib/utils/objects.js'; import type { SessionUpdate } from './session-updaters.js'; import createIDs from '../creators/id-creator.js'; import createMessages from '../creators/message-creator.js'; import { createUpdates } from '../creators/update-creator.js'; import { dbQuery, SQL } from '../database/database.js'; import { fetchEntryInfo, checkThreadPermissionForEntry, } from '../fetchers/entry-fetchers.js'; import { fetchActiveSessionsForThread } from '../fetchers/session-fetchers.js'; import type { Viewer } from '../session/viewer.js'; const defaultUpdateCreationResponse = { viewerUpdates: [], userInfos: [] }; async function updateEntry( viewer: Viewer, request: SaveEntryRequest, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const lastRevisionQuery = SQL` SELECT r.id, r.author, r.text, r.session, r.last_update, r.deleted, e.text AS entryText FROM revisions r LEFT JOIN entries e ON r.entry = e.id WHERE r.entry = ${request.entryID} ORDER BY r.last_update DESC LIMIT 1 `; const [hasPermission, entryInfo, [lastRevisionResult]] = await Promise.all([ checkThreadPermissionForEntry( viewer, request.entryID, threadPermissions.EDIT_ENTRIES, ), fetchEntryInfo(viewer, request.entryID), dbQuery(lastRevisionQuery), ]); if (!hasPermission) { throw new ServerError('invalid_credentials'); } if (!entryInfo) { throw new ServerError('invalid_parameters'); } if (entryInfo.deleted) { throw new ServerError('entry_deleted'); } if (lastRevisionResult.length === 0) { throw new ServerError('unknown_error'); } const lastRevisionRow = lastRevisionResult[0]; if ( lastRevisionRow.deleted || lastRevisionRow.text !== lastRevisionRow.entryText ) { throw new ServerError('database_corruption'); } const viewerID = viewer.userID; const dbPromises = []; let insertNewRevision = false; let shouldUpdateEntry = false; if ( viewerID === lastRevisionRow.author && viewer.session === lastRevisionRow.session ) { if (lastRevisionRow.last_update >= request.timestamp) { // Updates got sent out of order and as a result an update newer than us // has already been committed, so there's nothing to do return { entryID: request.entryID, newMessageInfos: [], updatesResult: defaultUpdateCreationResponse, }; } shouldUpdateEntry = true; if (lastRevisionRow.last_update + 120000 > request.timestamp) { dbPromises.push( dbQuery(SQL` UPDATE revisions SET last_update = ${request.timestamp}, text = ${request.text} WHERE id = ${lastRevisionRow.id} `), ); } else { insertNewRevision = true; } } else if ( viewer.session !== lastRevisionRow.session && request.prevText !== lastRevisionRow.text ) { throw new ServerError('concurrent_modification', { db: lastRevisionRow.text, ui: request.prevText, }); } else if (lastRevisionRow.last_update >= request.timestamp) { throw new ServerError('old_timestamp', { oldTime: lastRevisionRow.last_update, newTime: request.timestamp, }); } else { shouldUpdateEntry = true; insertNewRevision = true; } if (shouldUpdateEntry) { dbPromises.push( dbQuery(SQL` UPDATE entries SET last_update = ${request.timestamp}, text = ${request.text} WHERE id = ${request.entryID} `), ); } if (insertNewRevision) { const [revisionID] = await createIDs('revisions', 1); const revisionRow = [ revisionID, request.entryID, viewerID, request.text, request.timestamp, viewer.session, request.timestamp, 0, ]; dbPromises.push( dbQuery(SQL` INSERT INTO revisions(id, entry, author, text, creation_time, session, last_update, deleted) VALUES ${[revisionRow]} `), ); } - const updatedEntryInfo = { + const updatedEntryInfo: RawEntryInfo = { ...entryInfo, text: request.text, }; const [newMessageInfos, updatesResult] = await Promise.all([ createMessages(viewer, [ { type: messageTypes.EDIT_ENTRY, threadID: entryInfo.threadID, creatorID: viewerID, time: Date.now(), entryID: request.entryID, date: dateString(entryInfo.year, entryInfo.month, entryInfo.day), text: request.text, }, ]), createUpdateDatasForChangedEntryInfo( viewer, entryInfo, updatedEntryInfo, request.calendarQuery, ), Promise.all(dbPromises), ]); return { entryID: request.entryID, newMessageInfos, updatesResult }; } async function createUpdateDatasForChangedEntryInfo( viewer: Viewer, oldEntryInfo: ?RawEntryInfo, newEntryInfo: RawEntryInfo, inputCalendarQuery: ?CalendarQuery, ): Promise { if (!viewer.loggedIn) { throw new ServerError('not_logged_in'); } const entryID = newEntryInfo.id; invariant(entryID, 'should be set'); // If we ever make it possible to move entries from one thread to another, // we should update this code to look at oldEntryInfo.threadID as well const fetchedFilters = await fetchActiveSessionsForThread( newEntryInfo.threadID, ); let calendarQuery; if (inputCalendarQuery) { calendarQuery = inputCalendarQuery; } else if (viewer.hasSessionInfo) { // This should only ever happen for "legacy" clients who call in without // providing this information. These clients wouldn't know how to deal with // the corresponding UpdateInfos anyways, so no reason to be worried. calendarQuery = viewer.calendarQuery; } else { calendarQuery = defaultCalendarQuery(viewer.platform, viewer.timeZone); } let replaced = null; const { userID } = viewer; const filters = fetchedFilters.map(filter => filter.session === viewer.session && filter.userID === userID ? (replaced = { ...filter, calendarQuery }) : filter, ); if (!replaced) { const { session } = viewer; filters.push({ userID, session, calendarQuery }); } const time = Date.now(); const updateDatas = filters .filter( filter => rawEntryInfoWithinCalendarQuery(newEntryInfo, filter.calendarQuery) || (oldEntryInfo && rawEntryInfoWithinCalendarQuery(oldEntryInfo, filter.calendarQuery)), ) .map(filter => ({ type: updateTypes.UPDATE_ENTRY, userID: filter.userID, time, entryID, targetSession: filter.session, })); const { userInfos, ...updatesResult } = await createUpdates(updateDatas, { viewer, calendarQuery, updatesForCurrentSession: 'return', }); return { ...updatesResult, userInfos: values(userInfos), }; } type CalendarQueryComparisonResult = { +difference: $ReadOnlyArray, +oldCalendarQuery: CalendarQuery, +sessionUpdate: SessionUpdate, }; function compareNewCalendarQuery( viewer: Viewer, newCalendarQuery: CalendarQuery, ): CalendarQueryComparisonResult { if (!viewer.hasSessionInfo) { throw new ServerError('unknown_error'); } const oldCalendarQuery = viewer.calendarQuery; const difference = calendarQueryDifference( oldCalendarQuery, newCalendarQuery, ); const sessionUpdate = _isEqual(oldCalendarQuery)(newCalendarQuery) ? {} : { query: newCalendarQuery }; return { difference, oldCalendarQuery, sessionUpdate: Object.freeze({ ...sessionUpdate }), }; } export { updateEntry, createUpdateDatasForChangedEntryInfo, compareNewCalendarQuery, }; diff --git a/lib/types/entry-types.js b/lib/types/entry-types.js index 2d628bb59..91a0b35ef 100644 --- a/lib/types/entry-types.js +++ b/lib/types/entry-types.js @@ -1,256 +1,284 @@ // @flow -import t, { type TInterface } from 'tcomb'; +import t, { type TInterface, type TUnion } from 'tcomb'; import { type Platform, isWebPlatform } from './device-types.js'; import { type CalendarFilter, calendarFilterValidator, defaultCalendarFilters, } from './filter-types.js'; import type { RawMessageInfo } from './message-types.js'; import type { ServerCreateUpdatesResponse, ClientCreateUpdatesResponse, } from './update-types.js'; import type { UserInfo, AccountUserInfo } from './user-types.js'; import { fifteenDaysEarlier, fifteenDaysLater, thisMonthDates, } from '../utils/date-utils.js'; -import { tUserID, tID, tShape } from '../utils/validation-utils.js'; +import { tUserID, tID, tShape, tBool } from '../utils/validation-utils.js'; -export type RawEntryInfo = { - id?: string, // null if local copy without ID yet - localID?: string, // for optimistic creations - threadID: string, - text: string, - year: number, - month: number, // 1-indexed - day: number, // 1-indexed - creationTime: number, // millisecond timestamp - creatorID: string, - deleted: boolean, +export type RawEntryInfoBase = { + +id?: string, // null if local copy without ID yet + +localID?: string, // for optimistic creations + +threadID: string, + +text: string, + +year: number, + +month: number, // 1-indexed + +day: number, // 1-indexed + +creationTime: number, // millisecond timestamp + +creatorID: string, + +deleted: boolean, }; -export type RawEntryInfos = { - +[id: string]: RawEntryInfo, +const rawEntryInfoBaseValidatorShape = { + localID: t.maybe(t.String), + threadID: tID, + text: t.String, + year: t.Number, + month: t.Number, + day: t.Number, + creationTime: t.Number, + creatorID: tUserID, + deleted: t.Boolean, }; -export const rawEntryInfoValidator: TInterface = - tShape({ + +export type ThinRawEntryInfo = $ReadOnly<{ + ...RawEntryInfoBase, + id?: string, +}>; +const thinRawEntryInfoValidator: TInterface = + tShape({ + ...rawEntryInfoBaseValidatorShape, id: t.maybe(tID), - localID: t.maybe(t.String), - threadID: tID, - text: t.String, - year: t.Number, - month: t.Number, - day: t.Number, - creationTime: t.Number, - creatorID: tUserID, - deleted: t.Boolean, }); +export type ThickRawEntryInfo = $ReadOnly<{ + ...RawEntryInfoBase, + +id: string, + +thick: true, + +lastUpdatedTime: number, +}>; +const thickRawEntryInfoValidator: TInterface = + tShape({ + ...rawEntryInfoBaseValidatorShape, + id: t.String, + thick: tBool(true), + lastUpdatedTime: t.Number, + }); + +export type RawEntryInfo = ThinRawEntryInfo | ThickRawEntryInfo; +export const rawEntryInfoValidator: TUnion = t.union([ + thinRawEntryInfoValidator, + thickRawEntryInfoValidator, +]); +export type RawEntryInfos = { + +[id: string]: RawEntryInfo, +}; + export type EntryInfo = { id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creator: ?UserInfo, deleted: boolean, }; export type EntryStore = { +entryInfos: { +[id: string]: RawEntryInfo }, +daysToEntries: { +[day: string]: string[] }, +lastUserInteractionCalendar: number, }; export const entryStoreValidator: TInterface = tShape({ entryInfos: t.dict(tID, rawEntryInfoValidator), daysToEntries: t.dict(t.String, t.list(tID)), lastUserInteractionCalendar: t.Number, }); export type CalendarQuery = { +startDate: string, +endDate: string, +filters: $ReadOnlyArray, }; export const calendarQueryValidator: TInterface = tShape({ startDate: t.String, endDate: t.String, filters: t.list(calendarFilterValidator), }); export const defaultCalendarQuery = ( platform: ?Platform, timeZone?: ?string, ): CalendarQuery => { if (isWebPlatform(platform)) { return { ...thisMonthDates(timeZone), filters: defaultCalendarFilters, }; } else { return { startDate: fifteenDaysEarlier(timeZone).valueOf(), endDate: fifteenDaysLater(timeZone).valueOf(), filters: defaultCalendarFilters, }; } }; export type SaveEntryInfo = { +entryID: string, +text: string, +prevText: string, +timestamp: number, +calendarQuery: CalendarQuery, }; export type SaveEntryRequest = { +entryID: string, +sessionID?: empty, +text: string, +prevText: string, +timestamp: number, +calendarQuery?: CalendarQuery, }; export type SaveEntryResponse = { +entryID: string, +newMessageInfos: $ReadOnlyArray, +updatesResult: ServerCreateUpdatesResponse, }; export type SaveEntryResult = { +entryID: string, +newMessageInfos: $ReadOnlyArray, +updatesResult: ClientCreateUpdatesResponse, }; export type SaveEntryPayload = { ...SaveEntryResult, +threadID: string, }; export type CreateEntryInfo = { +text: string, +timestamp: number, +date: string, +threadID: string, +localID: string, +calendarQuery: CalendarQuery, }; export type CreateEntryRequest = { +text: string, +sessionID?: empty, +timestamp: number, +date: string, +threadID: string, +localID?: string, +calendarQuery?: CalendarQuery, }; export type CreateEntryPayload = { ...SaveEntryPayload, +localID: string, }; export type DeleteEntryInfo = { +entryID: string, +prevText: string, +calendarQuery: CalendarQuery, }; export type DeleteEntryRequest = { +entryID: string, +sessionID?: empty, +prevText: string, +timestamp: number, +calendarQuery?: CalendarQuery, }; export type RestoreEntryInfo = { +entryID: string, +calendarQuery: CalendarQuery, }; export type RestoreEntryRequest = { +entryID: string, +sessionID?: empty, +timestamp: number, +calendarQuery?: CalendarQuery, }; export type DeleteEntryResponse = { +newMessageInfos: $ReadOnlyArray, +threadID: string, +updatesResult: ServerCreateUpdatesResponse, }; export type DeleteEntryResult = { +newMessageInfos: $ReadOnlyArray, +threadID: string, +updatesResult: ClientCreateUpdatesResponse, }; export type RestoreEntryResponse = { +newMessageInfos: $ReadOnlyArray, +updatesResult: ServerCreateUpdatesResponse, }; export type RestoreEntryResult = { +newMessageInfos: $ReadOnlyArray, +updatesResult: ClientCreateUpdatesResponse, }; export type RestoreEntryPayload = { ...RestoreEntryResult, +threadID: string, }; export type FetchEntryInfosBase = { +rawEntryInfos: $ReadOnlyArray, }; export type FetchEntryInfosResponse = { ...FetchEntryInfosBase, +userInfos: { [id: string]: AccountUserInfo }, }; export type FetchEntryInfosResult = FetchEntryInfosBase; export type DeltaEntryInfosResponse = { +rawEntryInfos: $ReadOnlyArray, +deletedEntryIDs: $ReadOnlyArray, }; export type DeltaEntryInfosResult = { +rawEntryInfos: $ReadOnlyArray, +deletedEntryIDs: $ReadOnlyArray, +userInfos: $ReadOnlyArray, }; export type CalendarResult = { +rawEntryInfos: $ReadOnlyArray, +calendarQuery: CalendarQuery, }; export type CalendarQueryUpdateStartingPayload = { +calendarQuery?: CalendarQuery, }; export type CalendarQueryUpdateResult = { +rawEntryInfos: $ReadOnlyArray, +deletedEntryIDs: $ReadOnlyArray, +calendarQuery: CalendarQuery, +calendarQueryAlreadyUpdated: boolean, +keyserverIDs: $ReadOnlyArray, }; export type FetchRevisionsForEntryPayload = { +entryID: string, +text: string, +deleted: boolean, };