@codetonight/antepaste-core
v0.1.0
Published
Pure TypeScript clipboard-history model for Antepaste. No Electron, no native code.
Readme
@codetonight/antepaste-core
The pure TypeScript model of Antepaste's clipboard history: the clips, the towers they stack into, the schema that stores them, and the queries that read and write it. No Electron and no native code — ESLint rejects any electron or electron/* import here, and any filesystem import in src, so it stays that way.
Core never opens a database. It is handed one and works through it, which is what lets the same code back the desktop app, the MCP server and an in-memory test database without knowing which it is talking to.
What is in it
| | |
| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| hashContent | The SHA-256 that decides whether two copies are the same copy, and therefore whether a repeat copy moves an existing clip to the top or stacks a new one. |
| generatePreview | The one-line summary shown in the history list. |
| detectLanguage, formatTowerAsContext | Renders a tower as one markdown block for pasting into a chat, fencing clips that are clearly code. |
| validateTowerName, validateUuid, validateContent, validateTheme, validateSettingsKey | The checks every value crossing into the model passes. Each returns null or a ValidationError. |
| isOpenableUrl | Whether a string is safe to hand to the operating system as a link. http and https only. |
| decideCapture, DEFAULT_EXCLUDED_BUNDLES | Whether a copy is recorded at all, and under what flags. See "The capture policy" below. |
| createWalkSession | The cursor and undo behind the paste-then-Up gesture. See "The walk" below. |
| MIGRATIONS, applyMigrations, latestMigrationVersion | The schema as an append-only list of numbered steps, currently 1 to 9. |
| createRepositories and the nine individual factories | Every query, grouped by the table it reads. |
| SqliteDatabase, DbProvider | The database contract, described structurally so core is typed without the driver. |
The provider pattern
Repositories take a function that returns the current database, not a database:
import Database from 'better-sqlite3';
import { applyMigrations, createRepositories } from '@codetonight/antepaste-core';
const db = new Database('antepaste.db');
db.pragma('journal_mode = WAL');
applyMigrations(db, (version) => console.log(`applying migration ${version}`));
const { clipRepo, towerRepo } = createRepositories(() => db);That indirection is the whole design. A factory capturing a handle would go on using it after it was closed or replaced, which is why tests — each building a fresh in-memory database — can construct their repositories once and have every case run against a different one. It is also what lets an application reopen or move its file without rebuilding anything. packages/core/test/repositories-sync.test.ts asserts the property directly, so replacing the provider with a cached handle fails as itself rather than as a dozen unrelated errors.
The SqliteDatabase interface is structural rather than an import of better-sqlite3's own types, so a consumer on better-sqlite3 11 and one on 13 are both satisfied without agreeing on a version of @types/better-sqlite3. better-sqlite3 ships no typings itself — there is not one .d.ts in the 13.0.3 tarball — so @types/better-sqlite3 is what every consumer's handle is typed through, and what the contract has to fit.
It fits it without an adapter, measured rather than assumed: a Database assigns to SqliteDatabase under @types/better-sqlite3 7.6.13 and 9.6.0, on TypeScript 5.9 and 6.0. That needed the bind positions widened to SqliteBindParam. Those typings make prepare generic over BindParameters extends unknown[] | {} and return Statement<[Bind]> for a named-parameter object, which types the bind position as an object — and an object type excludes null, so on TypeScript 5.x, where the call resolves to the union of both branches, a real handle did not satisfy the interface at all and a consumer had to write an adapter to get past it. SqliteBindParam is a bind value or a named-parameter object whose values are bind values — precise enough to admit that branch, narrow enough that a boolean, an undefined and an object of objects all stay compile errors. undefined is the one that earns its keep: the driver does not object to it, it stores a null and reports success. test/sqlite-contract.test.ts checks all of it at compile time and at run time.
The capture policy
decideCapture answers one question — should this copy be recorded, and under what flags — from an input that contains everything it needs, so it reads no clipboard, no clock and no database. That is what lets the privacy rulings in docs/decisions/0001-step0-councils.md §4 be checked by the test suite rather than asserted in a README.
The rules are applied in order, and a refusal always outranks a capture:
- A type asking not to be recorded. The four nspasteboard.org markers, the three per-application ones that predate them, and Windows'
ExcludeClipboardContentFromMonitorProcessingall refuse on presence alone.CanIncludeInClipboardHistoryis a DWORD rather than a marker, so a platform adapter must render it asCanIncludeInClipboardHistory=0for it to mean anything; a bare name or=1decides nothing. - An application we never record. A declared
org.nspasteboard.sourceis believed. Otherwise the application in front is used — but only while it is still evidence, which the next rule qualifies.DEFAULT_EXCLUDED_BUNDLESholds the six macOS bundle identifiers and four Windows image names, compared without regard to case. - A copy nobody can be credited with. No declared source and an application switch inside the capture window means the application in front now is a coincidence, not a source, so the copy is refused as
unknown-sourcerather than credited to whoever happens to be there. - A secret. A run of four to eight digits within forty characters of code, OTP, verification, passcode or 2FA; or twenty or more characters with no whitespace carrying more than 3.5 bits of Shannon entropy each. Both are concealed and transient. They differ in what happens next, and the difference is the strength of the evidence:
- A one-time code expires two minutes later, which is what makes pasting it a second time work without leaving it in history for a month. Its preview is the message with the digits taken out —
Your code is 1234becomesYour code is …. Masking such a message by position would leak the code, because a code sits at the end of the sentence announcing it: the first draft of this producedYo…34, which is half of it. - An entropy-only match is not destroyed. Twenty unbroken high-entropy characters is a guess, and in information terms it is indistinguishable from a git commit hash (3.97 bits per character) or a package integrity digest (4.86). Destroying those two minutes later, with a pin no defence, would quietly delete the most commonly copied thing in a developer's day. So it is hidden and never synced, and it stays: a guess may cost visibility, never the data. Its preview shows two characters at each end, which is enough to tell two tokens apart.
- A one-time code expires two minutes later, which is what makes pasting it a second time work without leaving it in history for a month. Its preview is the message with the digits taken out —
- The reserved
pck-cold-prefix, which is a marker rather than something a user copied.
Images and files pass through rules 1 to 3 exactly as text does, which is the other half of that ruling. They do not reach rules 4 and 5, which read text an image does not have and would mask half the deep paths anyone copies.
The entropy rule's known cost is that a long unbroken URL, path, hash or identifier can read as a token and be hidden behind a mask. shannonEntropyPerCharacter is exported so the threshold is a number anyone can compute rather than a claim. It would be wrong if, in use, masked ordinary strings outnumbered caught secrets — the fix then is to require a character-class mix as well as entropy, not to drop the rule. It would also be wrong if a real credential were commonly pasted with no accompanying word and needed an expiry to be safe; the fix there is a narrower classifier, not a destructive default. Both asymmetries were asked for by a sovereign council (seal a78eb8eb602096c5).
The walk
createWalkSession is the paste-then-Up gesture with no operating system in it: where the cursor is, what should be written, and whether a write is a preview or the real thing. It writes nothing. It says what should be written, and the caller makes whichever of those writes it is in a position to make — on macOS the native addon has already replaced the text in the field by the time a step is reported, so only the normal write at the end is the engine's to do.
Five invariants, each with its own cases in test/walk-session.test.ts:
- The cursor clamps at both ends and says so. A step past either end leaves the cursor where it was, writes nothing, and comes back with
clamped: true, so a caller can tell "you are already at the oldest" from "you moved". - Duplicates are never collapsed while a gesture is running. Two identical items are two items and walking past them takes two taps. Collapsing them would make the same Up tap move one place sometimes and two at others, based on content the user cannot see. Deduplicating is the history's job, at the moment a copy is recorded.
restorereturns to index 0 and makes no normal write. It previews the original and leaves the gesture open, so Escape puts things back without ending the walk.commitat index 0 writes nothing; at any other index it writes exactly one normal write. Index 0 is where the paste already left things, and writing it again would be a clipboard change nobody asked for.- A history arriving mid-gesture waits. Swapping the list under a walk would move the cursor to a different item without the user touching anything — and a copy landing mid-gesture is exactly what the paste being walked away from tends to produce. Between gestures there is nothing to disturb, so it is taken up at once.
A closed session accepts a step, a restore or a commit and ignores it rather than throwing. A native event stream can deliver a step after a disarm, and dropping a late event is the right answer to that; crashing the application is not.
better-sqlite3
A peer dependency, range >=11.7.0 <14: the consumer owns the driver and the file, core owns the schema and the queries. The range covers TWR's locked 11.10.0, so the existing desktop app can adopt core without moving its driver. Tests run against 13.0.3, which ships N-API prebuilds inside its published tarball for every platform in CI, so there is no install script and no compile step.
Schema
Migrations 1 to 6 are TWR's, moved across byte for byte. Three are new:
- 7 — a
blobstable keyed by the same content hash the rest of the model uses, and a nullableblob_hashonclip_itemsthat references it, so a large clip can live on disk once instead of inline in every row. - 8 — where a clip came from and whether it may leave the device:
device_id,hlc,source_bundle,concealed,transient,expires_at,syncanddeleted_at. The last is a tombstone rather than aDELETE, because a row simply removed would be sent back by the next device that still holds it. Nopinnedcolumn is added;clip_itemshas had one since migration 1. - 9 —
op_logand an index onhlc, a replayable record of changes. Stamps compare as strings, so two devices agree on an order without trusting each other's clocks.
markExpired, tombstone and setFlags write these columns. The read methods do not yet filter tombstoned clips out — that belongs with the sync work that will consume them.
Provenance
Extracted from TWR (CodeTonight SA) by V>> (Lourens Cornelius "Laurie" Scheepers), with assistance of Claude Opus 5 (claude-opus-5) and Claude Fable 5.1 (claude-fable-5-1).
Licence: MIT (see LICENSE).
