diff --git a/src/backend/drivers/notification/NotificationDriver.test.ts b/src/backend/drivers/notification/NotificationDriver.test.ts index 186c4b414..e27d8e23b 100644 --- a/src/backend/drivers/notification/NotificationDriver.test.ts +++ b/src/backend/drivers/notification/NotificationDriver.test.ts @@ -17,7 +17,7 @@ * along with this program. If not, see . */ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import { makeActor, type Actor } from '../../core/actor.js'; import { runWithContext } from '../../core/context.js'; @@ -133,6 +133,34 @@ describe('NotificationDriver.create', () => { expect(row?.type).toBe(NOTIF_TYPE); }); + it('returns the created notification before the replica has it', async () => { + const { actor } = await makeUser(); + // sqlite's pread delegates to read, so the primary is pinned to the + // real one while replica reads of `notification` come back empty. + const db = server.clients.db; + const realRead = db.read.bind(db); + const pread = vi.spyOn(db, 'pread').mockImplementation(realRead); + const read = vi + .spyOn(db, 'read') + .mockImplementation(async (q, p) => + /FROM `notification`/u.test(q) ? [] : realRead(q, p), + ); + let result: Record | null; + try { + result = (await withActor(actor, () => + driver.create({ + object: { value: { type: NOTIF_TYPE, title: 'lagged' } }, + }), + )) as Record | null; + } finally { + read.mockRestore(); + pread.mockRestore(); + } + + expect(result?.uid).toEqual(expect.any(String)); + expect(result?.value).toEqual({ type: NOTIF_TYPE, title: 'lagged' }); + }); + it('rejects a `value` with no `type` with 400', async () => { const { actor } = await makeUser(); await expect( diff --git a/src/backend/drivers/notification/NotificationDriver.ts b/src/backend/drivers/notification/NotificationDriver.ts index aa57155d3..0ff5253a3 100644 --- a/src/backend/drivers/notification/NotificationDriver.ts +++ b/src/backend/drivers/notification/NotificationDriver.ts @@ -182,6 +182,7 @@ export class NotificationDriver extends PuterDriver { return this.#toClient( await this.stores.notification.getByUid(uid, { userId: actor.user.id, + primary: true, }), ); } diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index f67c13bd6..2b09cce57 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -207,6 +207,37 @@ describe('ShareService', () => { expect(listed.items.map((i) => i.entryUid)).toContain(file.uuid); }); + it('keeps the grant when the replica has not seen the new share row yet', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + // sqlite's pread delegates to read, so the primary is pinned to the + // real one while replica reads of `share` come back empty. + const db = server.clients.db; + const realRead = db.read.bind(db); + const pread = vi.spyOn(db, 'pread').mockImplementation(realRead); + const read = vi + .spyOn(db, 'read') + .mockImplementation(async (q, p) => + /FROM `share`/u.test(q) ? [] : realRead(q, p), + ); + let result; + try { + result = await share(owner.actor, { + uid: file.uuid, + recipient: { email: recipient.email }, + mode: 'read', + }); + } finally { + read.mockRestore(); + pread.mockRestore(); + } + + expect(result.mode).toBe('read'); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + it('tells the account how much is shared with it, and an app only its page', async () => { const owner = await makeUser(); const recipient = await makeUser(); diff --git a/src/backend/stores/app/AppStore.js b/src/backend/stores/app/AppStore.js index 3a5c28256..c5614c24f 100644 --- a/src/backend/stores/app/AppStore.js +++ b/src/backend/stores/app/AppStore.js @@ -420,7 +420,11 @@ export class AppStore extends PuterStore { ); const affected = (result?.affectedRows ?? result?.changes ?? 0) > 0; if (affected) { - const fresh = await this.getById(appId); + // Not `getById`: on a cache miss it would cache the replica's + // pre-claim row. + const fresh = await this.#readFromDb('id', appId, { + primary: true, + }); if (fresh) { await this.invalidate(fresh); } @@ -570,7 +574,7 @@ export class AppStore extends PuterStore { await this.clearCacheTombstones( this.#cacheKeysForApp({ id: insertId, uid, name: allowed.name }), ); - const fresh = await this.getById(insertId); + const fresh = await this.#readBackById(insertId); if (fresh?.name) { await this.#clearOldAppNamesForName(fresh.name); } @@ -651,7 +655,7 @@ export class AppStore extends PuterStore { await this.clearCacheTombstones( this.#cacheKeysForApp({ id: insertId, uid, name: fields.name }), ); - const fresh = await this.getById(insertId); + const fresh = await this.#readBackById(insertId); await this.#invalidateListCachesForApps([fresh]); return fresh; } @@ -685,7 +689,7 @@ export class AppStore extends PuterStore { await this.#clearOldAppNamesForName(allowed.name); } - const fresh = await this.#readFromDb('id', appId); + const fresh = await this.#readFromDb('id', appId, { primary: true }); await this.#invalidateListCachesForApps([before, fresh]); if (fresh) { await this.#refreshCache({ ...fresh, ...allowed }); @@ -970,6 +974,18 @@ export class AppStore extends PuterStore { return normalized; } + /** A row this store just wrote, from the primary; cached on the way out. */ + async #readBackById(id) { + const fresh = await this.#readFromDb('id', id, { primary: true }); + if (!fresh) return null; + try { + await this.#writeCache(fresh); + } catch { + // Best effort; the next read fills it. + } + return fresh; + } + async #readFromDb(prop, value, { primary = false } = {}) { const read = primary ? (sql, params) => this.clients.db.pread(sql, params) @@ -1157,7 +1173,7 @@ export class AppStore extends PuterStore { async #refreshCache(app) { const keys = this.#cacheKeysForApp(app); - // `update` reads the replica, so it can refresh a row already deleted. + // A delete can land between `update`'s read and this refresh. await this.writeCacheUnlessDeleted(keys, () => this.#publishRefresh(keys, app), ); diff --git a/src/backend/stores/app/AppStore.test.js b/src/backend/stores/app/AppStore.test.js index 36efed1dd..158e3e70c 100644 --- a/src/backend/stores/app/AppStore.test.js +++ b/src/backend/stores/app/AppStore.test.js @@ -1129,6 +1129,92 @@ describe('AppStore CRUD and cache invalidation', () => { const map = await appStore.getByUids(['app-nope']); expect(map.size).toBe(0); }); + + // -- behind a lagging replica -------------------------------------- + + /** + * Replica reads of `apps` see only `staleRows`. sqlite's pread delegates to + * read, so the primary is pinned to the real one. + */ + const withLaggingReplica = async (staleRows, fn) => { + const realRead = db.read.bind(db); + const preadSpy = vi.spyOn(db, 'pread').mockImplementation(realRead); + const readSpy = vi + .spyOn(db, 'read') + .mockImplementation(async (sql, params) => + /FROM `apps`/.test(sql) + ? staleRows.map((r) => ({ ...r })) + : realRead(sql, params), + ); + try { + return await fn(); + } finally { + readSpy.mockRestore(); + preadSpy.mockRestore(); + } + }; + + const rawRow = async (id) => + (await db.read('SELECT * FROM `apps` WHERE `id` = ?', [id]))[0]; + + const cachedByUid = async (uid) => + JSON.parse((await redis.get(`apps:uid:${uid}`)) ?? 'null'); + + it('returns and caches a created app before the replica has it', async () => { + const app = await withLaggingReplica([], () => + createApp({ title: 'Lagged' }), + ); + expect(app?.title).toBe('Lagged'); + expect((await cachedByUid(app.uid))?.title).toBe('Lagged'); + + const origin = 'https://lagging-replica-bootstrap.test'; + const uid = `app-${uuidv5(origin, uuidv5.URL)}`; + const bootstrapped = await withLaggingReplica([], () => + appStore.createFromOrigin(uid, origin), + ); + expect(bootstrapped?.uid).toBe(uid); + expect((await cachedByUid(uid))?.index_url).toBe(origin); + }); + + it('returns and caches the updated app, not a replica copy missing an earlier update', async () => { + const app = await createApp({ title: 'One' }); + const stale = await rawRow(app.id); + await appStore.update(app.id, { title: 'Two' }); + + const updated = await withLaggingReplica([stale], () => + appStore.update(app.id, { description: 'later' }), + ); + + expect(updated).toMatchObject({ title: 'Two', description: 'later' }); + expect(await cachedByUid(app.uid)).toMatchObject({ + title: 'Two', + description: 'later', + }); + }); + + it('does not cache the pre-claim row a lagging replica still serves', async () => { + const name = freshName(); + await db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`) VALUES (?, ?, ?, ?)', + [`app-${name}`, name, name, `https://${name}.example.com/`], + ); + // Straight from the table, so nothing is cached yet. + const [stale] = await db.read('SELECT * FROM `apps` WHERE `name` = ?', [ + name, + ]); + + await withLaggingReplica([stale], async () => { + expect(await appStore.claimOwnership(Number(stale.id), OWNER)).toBe( + true, + ); + // Let any unawaited cache write land. + await new Promise((r) => setTimeout(r, 20)); + }); + + // Nothing cached, or the claimed row; never the unowned copy. + const cached = await cachedByUid(stale.uid); + expect(cached === null || cached.owner_user_id === OWNER).toBe(true); + }); }); describe('AppStore detailed stats', () => { diff --git a/src/backend/stores/notification/NotificationStore.js b/src/backend/stores/notification/NotificationStore.js index 78b85d6a8..04e8c8cdb 100644 --- a/src/backend/stores/notification/NotificationStore.js +++ b/src/backend/stores/notification/NotificationStore.js @@ -23,16 +23,22 @@ import { PuterStore } from '../types'; export class NotificationStore extends PuterStore { // -- Reads -------------------------------------------------------- - async getByUid(uid, { userId } = {}) { + /** + * Pass `primary` to read back a row just written; a replica may lag. + * + * @param {string} uid + * @param {{ userId?: number; primary?: boolean }} [opts] + */ + async getByUid(uid, { userId, primary = false } = {}) { const where = userId !== undefined ? 'WHERE `uid` = ? AND `user_id` = ?' : 'WHERE `uid` = ?'; const params = userId !== undefined ? [uid, userId] : [uid]; - const rows = await this.clients.db.read( - `SELECT * FROM \`notification\` ${where} LIMIT 1`, - params, - ); + const sql = `SELECT * FROM \`notification\` ${where} LIMIT 1`; + const rows = primary + ? await this.clients.db.pread(sql, params) + : await this.clients.db.read(sql, params); return this.#normalizeRow(rows[0]) ?? null; } @@ -182,7 +188,7 @@ export class NotificationStore extends PuterStore { 'INSERT INTO `notification` (`uid`, `user_id`, `value`, `type`, `audience`, `app_uid`) VALUES (?, ?, ?, ?, ?, ?)', [uid, userId, serialized, type, audience, appUid], ); - return this.getByUid(uid, { userId }); + return this.getByUid(uid, { userId, primary: true }); } /** diff --git a/src/backend/stores/notification/NotificationStore.test.js b/src/backend/stores/notification/NotificationStore.test.js index a1e28e5b4..1a9a50642 100644 --- a/src/backend/stores/notification/NotificationStore.test.js +++ b/src/backend/stores/notification/NotificationStore.test.js @@ -18,7 +18,7 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { setupTestServer } from '../../testUtil.ts'; describe('NotificationStore', () => { @@ -84,6 +84,32 @@ describe('NotificationStore', () => { expect(created.value).toBe('plain text'); }); + it('returns the created row before the replica has it', async () => { + // sqlite's pread delegates to read, so the primary is pinned to the + // real one while replica reads of `notification` come back empty. + const db = server.clients.db; + const realRead = db.read.bind(db); + const pread = vi.spyOn(db, 'pread').mockImplementation(realRead); + const read = vi + .spyOn(db, 'read') + .mockImplementation(async (q, p) => + /FROM `notification`/u.test(q) ? [] : realRead(q, p), + ); + let created; + try { + created = await store.create({ + userId: user.id, + value: { title: 'lagged' }, + }); + } finally { + read.mockRestore(); + pread.mockRestore(); + } + + expect(created?.user_id).toBe(user.id); + expect(created?.value).toEqual({ title: 'lagged' }); + }); + it('rejects a create with no user', async () => { await expect(store.create({ value: {} })).rejects.toThrow( 'userId is required', diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 8e8dc2edb..71f7001b0 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -53,10 +53,12 @@ const SHARED_IDS_CHUNK_SIZE = 1000; export class ShareStore extends PuterStore { // -- Reads -------------------------------------------------------- - async getByUid(uid) { - const rows = await this.clients.db.read( + /** Pass `primary` to read back a row just written; a replica may lag. */ + async getByUid(uid, { primary = false } = {}) { + const rows = await this.#select( 'SELECT * FROM `share` WHERE `uid` = ? LIMIT 1', [uid], + primary, ); return this.#normalizeRow(rows[0]) ?? null; } @@ -623,7 +625,7 @@ export class ShareStore extends PuterStore { [mode, data, existing[0].uid], ); return { - row: await this.getByUid(existing[0].uid), + row: await this.getByUid(existing[0].uid, { primary: true }), created: false, }; } @@ -644,7 +646,10 @@ export class ShareStore extends PuterStore { fsentryId, ], ); - return { row: await this.getByUid(uid), created: true }; + return { + row: await this.getByUid(uid, { primary: true }), + created: true, + }; } async create({ issuerUserId, recipientEmail, data }) { @@ -660,7 +665,7 @@ export class ShareStore extends PuterStore { 'INSERT INTO `share` (`uid`, `issuer_user_id`, `recipient_email`, `data`) VALUES (?, ?, ?, ?)', [uid, issuerUserId, recipientEmail, serialized], ); - return this.getByUid(uid); + return this.getByUid(uid, { primary: true }); } /** @@ -731,7 +736,10 @@ export class ShareStore extends PuterStore { data, ], ); - return this.getActive({ holderUserId, fsentryId, issuerUserId }); + return this.getActive( + { holderUserId, fsentryId, issuerUserId }, + { primary: true }, + ); } /** @@ -797,7 +805,10 @@ export class ShareStore extends PuterStore { data, ], ); - return this.getActiveGroup({ holderGroupId, fsentryId, issuerUserId }); + return this.getActiveGroup( + { holderGroupId, fsentryId, issuerUserId }, + { primary: true }, + ); } /** @@ -805,12 +816,17 @@ export class ShareStore extends PuterStore { * @param {number} input.holderGroupId * @param {number} input.fsentryId * @param {number} input.issuerUserId + * @param {{ primary?: boolean }} [opts] */ - async getActiveGroup({ holderGroupId, fsentryId, issuerUserId }) { - const rows = await this.clients.db.read( + async getActiveGroup( + { holderGroupId, fsentryId, issuerUserId }, + { primary = false } = {}, + ) { + const rows = await this.#select( 'SELECT * FROM `share` WHERE `holder_group_id` = ? AND ' + '`fsentry_id` = ? AND `issuer_user_id` = ? LIMIT 1', [holderGroupId, fsentryId, issuerUserId], + primary, ); return this.#normalizeRow(rows[0]) ?? null; } @@ -847,12 +863,17 @@ export class ShareStore extends PuterStore { * @param {number} input.holderUserId * @param {number} input.fsentryId * @param {number} input.issuerUserId + * @param {{ primary?: boolean }} [opts] */ - async getActive({ holderUserId, fsentryId, issuerUserId }) { - const rows = await this.clients.db.read( + async getActive( + { holderUserId, fsentryId, issuerUserId }, + { primary = false } = {}, + ) { + const rows = await this.#select( 'SELECT * FROM `share` WHERE `holder_user_id` = ? AND ' + '`fsentry_id` = ? AND `issuer_user_id` = ? LIMIT 1', [holderUserId, fsentryId, issuerUserId], + primary, ); return this.#normalizeRow(rows[0]) ?? null; } @@ -905,7 +926,7 @@ export class ShareStore extends PuterStore { ], ); if ((result?.affectedRows ?? result?.changes ?? 0) === 0) return null; - return this.getByUid(uid); + return this.getByUid(uid, { primary: true }); } /** @@ -1037,11 +1058,12 @@ export class ShareStore extends PuterStore { : this.clients.db.read(sql, params); } - async getAnyone(fsentryId) { - const rows = await this.#readAnyone( - 'SELECT * FROM `share` WHERE `fsentry_id` = ? AND `anyone` = 1 LIMIT 1', - [fsentryId], - ); + async getAnyone(fsentryId, { primary = false } = {}) { + const sql = + 'SELECT * FROM `share` WHERE `fsentry_id` = ? AND `anyone` = 1 LIMIT 1'; + const rows = primary + ? await this.clients.db.pread(sql, [fsentryId]) + : await this.#readAnyone(sql, [fsentryId]); return this.#normalizeRow(rows[0]) ?? null; } @@ -1140,7 +1162,7 @@ export class ShareStore extends PuterStore { issuerUserId, ], ); - return this.getAnyone(fsentryId); + return this.getAnyone(fsentryId, { primary: true }); } /** @@ -1231,6 +1253,12 @@ export class ShareStore extends PuterStore { // -- Internals ---------------------------------------------------- + #select(sql, params, primary) { + return primary + ? this.clients.db.pread(sql, params) + : this.clients.db.read(sql, params); + } + /** The recursive walk of a directory's row ids, by parent linkage. */ #subtreeCte() { return ( diff --git a/src/backend/stores/share/ShareStore.test.js b/src/backend/stores/share/ShareStore.test.js index 225694f03..9aa2d3b84 100644 --- a/src/backend/stores/share/ShareStore.test.js +++ b/src/backend/stores/share/ShareStore.test.js @@ -1000,5 +1000,152 @@ describe('ShareStore', () => { expect(await store.getByUid(mine.row.uid)).toBeNull(); expect(await store.getByUid(theirs.row.uid)).not.toBeNull(); }); + + describe('behind a lagging replica', () => { + const rawRow = async (uid) => + ( + await server.clients.db.read( + 'SELECT * FROM `share` WHERE `uid` = ?', + [uid], + ) + )[0]; + + /** + * Replica reads of `share` see only `staleRows`. sqlite's pread + * delegates to read, so the primary is pinned to the real one. + */ + const lagShares = (staleRows = []) => { + const db = server.clients.db; + const realRead = db.read.bind(db); + const pread = vi + .spyOn(db, 'pread') + .mockImplementation(realRead); + const read = vi + .spyOn(db, 'read') + .mockImplementation(async (q, p) => + /FROM `share`/u.test(q) + ? staleRows.map((r) => ({ ...r })) + : realRead(q, p), + ); + return () => { + read.mockRestore(); + pread.mockRestore(); + }; + }; + + it('returns the rows it inserts before the replica has them', async () => { + const entry = await makeEntry(issuer); + const team = await server.stores.team.create({ + ownerUserId: issuer.id, + name: 'Lagging replica', + }); + + const restore = lagShares(); + try { + const invite = await store.create({ + issuerUserId: issuer.id, + recipientEmail: `lag-${uuidv4()}@test.local`, + data: {}, + }); + expect(invite?.issuer_user_id).toBe(issuer.id); + + const pending = await store.upsertPending({ + issuerUserId: issuer.id, + recipientEmail: `lag-${uuidv4()}@test.local`, + fsentryId: entry.id, + mode: 'read', + }); + expect(pending.created).toBe(true); + expect(pending.row?.mode).toBe('read'); + + const active = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + expect(active?.holder_user_id).toBe(holder.id); + + const group = await store.upsertActiveGroup({ + issuerUserId: issuer.id, + holderGroupId: team.id, + fsentryId: entry.id, + mode: 'read', + }); + expect(group?.holder_group_id).toBe(team.id); + + const link = await store.upsertAnyone({ + issuerUserId: issuer.id, + fsentryId: entry.id, + mode: 'read', + }); + expect(link?.mode).toBe('read'); + } finally { + restore(); + } + }); + + it('returns the row as written, not the replica copy from before', async () => { + const entry = await makeEntry(issuer); + const email = `lag-${uuidv4()}@test.local`; + const { row: invite } = await store.upsertPending({ + issuerUserId: issuer.id, + recipientEmail: email, + fsentryId: entry.id, + mode: 'read', + }); + const active = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'read', + }); + const claimable = await store.create({ + issuerUserId: issuer.id, + recipientEmail: `lag-${uuidv4()}@test.local`, + data: {}, + }); + + let restore = lagShares([await rawRow(invite.uid)]); + try { + const remoded = await store.upsertPending({ + issuerUserId: issuer.id, + recipientEmail: email, + fsentryId: entry.id, + mode: 'write', + }); + expect(remoded.created).toBe(false); + expect(remoded.row.mode).toBe('write'); + } finally { + restore(); + } + + restore = lagShares([await rawRow(active.uid)]); + try { + const upgraded = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: holder.id, + fsentryId: entry.id, + mode: 'write', + }); + expect(upgraded.mode).toBe('write'); + } finally { + restore(); + } + + restore = lagShares([await rawRow(claimable.uid)]); + try { + const applied = await store.applyPending({ + uid: claimable.uid, + holderUserId: holder.id, + fsentryId: (await makeEntry(issuer)).id, + mode: 'read', + }); + expect(applied.holder_user_id).toBe(holder.id); + } finally { + restore(); + } + }); + }); }); }); diff --git a/src/backend/stores/subdomain/SubdomainStore.test.ts b/src/backend/stores/subdomain/SubdomainStore.test.ts index a0fb87d78..5e89857fe 100644 --- a/src/backend/stores/subdomain/SubdomainStore.test.ts +++ b/src/backend/stores/subdomain/SubdomainStore.test.ts @@ -123,6 +123,37 @@ describe('SubdomainStore cache coherency', () => { expect(after?.root_dir_id).toBe(2); }); + it('update returns and caches the new row before the replica has it', async () => { + const userId = await makeUser(); + const subdomain = `sds-lag-${Math.random().toString(36).slice(2, 8)}`; + const row = await store.create({ userId, subdomain, rootDirId: 1 }); + + // sqlite's pread delegates to read, so the primary is pinned to the + // real one while replica reads of `subdomains` come back empty. + const db = server.clients.db; + const realRead = db.read.bind(db); + const pread = vi.spyOn(db, 'pread').mockImplementation(realRead); + const read = vi + .spyOn(db, 'read') + .mockImplementation(async (q, p) => + /FROM `subdomains`/u.test(q) ? [] : realRead(q, p), + ); + let updated; + try { + updated = await store.update( + row.uuid, + { root_dir_id: 2 }, + { userId }, + ); + } finally { + read.mockRestore(); + pread.mockRestore(); + } + + expect(updated?.root_dir_id).toBe(2); + expect((await store.getBySubdomain(subdomain))?.root_dir_id).toBe(2); + }); + it('delete leaves a negative marker rather than a stale row', async () => { const userId = await makeUser(); const subdomain = `sds-delete-${Math.random().toString(36).slice(2, 8)}`; diff --git a/src/backend/stores/subdomain/SubdomainStore.ts b/src/backend/stores/subdomain/SubdomainStore.ts index ff0c130f8..6c3402937 100644 --- a/src/backend/stores/subdomain/SubdomainStore.ts +++ b/src/backend/stores/subdomain/SubdomainStore.ts @@ -612,7 +612,7 @@ export class SubdomainStore extends PuterStore { [...values, ...whereParams], ); - const after = await this.getByUuid(uuid, { userId }); + const after = await this.getByUuid(uuid, { userId, primary: true }); if (before?.subdomain && before.subdomain !== after?.subdomain) { await this.publishCacheKeys({ keys: [this.#cacheKey(before.subdomain)],