mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-06 14:07:51 +00:00
* refactor(stores): one read-through RowCache for UserStore and AppStore UserStore and AppStore each carried the same multi-key row cache: key builder, cache read, tombstone-guarded backfill, broadcast refresh, invalidate and tombstone-on-delete. They now share RowCache on PuterStore, which also single-flights concurrent misses for one key and batches getByIds/getByUids: one pipelined tombstone check for the loaded rows and one pipelined backfill instead of per-row awaits. Cache keys and TTLs are unchanged. * perf(apps): invalidate cached app lists by generation, not by tracker scan Every app write SMEMBERS'd a global set of every cached list key, parsed each key back into its filters and deleted the matches. List pages are now keyed by their owner's generation token (or a shared one for unscoped lists), and a write replaces the tokens it affects: O(1) per write, and a page loaded before a write can no longer be cached after it. * refactor(stores): one keyset page helper for store listings Thirteen store listings each cut a LIMIT n+1 read to a page and sealed the last row's position, and each opened an id cursor its own way. They now use keysetPage() and openIdCursor() from util/pagination. Cursor payloads and sealing are unchanged; a cursor without a usable id still fails with 400 in ShareStore and still restarts the listing elsewhere (strict option). * refactor(stores): delete store methods nothing calls AppStore.existsByIndexUrl, OIDCStore.unlinkByUserId, TeamStore.listByOwner/listActiveSeatUuids, SubdomainStore.getByDomain/ listByDomain, FSEntryStore.ensureDirectoriesForUser, ShareStore.listByIssuer/listByRecipientEmail/deleteByRecipientEmail/ getDailyShareCount/listByHolderAndFsentries and PermissionStore.invalidateScanCache/deleteUserUserPermsByPermissionPrefix had no production callers. ShareStore.listByFsentries was a copy of listReaching; its callers use the latter. SubdomainStore SMEMBERS'd a per-user prefix-list tracker on every write and broadcast its deletion, but nothing ever wrote that set or cached a prefix listing. The reads are gone. * refactor(stores): shared escapeLike and bumpGeneration helpers escapeLike replaces the LIKE escaping copied into PermissionStore (twice), ShareStore and FSEntryStore, and now also guards SubdomainStore's prefix filters, which passed caller prefixes into LIKE unescaped. bumpGeneration sends INCR and EXPIRE in one pipeline instead of two awaited commands in PermissionStore, EventSubscriptionStore and PresenceStore. * refactor(stores): read affected rows off WriteResult Every database client returns affectedRows and anyRowsAffected, so the `affectedRows ?? changes ?? 0` fallbacks in ShareStore, NotificationStore, UserStore and UserBlockStore read those directly. * perf(stores): batch flat-permission KV deletes and durable cache drops PermissionStore deleted each flat KV entry with its own kv.del; one kv.batchDel now covers the set. DurableSubscriptionStore's suspend and reap dropped each row from the cache separately; EventSubscriptionStore gains dropDurables, which drops them per owner in one pass, and dropDurable calls it. suspend keeps one conditional UPDATE per row, which is how concurrent settles learn which rows each of them suspended. * refactor(fs): fold FSEntryStore's duplicate getters, sort maps and cache keys - getEntryByUuid and getEntryById share one by-column getter. - listChildren and listChildrenPage read one sort table; the offset listing now coalesces NULL modified/size like the cursor listing, so both order NULLs the same way on every engine. - Entry cache keys come from one builder instead of hand-built strings. - getEntriesByIds, getEntriesByPaths and the per-user path reader fetch their cache hits in one pipelined round trip instead of a GET per key, through readCacheValues, which RowCache.readMany now uses too. * fix(apps): cache the row a lost createFromOrigin race reads from the primary Keeps AppStore's private #writeCache as a RowCache write, so code that still calls it (#4166's read-back) loads in either merge order.
206 lines
7.1 KiB
TypeScript
206 lines
7.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
decodeCursor,
|
|
encodeCursor,
|
|
keysetPage,
|
|
normalizeLimit,
|
|
normalizeOffset,
|
|
openCursor,
|
|
openIdCursor,
|
|
sealCursor,
|
|
} from './pagination';
|
|
import { HttpError } from '../core/http';
|
|
|
|
describe('pagination util', () => {
|
|
describe('encodeCursor / decodeCursor', () => {
|
|
it('round-trips a payload', () => {
|
|
const payload = { id: 42, s: 'name' };
|
|
expect(decodeCursor(encodeCursor(payload))).toEqual(payload);
|
|
});
|
|
|
|
it('returns undefined for empty payloads', () => {
|
|
expect(encodeCursor(undefined)).toBeUndefined();
|
|
expect(encodeCursor({})).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined for null/undefined/blank cursors', () => {
|
|
expect(decodeCursor(undefined)).toBeUndefined();
|
|
expect(decodeCursor(null)).toBeUndefined();
|
|
expect(decodeCursor(' ')).toBeUndefined();
|
|
});
|
|
|
|
it('passes through object cursors', () => {
|
|
expect(decodeCursor({ id: 1 })).toEqual({ id: 1 });
|
|
});
|
|
|
|
it('accepts raw JSON cursors', () => {
|
|
expect(decodeCursor('{"id":7}')).toEqual({ id: 7 });
|
|
});
|
|
|
|
it('throws 400 on garbage', () => {
|
|
expect(() => decodeCursor('!!!not-a-cursor!!!')).toThrowError(
|
|
HttpError,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('sealCursor / openCursor', () => {
|
|
const SECRET = 'cursor-test-secret';
|
|
|
|
it('round-trips a payload', () => {
|
|
const sealed = sealCursor({ id: 1234567 }, SECRET);
|
|
expect(openCursor(sealed, SECRET)).toEqual({ id: 1234567 });
|
|
});
|
|
|
|
it('carries nothing a holder can read back', () => {
|
|
const sealed = sealCursor({ id: 1234567 }, SECRET)!;
|
|
expect(
|
|
Buffer.from(sealed, 'base64url').toString('latin1'),
|
|
).not.toContain('1234567');
|
|
expect(() => decodeCursor(sealed)).toThrowError(HttpError);
|
|
});
|
|
|
|
it('does not repeat itself for the same position', () => {
|
|
expect(sealCursor({ id: 1 }, SECRET)).not.toBe(
|
|
sealCursor({ id: 1 }, SECRET),
|
|
);
|
|
});
|
|
|
|
it('refuses a cursor altered in transit', () => {
|
|
const raw = Buffer.from(
|
|
sealCursor({ id: 9 }, SECRET)!,
|
|
'base64url',
|
|
);
|
|
raw[raw.length - 1] ^= 1;
|
|
expect(() =>
|
|
openCursor(raw.toString('base64url'), SECRET),
|
|
).toThrowError(HttpError);
|
|
});
|
|
|
|
it('refuses a cursor sealed under another secret', () => {
|
|
const sealed = sealCursor({ id: 9 }, 'some-other-secret');
|
|
expect(() => openCursor(sealed, SECRET)).toThrowError(HttpError);
|
|
});
|
|
|
|
it('still reads a plain cursor issued before sealing', () => {
|
|
expect(openCursor(encodeCursor({ id: 7 }), SECRET)).toEqual({
|
|
id: 7,
|
|
});
|
|
});
|
|
|
|
it('answers no cursor the way decodeCursor does', () => {
|
|
expect(sealCursor({}, SECRET)).toBeUndefined();
|
|
expect(openCursor(undefined, SECRET)).toBeUndefined();
|
|
expect(openCursor(null, SECRET)).toBeUndefined();
|
|
expect(openCursor(' ', SECRET)).toBeUndefined();
|
|
});
|
|
|
|
it('falls back to a plain cursor without a secret', () => {
|
|
const plain = sealCursor({ id: 3 }, undefined);
|
|
expect(plain).toBe(encodeCursor({ id: 3 }));
|
|
expect(openCursor(plain, undefined)).toEqual({ id: 3 });
|
|
});
|
|
});
|
|
|
|
describe('normalizeLimit', () => {
|
|
it('returns undefined when absent', () => {
|
|
expect(normalizeLimit(undefined)).toBeUndefined();
|
|
expect(normalizeLimit(null)).toBeUndefined();
|
|
});
|
|
|
|
it('floors and caps', () => {
|
|
expect(normalizeLimit(10.9)).toBe(10);
|
|
expect(normalizeLimit(9000, { cap: 500 })).toBe(500);
|
|
});
|
|
|
|
it('throws 400 on zero, negative, or non-numeric', () => {
|
|
expect(() => normalizeLimit(0)).toThrowError(HttpError);
|
|
expect(() => normalizeLimit(-5)).toThrowError(HttpError);
|
|
expect(() => normalizeLimit('abc')).toThrowError(HttpError);
|
|
});
|
|
});
|
|
|
|
describe('normalizeOffset', () => {
|
|
it('returns undefined when absent', () => {
|
|
expect(normalizeOffset(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('accepts zero', () => {
|
|
expect(normalizeOffset(0)).toBe(0);
|
|
});
|
|
|
|
it('throws 400 on negative or non-numeric', () => {
|
|
expect(() => normalizeOffset(-1)).toThrowError(HttpError);
|
|
expect(() => normalizeOffset('x')).toThrowError(HttpError);
|
|
});
|
|
|
|
it('throws 400 above the cap', () => {
|
|
expect(() => normalizeOffset(5001, { cap: 5000 })).toThrowError(
|
|
HttpError,
|
|
);
|
|
expect(normalizeOffset(5000, { cap: 5000 })).toBe(5000);
|
|
});
|
|
});
|
|
|
|
describe('openIdCursor', () => {
|
|
const secret = 'keyset-secret';
|
|
|
|
it('returns null without a cursor', () => {
|
|
expect(openIdCursor(undefined, secret)).toBeNull();
|
|
expect(openIdCursor('', secret)).toBeNull();
|
|
});
|
|
|
|
it('reads the id from a sealed or plain cursor', () => {
|
|
expect(openIdCursor(sealCursor({ id: 41 }, secret), secret)).toBe(
|
|
41,
|
|
);
|
|
expect(openIdCursor(encodeCursor({ id: 7 }), secret)).toBe(7);
|
|
});
|
|
|
|
it('reads a cursor without a usable id as the first page', () => {
|
|
expect(
|
|
openIdCursor(encodeCursor({ appUid: 'app-1' }), secret),
|
|
).toBeNull();
|
|
expect(openIdCursor(encodeCursor({ id: -3 }), secret)).toBeNull();
|
|
});
|
|
|
|
it('refuses a cursor without a usable id when strict', () => {
|
|
expect(() =>
|
|
openIdCursor(encodeCursor({ id: 'x' }), secret, {
|
|
label: 'share cursor',
|
|
strict: true,
|
|
}),
|
|
).toThrowError('invalid share cursor');
|
|
});
|
|
|
|
it('refuses an unreadable cursor either way', () => {
|
|
expect(() => openIdCursor('!!!', secret)).toThrowError(HttpError);
|
|
});
|
|
});
|
|
|
|
describe('keysetPage', () => {
|
|
const secret = 'keyset-secret';
|
|
const rows = [{ id: 3 }, { id: 5 }, { id: 9 }];
|
|
|
|
it('returns every row and no cursor when nothing follows', () => {
|
|
expect(keysetPage(rows, 3, secret)).toEqual({ rows });
|
|
});
|
|
|
|
it('cuts to the limit and points the cursor at the last row kept', () => {
|
|
const page = keysetPage(rows, 2, secret);
|
|
expect(page.rows).toEqual([{ id: 3 }, { id: 5 }]);
|
|
expect(openIdCursor(page.cursor, secret)).toBe(5);
|
|
});
|
|
|
|
it('takes a custom position, plain without a secret', () => {
|
|
const page = keysetPage(
|
|
[{ app: 'a' }, { app: 'b' }],
|
|
1,
|
|
undefined,
|
|
(last) => ({ appUid: last.app }),
|
|
);
|
|
expect(decodeCursor(page.cursor)).toEqual({ appUid: 'a' });
|
|
});
|
|
});
|
|
});
|