Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,18 +142,18 @@ Update `docs.json` when adding, moving, or removing pages. Add redirects for mov

## Sync Streams and Sync Rules

Sync Streams are the default for new documentation. Keep legacy Sync Rules documentation accurate, but do not add new Sync Rules teaching, examples, or proactive references.
Sync Rules are deprecated, but many customers still run them. New documentation covers Sync Streams. Keep the Sync Rules docs accurate, and add to them when that makes them more accurate or helpful, without prioritizing that work. The goal is to avoid noisy Sync Rules references outside their own section, not to stop maintaining it.

When existing content shows both in tabs, preserve equivalent results and filters. Do not add new parallel Sync Rules examples.

For existing prose that mentions both, use "[Sync Streams](/sync/streams/overview) (or legacy [Sync Rules](/sync/rules/overview))" once per page or major section. Later mentions should omit Sync Rules.
- **Sync Rules section.** `sync/rules/` and `sync/grammar/sync-rules/` hold all Sync Rules content. Each page opens with an `<Info>` callout that starts "Sync Rules are deprecated." and links to its Sync Streams version. The sidebar group stays "Sync Rules (Legacy)".
- **Keep the engines apart.** Never place Sync Streams and Sync Rules content side by side: no engine tabs, no "(or legacy Sync Rules)" asides, no pointers to a Sync Rules equivalent. Outside `sync/rules/`, mention Sync Rules only to state a behavior difference that affects Sync Rules users, and remove other mentions when you edit a page.
- **Shared pages.** A page that applies to both engines keeps one body in `snippets/sync-shared/<slug>.mdx`, imported by a Sync Streams wrapper at the original path and a Sync Rules wrapper at `sync/rules/<slug>.mdx` that adds the callout and `noindex: true`. Edit the snippet, not the wrappers, and keep it valid for both engines.
- **Counterpart comments.** Every split twin, wrapper, and shared snippet starts with an MDX comment naming its counterpart. Read it before editing, apply a fix to both sides where content is shared, and keep the comment when restructuring.

## Verification

- Verify technical claims and run code examples before publication. Select other checks appropriate to the change.
- Run `vale <file>` for changed MDX pages. Add new technical terms to `.github/vale/config/vocabularies/PowerSync/accept.txt`; do not add ordinary misspellings.
- After link or navigation changes, run `npx mintlify broken-links`. Mintlify requires Node 20.17–24; if needed, use `PATH="/opt/homebrew/opt/node@24/bin:$PATH" npx mintlify broken-links`.
- For anchor and snippet checks, use `pnpm check:links`. Validate repository instruction links as file paths, since the site checker does not cover all of them.
- After link or navigation changes, run `pnpm check:links`. It runs the Mintlify path, anchor, and snippet checks through `scripts/check-links.mjs`, resolves anchors defined in imported snippets, and is the same check CI runs. It tells you if your Node version is unsupported. Validate repository instruction links as file paths, since the site checker does not cover them.
- Use [the lint command](commands/lint-docs.md) for the check workflow and [the reviewer](agents/document-reviewer.md) for editorial review. Passing linters does not establish technical accuracy or style compliance.

## Git Workflow
Expand Down
1 change: 1 addition & 0 deletions .claude/agents/document-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Apply the canonical standards in three passes and report findings from each:
1. **Accuracy:** claims, platform scope, versions, and consistency with the surrounding page. Before reporting a claim as unverified, check the sources the PR or the user cites, such as the source PR, divergence issue, release notes, or code at the merged commit, and follow the links inside them. If nothing is cited, look up the release the text names. Report a claim as unverified only when no source covers it or a source contradicts it. Give evidence, do not invent problems, and do not approve unverified claims as correct.
2. **Necessity:** list every sentence that describes what the product prints, displays, logs, or says in an error. Treat each one as a finding to remove unless it passes the restating rule in [Content Strategy](../CLAUDE.md#content-strategy), and report it even when the sentence is accurate. Describing visible output is not a mechanism, consequence, or signal. Also flag internal mechanics, rare exceptions, and repetition. Flag missing context only when readers need it, and do not require every entry to explain a mechanism, consequence, signal, action, and trade-off.
3. **Clarity and format:** plain technical English, clear actors and actions, and suitable examples and components.
4. **Sync Rules containment:** flag any Sync Rules mention, example, or tab outside `sync/rules/` and `snippets/sync-shared/` that does not state a behavior difference. Check that split twins, wrappers, and shared snippets keep their counterpart comment.

## Default Output

Expand Down
8 changes: 4 additions & 4 deletions .claude/commands/lint-docs.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
---
allowed-tools: Read, Bash(npx mintlify *), Bash(PATH=* npx mintlify *), Bash(vale *), Bash(git diff *), Bash(git status *)
description: Run Vale and Mintlify broken-link checks for documentation changes and report failures.
allowed-tools: Read, Bash(pnpm check:links), Bash(PATH=* pnpm check:links), Bash(vale *), Bash(git diff *), Bash(git status *)
description: Run Vale and the link check for documentation changes and report failures.
---

1. Read the canonical [Verification](../CLAUDE.md#verification) section for commands, supported Node versions, and vocabulary rules.
1. Read the canonical [Verification](../CLAUDE.md#verification) section for commands and vocabulary rules.
2. Use the requested file scope. Otherwise, identify changed MDX pages with `git diff main --name-only --diff-filter=ACMR -- '*.mdx'` and `git status --short`, including untracked pages.
3. Run `vale <file>` for each page and `npx mintlify broken-links` for the site. Use the canonical Node fallback if needed.
3. Run `vale <file>` for each page and `pnpm check:links` once for the site.
4. Report findings by file, suggested fixes, and totals for errors, warnings, and suggestions. Report failed or unavailable checks separately from content findings.
2 changes: 1 addition & 1 deletion .claude/skills/doc-author/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ Use the canonical Working Process for scope changes and unresolved decisions.
1. Identify the reader, desired outcome, and affected feature or concept.
2. Research the implementation and existing coverage. Read the most relevant related pages and `docs.json`; avoid unnecessary duplication.
3. If a plan is needed, state the proposed pages, structure, and unresolved questions before drafting.
4. Write the update under the canonical standards. Keep the existing structure unless the task requires a change.
4. Write the update under the canonical standards. Write new content for Sync Streams and keep Sync Rules content inside its own section. Keep the existing structure unless the task requires a change.
5. Self-review for accuracy, reader understanding, minimum useful detail, and navigation fit. Run the canonical verification checks relevant to the change.
6. Present the result and any unresolved draft TODOs, or complete the delivery workflow already authorized by the user.
2 changes: 1 addition & 1 deletion .claude/skills/pr-to-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ Ask before expanding scope, documenting a deprecation that needs migration decis

## 4. Draft and Verify

Apply the canonical writing standards, navigation requirements, and verification checks. Preserve existing structure unless restructuring is part of the approved plan. Flag unresolved facts using the canonical draft-TODO convention and report what must be resolved before publication.
Apply the canonical writing standards, navigation requirements, and verification checks. Document new behavior for Sync Streams. Update Sync Rules pages when the change affects them too, and do not add Sync Rules mentions elsewhere. Preserve existing structure unless restructuring is part of the approved plan. Flag unresolved facts using the canonical draft-TODO convention and report what must be resolved before publication.
29 changes: 24 additions & 5 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,30 @@
"sync/rules/data-queries",
"sync/rules/many-to-many-join-tables",
"sync/rules/client-parameters",
"sync/grammar/sync-rules/index"
{
"group": "Reference",
"pages": [
"sync/rules/supported-sql",
"sync/grammar/sync-rules/index",
"sync/rules/types"
]
},
{
"group": "Advanced",
"pages": [
"sync/rules/reducing-bucket-count",
"sync/rules/prioritized-sync",
"sync/rules/client-id",
"sync/rules/case-sensitivity",
"sync/rules/compatibility",
"sync/rules/storage-version-4",
"sync/rules/sync-data-by-time",
"sync/rules/schemas-and-connections",
"sync/rules/multiple-client-versions",
"sync/rules/partitioned-tables",
"sync/rules/sharded-databases"
]
}
]
}
]
Expand Down Expand Up @@ -800,10 +823,6 @@
"source": "/usage/sync-rules/operators-and-functions",
"destination": "/sync/supported-sql"
},
{
"source": "/sync/rules/supported-sql",
"destination": "/sync/supported-sql"
},
{
"source": "/usage/sync-rules/advanced-topics",
"destination": "/sync/advanced/overview"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"packageManager": "pnpm@11.3.0+sha512.2c403d6594527287672b1f7056343a1f7c3634036a67ffabfcc2b3d7595d843768f8787148d1b57cf7956c90606bbd192857c363af19e96d2d0ec9ec5741d215",
"scripts": {
"dev": "mintlify dev",
"check:links": "mintlify broken-links --check-anchors --check-snippets"
"check:links": "node scripts/check-links.mjs"
},
"devDependencies": {
"mintlify": "^4.2.520"
Expand Down
187 changes: 187 additions & 0 deletions scripts/check-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#!/usr/bin/env node
/**
* Snippet-aware link check.
*
* Runs `mintlify broken-links --check-anchors --check-snippets`, then re-validates
* every reported `#anchor` against the headings of the target page *including*
* headings that come from snippets the page imports. The Mintlify checker only
* reads page files, so a page whose body lives in a snippet (for example the pages
* shared between the Sync Streams and Sync Rules sections, see snippets/sync-shared/)
* would otherwise fail for every inbound anchor link.
*
* Reported links without a fragment, and anchors that still cannot be found, are
* printed in the Mintlify format and make the script exit with status 1.
*/
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';

const nodeMajor = Number(process.versions.node.split('.')[0]);
if (nodeMajor < 20 || nodeMajor > 24) {
console.error(
`The Mintlify CLI supports Node 20.17 to 24, but this is Node ${process.versions.node}.\n` +
'Run `nvm use` (the repo pins Node 24 in .nvmrc) or prefix the command with ' +
'PATH="/opt/homebrew/opt/node@24/bin:$PATH".',
);
process.exit(1);
}

const root = process.cwd();
const localBin = path.join(root, 'node_modules', '.bin', 'mintlify');
const bin = existsSync(localBin) ? localBin : 'mintlify';

const run = spawnSync(bin, ['broken-links', '--check-anchors', '--check-snippets'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
});
if (run.error) {
console.error(`Could not run ${bin}: ${run.error.message}`);
process.exit(1);
}

const output = ((run.stdout ?? '') + (run.stderr ?? ''))
.replace(/\x1B\[[0-9;?]*[A-Za-z]/g, '')
.replace(/\r/g, '');

// Parse blocks of "<file>\n ⎿ <link>\n ⎿ <link>".
const flagged = [];
let currentFile = null;
for (const rawLine of output.split('\n')) {
const line = rawLine.trim();
if (!line) continue;
if (/checking for broken links/.test(line)) continue;
if (/^found \d+ broken link/.test(line)) continue;
if (/^success/i.test(line)) continue;
const link = line.match(/^⎿\s*(\S.*)$/);
if (link) {
if (currentFile) flagged.push({ file: currentFile, link: link[1].trim() });
continue;
}
currentFile = line;
}

if (run.status === 0 && flagged.length === 0) {
console.log('success no broken links found');
process.exit(0);
}
if (flagged.length === 0) {
// Non-zero exit without a parsable report: show what Mintlify printed.
console.log(output.trim());
process.exit(run.status ?? 1);
}

function pageFile(urlPath) {
const p = urlPath.replace(/^\//, '').replace(/\/$/, '');
for (const candidate of [`${p}.mdx`, `${p}.md`, `${p}/index.mdx`, `${p}/index.md`]) {
if (existsSync(path.join(root, candidate))) return candidate;
}
return null;
}

function snippetFile(spec) {
const rel = spec.startsWith('/') ? spec.slice(1) : path.posix.join('snippets', spec);
return existsSync(path.join(root, rel)) ? rel : null;
}

// Page source plus the source of every snippet it imports, recursively.
function collectSource(file, seen = new Set()) {
if (seen.has(file)) return '';
seen.add(file);
const src = readFileSync(path.join(root, file), 'utf8').replace(/^---\n[\s\S]*?\n---\n/, '');
let out = src;
for (const m of src.matchAll(/^import\s+\w+\s+from\s+['"]([^'"]+)['"]/gm)) {
const f = snippetFile(m[1]);
if (f) out += `\n${collectSource(f, seen)}`;
}
for (const m of src.matchAll(/<Snippet\s+file=["']([^"']+)["']/g)) {
const f = snippetFile(m[1]);
if (f) out += `\n${collectSource(f, seen)}`;
}
return out;
}

// Mintlify slugs: inline JSX, backticks, emphasis, and punctuation such as
// parentheses are dropped; underscores are kept; spaces become hyphens. Several
// candidates are produced so that the check stays lenient about edge cases the
// Mintlify checker has already accepted elsewhere.
function slugCandidates(text) {
const base = text
.replace(/<[^>]*>/g, ' ')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/[`*]/g, '')
.trim()
.toLowerCase();
const strict = base.replace(/[^a-z0-9\s_-]/g, '').trim().replace(/\s+/g, '-');
const keepPunctuation = base.replace(/[^a-z0-9\s_/:-]/g, '').trim().replace(/\s+/g, '-');
return new Set([strict, keepPunctuation, strict.replace(/-+/g, '-'), keepPunctuation.replace(/-+/g, '-')]);
}

const anchorCache = new Map();
function anchorsFor(file) {
if (anchorCache.has(file)) return anchorCache.get(file);
const src = collectSource(file).replace(/```[\s\S]*?```/g, '');
const anchors = new Set();
for (const m of src.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) {
let heading = m[1];
const explicit = heading.match(/\{#([^}]+)\}\s*$/);
if (explicit) {
anchors.add(explicit[1]);
heading = heading.replace(/\{#[^}]+\}\s*$/, '');
}
for (const slug of slugCandidates(heading)) anchors.add(slug);
}
for (const m of src.matchAll(/<Accordion\b[^>]*\btitle=["']([^"']+)["']/g)) {
for (const slug of slugCandidates(m[1])) anchors.add(slug);
}
for (const m of src.matchAll(/\bid=["']([^"']+)["']/g)) anchors.add(m[1]);
for (const m of src.matchAll(/<(?:ResponseField|ParamField)\b[^>]*\bname=["']([^"']+)["']/g)) {
anchors.add(`param-${m[1].replace(/_/g, '-')}`);
}
anchorCache.set(file, anchors);
return anchors;
}

const unresolved = [];
for (const item of flagged) {
const hash = item.link.indexOf('#');
if (hash < 0) {
unresolved.push(item); // a path problem, not an anchor problem
continue;
}
const target = item.link.slice(0, hash).split('?')[0];
let anchor = item.link.slice(hash + 1);
try {
anchor = decodeURIComponent(anchor);
} catch {
// keep the raw fragment
}
const file = target === '' ? item.file : pageFile(target);
if (!file) {
unresolved.push(item);
continue;
}
const anchors = anchorsFor(file);
const withoutSuffix = anchor.replace(/-\d+$/, '');
if (anchors.has(anchor) || (withoutSuffix !== anchor && anchors.has(withoutSuffix))) continue;
unresolved.push(item);
}

const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
if (unresolved.length === 0) {
console.log(`success no broken links found (${plural(flagged.length, 'anchor')} resolved through imported snippets)`);
process.exit(0);
}

const byFile = new Map();
for (const item of unresolved) {
if (!byFile.has(item.file)) byFile.set(item.file, []);
byFile.get(item.file).push(item.link);
}
console.log(`found ${plural(unresolved.length, 'broken link')} in ${plural(byFile.size, 'file')}\n`);
for (const [file, links] of byFile) {
console.log(file);
for (const link of links) console.log(` ⎿ ${link}`);
console.log();
}
process.exit(1);
40 changes: 40 additions & 0 deletions snippets/sync-shared/case-sensitivity.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{/* Shared body: rendered by sync/advanced/case-sensitivity.mdx (Sync Streams section) and sync/rules/case-sensitivity.mdx (Sync Rules (Legacy) section). Keep the content valid for both engines. */}

### Case in Sync Rules

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This snippet now renders on the Sync Streams page (sync/advanced/case-sensitivity.mdx) as well as the Sync Rules page, but the heading and prose still frame case-folding as a Sync Rules-only behavior: "Case in Sync Rules" here, "Sync Rule queries" on line 5, "case produced by Sync Rules" on line 15, and "this Sync Rule query" on line 28. A Sync Streams reader will see this unchanged and be pointed at a concept ("Sync Rules") that doesn't apply to them. Reword these to be engine-neutral (e.g. "Case in Sync Queries" / "sync queries") so the shared body actually reads as valid for both engines.


PowerSync converts all table/collection and column/field names to lower-case by default in Sync Rule queries (this is how Postgres also behaves). To preserve the case, surround the names with double quotes, for example:

```sql
SELECT "ID" as id, "Description", "ListID" FROM "TODOs" WHERE "TODOs"."ListID" = bucket.list_id
```

When using `SELECT *`, the original case is preserved for the returned columns/fields.

### Client-Side Case

On the client side, the case of table and column names in the [client-side schema](/intro/setup-guide#define-your-client-side-schema) must match the case produced by Sync Rules exactly. For the above example, use the following in Dart:

```dart
Table('TODOs', [
Column.text('Description'),
Column.text('ListID')
])
```

SQLite itself is case-insensitive. When querying and modifying the data on the client, any case may be used. For example, the above table may be queried using `SELECT description FROM todos WHERE listid = ?`.

Operations (`PUT`/`PATCH`/`DELETE`) are stored in the upload queue using the case as defined in the schema above for table and column names, not the case used in queries.

As another example, in this Sync Rule query:

```sql
SELECT ID, todo_description as Description FROM todo_items as TODOs
```

Each identifier in the example is unquoted and converted to lower case. That means the client-side schema would be:

```dart
Table('todos', [
Column.text('description')
])
```
Loading
Loading