Each persister calls an EntryMapper before writing a PowerSync CrudEntry:
export type EntryMapper = (entry: CrudEntry) => MappedEntry | null;The mapper runs synchronously inside the database transaction. It returns one
{ table, op, id, data } entry, or null to skip the operation. Pass a custom mapper
as the second argument to the persister factory:
createPostgresPersister(uri, myCustomMapper);The factories are configured in backend/src/persistence/persister-factories.ts.
Postgres, MySQL, and SQL Server use defaultMapper. It preserves table and field
names without converting or validating values, and logs this on every call. The row ID
is taken from entry.id (falling back to op_data.id) and removed from the remaining fields.
Replace it if your client and database schemas differ.
MongoDB uses createMongoMapper. At startup, discoverSchema reads collection
$jsonSchema validators through db.listCollections() and builds field type
converters. See backend/src/persistence/mongo/mongo-schema.ts.
With the default MongoDB mapper, a write to a collection without a discovered validator
fails with SCHEMA_MISMATCH. The error includes the original operation index, and the
whole transaction is rolled back. The shared fatal-error handler
routes the failure. A custom mapper handles its own schema mapping.
Add a $jsonSchema validator for each collection that accepts writes through the
default mapper. Restart the backend after adding or changing validators.
- Rename tables or fields. Return a different
tableor change the keys indata. - Convert values. Use a converter for fields whose JSON values differ from the
database type, such as timestamps or booleans. See
applySchemainmongo-schema.ts. - Set server-controlled fields. Add or overwrite values such as
updated_at. For ownership fields such asowner_idandcreated_by, see authorization. The mapper does not receive the verified identity. - Remove fields. Omit fields that the database does not store or clients may not write.
entry.tableis the client table name, exactly as the client's PowerSync schema and sync streams name it. A sync stream can serve a source table under a different name (FROM todos AS expanded_todos) or add alias columns (title AS fake_title); uploads use those client names, which usually do not exist in the source database.entry.op_dataholds only what the client wrote. ForPATCHthat is only the changed columns, not the whole row. ForDELETEit is absent.- The row ID is
entry.id(fall back toop_data.id). Return it asidand leave it out ofdata: the SQL persisters take the ID frommapped.idand ignore anidkey indata. - A rename is therefore a change to
tableand to the keys ofdata. If two client columns can map to the same source column in onePUT, decide which wins; the stream may serve the same value under both names.
defaultMapper logs an error on every call to warn that nothing is renamed or validated.
A mapper that calls it prints that warning for every operation, even though it does map.
Copy its few lines of ID and field handling instead.
// backend/src/mapping/renames.ts
import type { EntryMapper } from './types.js';
// Client table -> source table, and per-table client column -> source column.
const TABLES: Record<string, { table: string; columns: Record<string, string> }> = {
expanded_todos: { table: 'todos', columns: { fake_title: 'title' } }
};
export const renamingMapper: EntryMapper = (entry) => {
const rule = TABLES[entry.table];
const table = rule?.table ?? entry.table;
const raw = entry.op_data ?? {};
const id = (entry.id ?? raw.id) as string;
if (entry.op === 'DELETE') return { table, op: entry.op, id, data: {} };
const { id: _discardId, ...fields } = raw;
if (!rule) return { table, op: entry.op, id, data: fields };
const data: Record<string, unknown> = {};
for (const [key, value] of Object.entries(fields)) {
const target = rule.columns[key] ?? key;
if (target !== key && target in fields) continue; // the source column's own value wins
data[target] = value;
}
return { table, op: entry.op, id, data };
};Return null to drop an operation, for example for a client-only table that has no source
table. Skipped operations are not an error.
Factories take (uri, mapper?) and are stored in a record, so wrap the one you want. Edit
backend/src/persistance/persister-factories.ts:
import { renamingMapper } from '../mapping/renames.js';
export const factories: Record<string, PersisterFactory> = {
mongodb: createMongoPersister,
postgres: createPostgresPersister,
mysql: (uri) => createMySQLPersister(uri, renamingMapper),
mssql: createMSSQLPersister
};Rebuild the image (docker compose up --build) because the TypeScript is compiled into it. Each
engine's factory needs its own change.
Passing a mapper to createMongoPersister skips discoverSchema, so your mapper must do any
type conversion that the default MongoDB mapper would have derived from the $jsonSchema
validators.
A client table or column that does not exist in the source fails as a SCHEMA_MISMATCH fatal
error (for example Table 'db.expanded_todos' doesn't exist), reported with the index of the
failing operation, and the whole transaction is rolled back. See error handling.
EntryMapper supports synchronous, one-to-one transformations. It cannot perform
asynchronous database lookups or return writes to multiple tables or documents.
Implement those operations in the relevant persister's updateBatch method under
backend/src/persistence/<database>/, using its database connection and transaction.