Server connectors
Push table rows to Notion and Google Sheets, or sync them both ways, from your server with the optional connector modules.
YaYaw Table ships optional server connectors for Notion and Google Sheets, for React and Vue. They push table rows to a Notion database or a Google Sheets tab from your server, or sync them both ways, and are the natural back end of a Connect destination. Give that destination a connector and the table provides the send screen itself: target and tab, column mapping, key field, mode, records and result. See Connector screens.
The connectors are plain TypeScript with no framework or npm dependency. They only use fetch and Web Crypto, so they run in Node 20+, Bun, Deno and edge runtimes. They are server-only: never import them in client components.
Who does what
The library handles the push itself:
converting values to the target's types (text, numbers, dates, checkboxes, selects, URLs, emails);
mapping table columns to Notion properties or sheet columns;
upserting rows keyed by a Yayaw ID field, or replacing a sheet's content;
retries that respect
Retry-After, with backoff on server errors, and rate limiting;typed errors that never contain row data.
Your application provides what a library cannot:
| Your application | Why |
|---|---|
| Stores tokens and service account keys | They are secrets. Store them encrypted, per organization or user, and never send them to the browser. |
| Checks who may push what | Decide who may connect a destination and push which view to it, and check it on every call. |
| Calls the functions | From its server actions, API routes or server handlers. |
| Loads the rows | On the server, with the view's query and the user's permissions, not from the client. |
| Runs scheduled pushes | In its own workers, see Schedule a Connect destination. |
| Stores the sync state | One SyncState per destination and view, see Two-way sync. |
Install
Each connector is a registry item containing the shared connector-model.ts, the shared sync-engine.ts and its provider module.
# React
npx shadcn@latest add https://table.yayaw.app/r/yayaw-table-connector-notion.json
npx shadcn@latest add https://table.yayaw.app/r/yayaw-table-connector-google-sheets.json
# Vue
npx shadcn-vue@latest add https://table.yayaw.app/r/yayaw-table-vue-connector-notion.json
npx shadcn-vue@latest add https://table.yayaw.app/r/yayaw-table-vue-connector-google-sheets.jsonFiles are installed under components/ui/yayaw-table/connectors/ (React) or components/ui/yayaw-table-vue/connectors/ (Vue). Import each module directly; there is no index file.
Set up Notion
In Notion, open Settings › Connections › Develop or manage integrations and create an internal integration for your workspace, with the capabilities to read, update and insert content.
Copy its internal integration secret and store it on your server as the connection's token.
Share each target database with the integration: open the database, then … › Connections and add the integration. Notion reports a database that is not shared as
not_found.Add a Yayaw ID property (title, text or number) to the database, or pick another key property in the mapping. Prepare can add it for you.
The Notion module (notion.ts) provides:
verifyNotionToken(token): returns{ botId, name, workspaceName }.listNotionDatabases(token): the databases shared with the integration.getNotionDatabaseSchema(token, databaseIdOrUrl): the properties, with select and status options.defaultNotionMapping(columns, schema): maps columns to properties with the same name and a compatible type, then fills the title property.pushRowsToNotionDatabase({ token, databaseId, mapping, rows, maxRows }): creates or updates one page per row, keyed by theYayaw IDproperty. Requests are spaced to about three per second, and a push is limited to 5,000 rows. Properties are found by their id first, so a renamed property keeps receiving its column.notionTargetSchema(schema),prepareNotionDatabase({ token, databaseId, fixes }),listNotionPages(token)andcreateNotionDatabase({ token, parentPageId, title, columns }): check, prepare or create a database. See Keeping the target healthy.
Set up Google Sheets
In the Google Cloud console, create or pick a project and enable the Google Sheets API. If it is disabled, pushes fail with
api_disabled.Create a service account, then add a JSON key to it and store the downloaded file on your server as the connection's secret.
Share each spreadsheet with the service account's email (
…@….iam.gserviceaccount.com) as an Editor.
The Google Sheets module (google-sheets.ts) provides:
parseServiceAccountKey(json): validates the key file and returns the credentials.verifyGoogleSheetsCredentials(credentials): checks the key and returns the email to share spreadsheets with.parseSpreadsheetId(urlOrId),getSpreadsheet(credentials, urlOrId)andreadHeaderRow(credentials, urlOrId, sheetTitle): read the spreadsheet, its tabs and the first row.pushRowsToSheet({ credentials, spreadsheetId, sheetTitle, columns, rows, keyColumn, mode, maxRows }): withmode: "upsert"(the default), updates rows whoseYayaw IDcell matches and appends the others; withmode: "replace", clears everything below the header and rewrites it. Missing headers are added at the end, the user's own columns are never reordered, and values are sent raw, so text starting with=is never evaluated. A push is limited to 10,000 rows by default.getSheetTargetSchema({ credentials, spreadsheetId, sheetTitle }),sheetTargetSchema(grid)andprepareSheet({ credentials, spreadsheetId, sheetTitle, fixes }): check or prepare a tab. See Keeping the target healthy.
If a spreadsheet is not shared with the service account, the push fails with not_shared and details.serviceAccountEmail, so your interface can say which email to share it with.
Server action example
"use server";
import {
isConnectorError,
toConnectorRows,
} from "@/components/ui/yayaw-table/connectors/connector-model";
import {
parseServiceAccountKey,
pushRowsToSheet,
} from "@/components/ui/yayaw-table/connectors/google-sheets";
import { loadConnection, loadViewRows, requireUser } from "@/server/app";
export async function pushViewToSheet(input: {
connectionId: string;
viewId: string;
}) {
const user = await requireUser();
// Your authorization: the user may use this connection and read this view.
const connection = await loadConnection(user, input.connectionId);
const { columns, records } = await loadViewRows(user, input.viewId);
try {
const result = await pushRowsToSheet({
credentials: parseServiceAccountKey(connection.secret),
spreadsheetId: connection.spreadsheetId,
sheetTitle: connection.sheetTitle,
columns: columns.map((column) => ({ id: column.id, header: column.label })),
rows: toConnectorRows(records),
});
return { ok: true, created: result.created, updated: result.updated };
} catch (error) {
if (isConnectorError(error)) {
// For example "not_shared", with the email to share the sheet with.
return {
ok: false,
code: error.code,
shareWith: error.details.serviceAccountEmail,
};
}
throw error;
}
}requireUser, loadConnection and loadViewRows stand for your own code. For the full flow, with targets, the mapping chosen on screen and the settings saved per view, see the Google Sheets example of the connector screens. In Vue, call the same functions from a Nuxt server route or any server handler.
Results and errors
Every push returns { created, updated, skipped, failed, failures, warnings, warningCount, truncated }. Rows without an id or with a repeated id are skipped with a warning; rows beyond maxRows set truncated. A row that fails is recorded in failures, while a revoked token or missing access stops the push with a ConnectorError.
ConnectorError.code is one of unauthorized, forbidden, not_shared, api_disabled, not_found, rate_limited, provider_unavailable, invalid_request, invalid_target, invalid_mapping, invalid_credentials or aborted. details only holds safe values (status, serviceAccountEmail): provider error bodies can echo row data, so they are never included.
Every function also accepts options: fetch, signal, timeoutMs, maxRetries and rateLimiter. A 429 is retried after its Retry-After delay (capped at 30 seconds). Server errors and network failures are retried with backoff only when the request is safe to repeat: creating a Notion page or appending sheet rows is retried only on 429, so an ambiguous failure never writes twice.
For scheduled pushes, your worker re-checks that the schedule's owner may still use the connection and the view, loads the rows on the server, calls the same push function with the job's AbortSignal, and stores the result for the run history. Share one createRateLimiter(334) between concurrent Notion jobs using the same token.
Two-way sync
A push only writes the target. A sync also reads it, compares both sides with what they held after the previous run, and writes each change to the other side. The shared sync-engine.ts module does the planning; it is pure: it never does I/O, never stores anything and never logs. Your application reads both sides, stores the sync state and runs the job. In the table, the connector screen lets users choose the direction, the conflict rule and the delete policy, preview the changes and sync now; your server functions run the engine and return toSyncPreview and toSyncRunResult.
Directions, merge and rules
planSync({ direction, conflictRule, deletePolicy, mapping, tableRecords, targetRecords, state }) compares the records of both sides with the stored state and returns a SyncPlan:
direction:two-waymerges both sides;pushmakes the target mirror the table andpullmakes the table mirror the target. A one-way direction never writes the other side, except the key field of a target record.Three-way merge per column. Each linked row keeps
baseValues, its values at the last sync (the default,storeBaseValues: true). A column changed only in the table goes to the target, a column changed only in the target goes to the table, and a column changed on both sides to different values is a conflict. Different columns changed on each side merge without conflict. WithoutbaseValues, the engine compares record hashes instead, and a record changed on both sides makes every differing column a conflict.conflictRule:table-wins(the default),target-wins, orlatest-wins, which compares theupdatedAtof both records and falls back to the table without both times or on a tie. Google Sheets rows have no edit time, so on Sheetslatest-winsbehaves astable-wins. Every conflict is reported inconflictswith the table, target and base values and how it was settled. Rules per column or in code are described in Conflict rules in code.deletePolicy, for a linked record missing on one side:ignorekeeps the link and does nothing,flag(the default) keeps the link and reports the record inflagged,propagatedeletes it on the other side and drops the link. A deletion the direction cannot write is flagged. A kept link never recreates the deleted record; remove the link from the state to create it again.Adoption. Before creating anything, unlinked records are matched by the Yayaw ID key, so an existing target record is adopted instead of duplicated; differing columns of an adopted record are conflicts. Records sharing a key are reported in
duplicatesand left alone, never guessed. A target record without a key gets the table row id written to its key field.
Values are compared in a normalized form (normalizeSyncValue), so a round trip through Notion or Sheets never looks like a change: empty values, numbers read back as text, dates, yes/no words and multi-selects in any order compare equal to what was written. summarizeSyncPlan(plan) returns the counts for a preview (creates, updates and deletions on each side, conflicts, flagged, duplicates, skipped, unchanged and changes). Running a sync twice gives changes: 0 the second time.
What your application stores
Records of both sides are SyncRecord { id, key?, values, updatedAt? }, with values keyed by column id. toSyncMapping(settings, columns) builds the SyncMapping from the settings of the connector screens (key field and column mapping).
Store one SyncState { links, lastSyncAt? } per destination and view, as JSON, for example next to the view's Connect settings. Each SyncLink { rowId, remoteId, tableHash, targetHash, baseValues?, syncedAt } ties a table row to its target record. Start from an empty state (EMPTY_SYNC_STATE): the first run adopts existing target records by key.
Running a sync in a worker
Each run reads both sides, calls planSync, applies the plan with applySyncPlan and saves result.state:
import {
applySyncPlan,
planSync,
summarizeSyncPlan,
toSyncMapping,
type SyncRecord,
} from "@/components/ui/yayaw-table/connectors/sync-engine";
import { createNotionSyncTarget } from "@/components/ui/yayaw-table/connectors/notion";
import { loadConnection, loadViewRows, syncStates, tableRows } from "@/server/app";
export async function runNotionSync(job: {
connectionId: string;
viewId: string;
ownerId: string;
signal: AbortSignal;
}) {
// Your authorization: the owner may still use this connection and this view.
const connection = await loadConnection(job.ownerId, job.connectionId);
const { columns, records } = await loadViewRows(job.ownerId, job.viewId);
const mapping = toSyncMapping(connection.settings, columns);
const target = createNotionSyncTarget(
{ token: connection.secret, databaseId: connection.databaseId, mapping },
{ signal: job.signal }
);
const state = await syncStates.get(job.connectionId, job.viewId);
// 1. Read both sides.
const tableRecords: SyncRecord[] = records.map((record) => ({
id: String(record.id),
values: record,
updatedAt: record.updatedAt,
}));
const targetRecords = await target.read();
// 2. Plan. A preview can stop here and show summarizeSyncPlan(plan).
const plan = planSync({
direction: "two-way",
conflictRule: "latest-wins",
deletePolicy: "flag",
mapping,
tableRecords,
targetRecords,
state,
});
// 3. Apply through the target and your own table adapter.
const result = await applySyncPlan(
plan,
{ target, table: tableRows(job.ownerId, job.viewId) },
{ signal: job.signal }
);
// 4. Save the state, even after a partial failure.
await syncStates.set(job.connectionId, job.viewId, result.state);
return { summary: summarizeSyncPlan(plan), result };
}loadConnection, loadViewRows, syncStates and tableRows stand for your own code. The table adapter is yours: create inserts rows and returns their ids, update patches the given columns and delete removes rows, all with the owner's permissions. Each method receives a batch of SyncWrite { id?, key?, values } (only the columns to write) and returns one { ok: true, id? } or { ok: false, code? } per item, in order.
applySyncPlan writes in batches of 50 (batchSize) in a safe order: creates and updates in the target, creates in the table with the key written back, updates in the table, then deletions. A failing item is recorded in failures and the run goes on. An authorization error (unauthorized, forbidden, not_shared, invalid_credentials, api_disabled) or the signal stops the run and sets result.stopped. result.state only records what succeeded: a failed row keeps its previous link, so the next run plans it again, and an ambiguous create is adopted by key instead of duplicated. Lock the destination and view while a sync runs, so two runs never apply plans made from the same state.
Notion and Google Sheets
Notion:
readNotionDatabase({ token, databaseId, mapping, since })reads every page of the database, following pagination, with the page id, the Yayaw ID key,last_edited_timeasupdatedAtand the mapped properties converted back to plain values. Withsince, it only reads pages edited from the start of that minute (Notion records edit times to the minute); passtargetPartial: truetoplanSyncwith it, so a missing page counts as unchanged rather than deleted. Deletions are only seen by a full read.createNotionSyncTarget({ token, databaseId, mapping })is the target adapter plusread(since?): it creates pages keyed by Yayaw ID, patches only the given properties and the key, and archives deleted pages.Google Sheets:
readSheetRows({ credentials, spreadsheetId, sheetTitle, mapping })reads the tab once and maps headers to columns. The remote id is the Yayaw ID cell, orrow:<number>for a row without one.createSheetSyncTarget({ credentials, spreadsheetId, sheetTitle, mapping })appends new rows, updates rows found by key writing only the given cells and the key, and deletes rows by key only, from the bottom up, in one request per 500 rows: row numbers shift when rows are deleted, so a row without a key is never deleted by its number. Missing headers are added at the end of the header row.
Known limits
In Google Sheets, numbers and checkboxes are read as typed values and dates as shown. A date typed by hand in a local format (such as
23/09/2026) is read back as text, not as a date.A value the provider cannot store as written reads back differently on the next run, which sees a change and plans it again. Map each column to a field of a compatible type to avoid it.
Conflict rules in code
The global conflictRule is one choice for every column. Two-way sync can also decide conflicts in code, per column, with three optional planSync inputs. For each column changed on both sides, they apply in this order, and the first one that decides wins: ownership, then columnRules, then resolveConflict, then the global conflictRule. A plan without them behaves exactly as before.
Ownership and column rules
ownership: Record<columnId, "table" | "target">: one side is the source of truth for a column and always wins it. In two-way, a change made on the other side is drift: the owner's value is written back on the next sync and reported inplan.overridden({ columnId, owner, tableValue, targetValue, bothChanged }), not inconflicts. A one-way sync never writes an owned column to its owner: a push leaves target-owned columns alone. Creations still write every mapped column.columnRules: Record<columnId, rule>:table-wins,target-winsandlatest-winswork as the global rule does, for that column only.mergeis a three-way union for list values such as multi-selects and tags: the items both sides kept or either side added, minus the items of the base removed on either side, table items first and without repeats (mergeSyncListsis exported). On a column that is not a list,mergefalls back to the next step.manualleaves the conflict to a person (see Manual review).
import { planSync } from "@/components/ui/yayaw-table/connectors/sync-engine";
const plan = planSync({
direction: "two-way",
conflictRule: "table-wins", // for every column the rules leave open
mapping,
tableRecords,
targetRecords,
state,
// The CRM owns prices, the table owns the internal owner.
ownership: { price: "target", owner: "table" },
// Tags are merged, notes are decided by a person.
columnRules: { tags: "merge", notes: "manual" },
});A resolver in code
resolveConflict(context) receives { columnId, field, tableValue, targetValue, baseValue, tableRecord, targetRecord, rowId, remoteId }, with values in their normalized form (see normalizeSyncValue), and returns one of:
"table"or"target": that side's value wins;{ value }: a value of your own, written to both sides where it differs;"skip": both sides stay as they are this run, and the conflict comes back on the next run;"manual": a person decides;undefined: no opinion, the globalconflictRuledecides.
It must be pure and synchronous: it runs where planSync runs, on your server or in your worker, never in the browser. A resolver that throws becomes a manual conflict with error: "resolver_failed", and one that returns a promise or anything else becomes a manual conflict with error: "invalid_decision".
import {
planSync,
type ConflictResolver,
} from "@/components/ui/yayaw-table/connectors/sync-engine";
const STATUS_ORDER = ["Draft", "Active", "Won", "Archived"];
/** Keeps the highest amount and never lets a status go backwards. */
export const resolveConflict: ConflictResolver = (conflict) => {
if (conflict.columnId === "amount") {
const amounts = [conflict.tableValue, conflict.targetValue].map(Number);
return { value: Math.max(...amounts) };
}
if (conflict.columnId === "status") {
const rank = (value: unknown) => STATUS_ORDER.indexOf(String(value));
return rank(conflict.tableValue) >= rank(conflict.targetValue)
? "table"
: "target";
}
// No opinion: the global conflictRule decides.
return undefined;
};
const plan = planSync({
direction: "two-way",
conflictRule: "latest-wins",
mapping,
tableRecords,
targetRecords,
state,
resolveConflict,
});Every conflict in plan.conflicts now carries resolution (table, target, merged, custom, manual or skipped), source (column, resolver or rule) and, for merged and custom values, value. winner is kept for older code and only means something when resolution is a side. summarizeSyncPlan adds the overridden and pendingConflicts counts.
Check the configuration
validateConflictConfig({ ownership, columnRules, resolveConflict, direction }, mapping) returns { code, columnId?, severity, message }[]. Errors: unknown_column, invalid_owner, invalid_rule, invalid_resolver, and owner_not_written (an owner that a one-way direction never writes, such as ownership: { price: "target" } with a push). Warnings: merge_not_list, rule_on_owned_column (ownership always wins) and rules_unused (column rules or a resolver with a one-way direction). Run it once when the configuration is loaded; planSync ignores what it flags instead of throwing.
import { validateConflictConfig } from "@/components/ui/yayaw-table/connectors/sync-engine";
const issues = validateConflictConfig(
{ ownership, columnRules, resolveConflict, direction: "two-way" },
mapping
);
if (issues.some((issue) => issue.severity === "error")) {
throw new Error(issues.map((issue) => issue.message).join("\n"));
}Manual review
A manual conflict is not applied. Both sides keep their value, the row's other columns keep syncing, and the conflict waits in SyncState.pendingConflicts as { rowId, remoteId, columnId, field, tableValue, targetValue, baseValue, detectedAt }. There is at most one per row and column. It keeps its detectedAt while the values stay the same, is replaced when either value changes, and disappears when both sides agree again. It stays pending even if one side goes back to the base value, until a person decides or the sides agree. A partial read (targetPartial) or a blocked row keeps it as it is.
When a person keeps one value, resolvePendingConflicts(state, resolutions, { mapping }) turns the decisions ({ rowId, columnId, choice: "table" | "target" | { value } }[]) into writes (operations.updateInTable and operations.updateInTarget, one per row and side) and the next state: the settled conflicts are removed and the chosen value becomes the column's base, so the next sync sees both sides agree. Decisions that match no pending conflict (already settled, or stale) are returned in unmatched. applyConflictResolutions(plan, { table, target }) applies the writes with the same adapters as applySyncPlan; a row whose write fails keeps its conflicts and its link.
import {
applyConflictResolutions,
resolvePendingConflicts,
toSyncMapping,
} from "@/components/ui/yayaw-table/connectors/sync-engine";
import {
toPendingConflicts,
toSyncRunResult,
type PendingConflictResolution,
} from "@/components/ui/yayaw-table/utils/connector-flow";
import { loadConnection, syncStates, tableRows, titles } from "@/server/app";
// The conflicts left to a person, named after their records.
export async function listSyncConflicts(connectionId: string, viewId: string) {
const state = await syncStates.get(connectionId, viewId);
return toPendingConflicts(state.pendingConflicts, {
rowLabel: (id) => titles.get(id),
});
}
// Writes the person's choices to both sides and saves the new state.
export async function resolveSyncConflicts(
connectionId: string,
viewId: string,
resolutions: PendingConflictResolution[]
) {
const { settings, columns, target } = await loadConnection(connectionId, viewId);
const mapping = toSyncMapping(settings, columns);
const state = await syncStates.get(connectionId, viewId);
const plan = resolvePendingConflicts(state, resolutions, { mapping });
const result = await applyConflictResolutions(plan, {
table: tableRows(viewId),
target,
});
await syncStates.set(connectionId, viewId, result.state);
return toSyncRunResult(result);
}Use the same lock as for a sync, so a resolution never runs at the same time as a sync of the same destination and view.
In the connector screen
Functions never reach the browser. The connector declares the rules the screen shows in conflicts, and two optional host functions for manual review, listConflicts and resolveConflicts, which call the functions above. The screen then shows Rules set by your app, labels the preview and offers Conflicts to resolve. See Conflict rules set by your app.
Keeping the target healthy
A push or a sync breaks when the target drifts: a property renamed, deleted or given another type, select options missing, no Yayaw ID field. The connectors now check the target before sending, fix what they safely can and say plainly what a person must do. The connector screen runs the check without extra code; Prepare needs one server function, and a scheduled run should check before it writes.
Stable field identity
The fields a connector describes carry an id (the Notion property id) and an index (the sheet column position). The screen saves them with each mapping entry as { columnId, field, fieldId?, fieldIndex? } and with the key as keyField, keyFieldId? and keyFieldIndex?. At run time a field is found by id first, then by name (resolveMappedField):
Notion.
toConnectorMappingandtoSyncMappingpass the property ids, andpushRowsToNotionDatabase,readNotionDatabaseandcreateNotionSyncTargetuse them. A push keeps writing "Price" after it was renamed "Cost" in Notion, and the screen shows "Renamed in Notion: Price → Cost" with Update mapping, which saves the new name.Google Sheets. Sheets have no ids, so a header that is gone is looked up at its saved position, shifted by as much as the key column moved, when the header there is not mapped by anything else, and reported as renamed. Writes follow the same rule:
pushRowsToSheet(fieldIndexesandkeyColumnIndex, fromtoConnectorMapping),createSheetSyncTarget,readSheetRows(fieldIndexandkeyFieldIndexintoSyncMapping) andprepareSheetwrite the renamed column in place and never add the old header again. Moved headers are harmless, because cells are written by header.Ambiguous. When the saved position cannot be used (nothing is there, or another mapped column uses that header), nothing is written: the sheet functions throw
field_missing, which stops a sync, and the check reports it as blocking until the field is chosen again.
Mappings saved with names only keep working and gain ids on their next save. On the server, upgradeMapping does the same.
Check the target
checkTargetSchema({ columns, mapping, keyField, keyFieldId, keyFieldIndex, targetSchema, direction }) lives in utils/connector-schema.ts (components/ui/yayaw-table/utils/connector-schema.ts in React, components/ui/yayaw-table-vue/connector-schema.ts in Vue). It is installed with the table, pure and safe on the server. columns are the table columns with their type and options. targetSchema is { provider, fields }, from notionTargetSchema(await getNotionDatabaseSchema(token, databaseId)) or from await getSheetTargetSchema({ credentials, spreadsheetId, sheetTitle }) (headers, positions and a type sampled from the first 20 rows).
It returns { issues, fixes }, with the issues sorted blocking, fixable, then warning. Each issue has a severity, a code, the columnId and field it concerns, a detail holding only names, types and option names, and, when it can be fixed, the fix that Prepare applies. The severity depends on the direction:
| Code | Push and two-way | Pull |
|---|---|---|
missing_field (never there), deleted_field (id gone) | fixable: create it | warning |
renamed_field | warning, the run continues by id | warning |
incompatible_type | blocking (warning in a sheet) | blocking |
coercible_type (text that must parse) | warning | warning; blocking when sampled values fail |
unsupported_type (people, relations, files) | blocking | blocking |
read_only_field (formula, rollup, created time…) | blocking | fine to read |
missing_options | fixable (Notion select and multi-select), blocking (Notion status: its API cannot add options), warning (sheet) | — |
missing_key | fixable | fixable |
key_wrong_type | blocking (warning in a sheet) | blocking |
duplicate_mapping (one field for two columns, or for a column and the key) | blocking | blocking |
title_unmapped (Notion page title) | blocking | — |
field_missing (sheet header gone, saved position unusable) | blocking | blocking |
Types follow typeCompatibility(columnType, targetType, direction). On a push, a text field takes any column; a number goes to Number (or text), a date to Date, a checkbox to Checkbox, a select to Select, Status or Multi-select, a multi-select to Multi-select, and URL, email and phone to their own type. Text sent into a Number, Select, Date, URL, email or phone field must parse. A pull reads Select or Status into a select, Multi-select, Select or Status into a multi-select, and text or computed values into typed columns only when they parse. Two-way takes the worse of both. A target without provider, such as a custom connector, is only checked for types and duplicates, since its push adds missing fields itself.
schemaBlocksRun(report) is true when at least one issue is blocking. Fixable issues do not block, but a Notion push without Yayaw ID still fails with invalid_mapping, so prepare the target first.
Prepare the target
Preparing only adds. It never deletes, renames or changes the type of anything, and running it twice changes nothing the second time.
prepareNotionDatabase({ token, databaseId, fixes })reads the database, creates the missing properties with the right type (Yayaw ID as text) and adds the missing select and multi-select options with the table's colors (the option'scolorwhen it names one, otherwise the tag color the table shows), in onePATCH /databasesrequest. It sends every existing option back, skips option names Notion refuses (commas, more than 100 characters) and returns{ applied, skipped }. Status options cannot be added through the Notion API: the person adds them in Notion.prepareSheet({ credentials, spreadsheetId, sheetTitle, fixes, keyColumn?, keyColumnIndex? })adds the missing headers at the end of the header row, the key first, growing the grid when needed, and never reorders or deletes a column. It returns{ applied, addedHeaders }.
Pass the fixes of a report, or those the screen sends to prepareTarget.
Create a Notion database
listNotionPages(token) lists the pages shared with the integration, following pagination: the parents a new database can be created in. createNotionDatabase({ token, parentPageId, title, columns, titleColumnId?, keyProperty? }) creates a database under one of them with one property per column (the right Notion type, select options with their colors), a title property filled by the first text column (or by the record id when there is none) and Yayaw ID. It returns { id, title, url, mapping }, where mapping holds every column with its property id, ready to save. The request is never retried after an ambiguous failure, so a database is never created twice.
Host functions for the screen
The screen checks the fields describe returned. To check a fresh schema on the server instead, and to let people prepare the target, the connector declares checkSchema and prepareTarget, which call server actions such as these (see Target check and Prepare):
"use server";
import {
getNotionDatabaseSchema,
notionTargetSchema,
prepareNotionDatabase,
} from "@/components/ui/yayaw-table/connectors/notion";
import type { ConnectorSettings } from "@/components/ui/yayaw-table/utils/connector-flow";
import {
checkTargetSchema,
type SchemaFix,
} from "@/components/ui/yayaw-table/utils/connector-schema";
import { loadConnection, loadViewColumns, requireUser } from "@/server/app";
export async function checkNotionTarget(settings: ConnectorSettings, viewId: string) {
const user = await requireUser();
// Your authorization: the user may use this connection and read this view.
const { token } = await loadConnection(user, settings.targetId);
const columns = await loadViewColumns(user, viewId); // { id, header, type, options }[]
const schema = await getNotionDatabaseSchema(token, settings.targetId);
return checkTargetSchema({
columns,
mapping: settings.mapping,
keyField: settings.keyField,
keyFieldId: settings.keyFieldId,
targetSchema: notionTargetSchema(schema),
direction: settings.direction ?? "push",
});
}
export async function prepareNotionTarget(fixes: SchemaFix[], settings: ConnectorSettings) {
const user = await requireUser();
const { token } = await loadConnection(user, settings.targetId);
// Only adds properties and options; returns { applied, skipped }.
return await prepareNotionDatabase({ token, databaseId: settings.targetId, fixes });
}requireUser, loadConnection and loadViewColumns stand for your own code. For Google Sheets, read the schema with getSheetTargetSchema and prepare with prepareSheet, passing keyColumn: settings.keyField and keyColumnIndex: settings.keyFieldIndex. The settings carry the saved names of renamed fields, so the report names both.
Scheduled runs
A worker checks the target before it runs and pauses the schedule instead of failing row by row:
import {
getNotionDatabaseSchema,
notionTargetSchema,
} from "@/components/ui/yayaw-table/connectors/notion";
import {
checkTargetSchema,
schemaBlocksRun,
} from "@/components/ui/yayaw-table/utils/connector-schema";
const schema = notionTargetSchema(await getNotionDatabaseSchema(token, databaseId));
const report = checkTargetSchema({
columns,
mapping: settings.mapping,
keyField: settings.keyField,
keyFieldId: settings.keyFieldId,
targetSchema: schema,
direction: settings.direction ?? "push",
});
if (schemaBlocksRun(report)) {
// Your scheduler: keep the schedule, stop its runs, tell its owner why.
await schedules.pause(scheduleId, {
reason: "target_schema",
issues: report.issues.filter((issue) => issue.severity === "blocking"),
});
return;
}Show the paused schedule's issues to its owner with a clear message: schemaIssueMessage(issue, { t, target, columns }) in connector-flow.ts words them as the screen does, with t from connectorLabels(locale), for example "Price: Notion property is Text, Number expected." Resume the schedule once a check passes. When the owner allowed it, run prepareNotionDatabase or prepareSheet with report.fixes before the push, so a missing Yayaw ID or option is added instead of failing the run.
Not covered yet: one Notion checkbox per multi-select option, a page content template, and sending existing records when a push destination is first connected (a push already sends every record of the view).