# powersync

Source: https://permdock.com/docs/cli/powersync

permdock powersync generate compiles the policy's read grants into PowerSync Sync Streams, and verify checks that no stream syncs a row the policy denies.

```bash
permdock powersync generate [--out sync-config.yaml] [--check] [--from <policy module>]
permdock powersync verify [--db <url>] [--fixtures rls.fixtures.json] [--from <policy module>]
```

`generate` writes an edition 3 [Sync Streams](https://docs.powersync.com/sync/streams/overview) file with one auto-subscribed stream per synced resource. Each stream's `queries` are the ways a user may read a row: one query per allow grant of the read permission, and one per role source of the membership table. The tables and memberships come from `rls` (`rls.tables`, `rls.memberships`), the same `database`-mode mapping [`permdock rls generate`](/docs/cli/rls) reads.

```ts
// permdock.config.ts
export default {
  policy: "./src/policy.ts",
  rls: {
    authorize: "database",
    tables: { job: "jobs" },
    memberships: {
      scopes: {
        organization: {
          table: "organization_users",
          user: "user_id",
          role: [
            "tier",
            { through: "roles", on: { role_id: "id" }, column: "key" },
          ],
          columns: { organization: "organization_id" },
        },
      },
    },
  },
  powersync: { out: "sync-config.yaml" },
};
```

```yaml
# Generated by permdock powersync generate from the policy. Do not edit.
config:
  edition: 3

streams:
  job:
    auto_subscribe: true
    queries:
      - "SELECT * FROM jobs WHERE jobs.organization_id IN (SELECT organization_users.organization_id FROM organization_users WHERE organization_users.user_id = auth.user_id() AND organization_users.tier IN ('admin'))"
      - "SELECT * FROM jobs WHERE jobs.organization_id IN (SELECT organization_users.organization_id FROM organization_users INNER JOIN roles ON roles.id = organization_users.role_id WHERE organization_users.user_id = auth.user_id() AND roles.key IN ('admin'))"
```

| Flag | Values | Default | Effect |
| --- | --- | --- | --- |
| `--out` | a path | `powersync.out`, else `sync-config.yaml` | Where `generate` writes and what `verify` compares |
| `--check` | flag | off | `generate`: exit `1` when the file on disk is stale; write nothing |
| `--db` | a Postgres URL | none | `verify`: run every stream query against this database per fixture |
| `--fixtures` | a path | `rls.fixtures`, else `rls.fixtures.json` | `verify`: the [`rls verify`](/docs/cli/rls) fixtures |
| `--from` | a module | `policy` in the config | The module exporting `policy` |

`powersync` also takes `resources` (the resources that get a stream; default every resource with a grant of the action), `action` (default `read`) and `manifest` (a path; none by default).

## Local snapshot rows [#local-snapshot-rows]

`generate` also writes the streams a device needs to build the user's snapshot with [`localSnapshot`](/docs/adapters/react-native#local-snapshot), each named `permdock_<table>` and starting from the user's own rows:

* `permdock_<memberships table>`: the user's membership rows (`<user> = auth.user_id()`) for each table in `rls.memberships`.
* `permdock_<global roles table>`: the user's global-role rows from `rls.roles`, else `permdock.user_roles` in `database` mode.
* `permdock_<roles table>`: the rows of each `through` roles table that one of the user's membership or global-role rows references.
* With `rls.customRoles` in `database` mode, the custom role tables, limited to rows with no tenant and rows of the user's tenants.

With `powersync.manifest` set, `generate` writes `localSnapshotManifest(policy)` as JSON to that path, so the app bundles the manifest without importing the policy. `powersyncSource` in `permdock/react-native` reads it ([PowerSync source](/docs/adapters/react-native#powersync-source)).

## What compiles [#what-compiles]

A query uses only `IN (SELECT …)`, `INNER JOIN`, `auth.user_id()` and `auth.parameter(…)`, the subset the Sync Streams compiler runs without `EXISTS`.

| Policy | Stream query |
| --- | --- |
| `role(…, { on: '<scope>' })` | `<row key> IN (SELECT <scope id> FROM <memberships> WHERE <user> = auth.user_id() AND <role> IN (…))` |
| A role column `through` a roles table | The same subquery with `INNER JOIN <roles> ON …`, comparing the roles table's key |
| Several role sources | One query per source |
| A role with `for` kinds | `AND <via> IN (…)` in the subquery |
| A resource role (`on: permissions.<resource>`) | The same subquery over `rls.memberships.resource.<resource>` |
| `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `isNull`, `in`, `notIn` literals | The comparison on the row column |
| `principal.id`, `principal.claims.<name>` | `auth.user_id()`, `auth.parameter('<name>')`; `in` a claim is `IN (SELECT value FROM json_each(…))` |
| `or` | One query per branch |
| `fields` | The id and the listed columns instead of `*` |

Everything else leaves the grant out of the stream, and `generate` prints why: a global role (no row filter), a custom role, `not`, `contains`, `related`, `opaque` and `sqlFunction` conditions, request context, approval, break-glass, `validFrom` / `validUntil`, and a memberships table with `expiresAt` (the sync service cannot compare with the current time). A resource whose read permission has any `deny` grant gets no stream, because a stream can only add rows. A resource with more than 32 queries gets no stream. A resource with no stream is listed as a comment at the end of the file.

## Verify [#verify]

`verify` compiles the policy again and fails when `sync-config.yaml` differs. With `--db`, it runs each fixture of a synced permission: the stream queries with the subject's id against the database, and `can()` in process with the row's organization active, since a stream holds rows of every organization the user belongs to. A row a stream syncs and the policy denies is a mismatch and exits `1`. A row the policy grants and no stream syncs is a note: the app reads it from the server. The database must hold the fixture rows and memberships, as for `rls verify --db`.

`verify` passes a fixture's `subject.claims` to `auth.parameter()` and fails when a `permdock_*` stream syncs another user's rows. It compares the `powersync.manifest` file as JSON.

[`permdock doctor`](/docs/cli/doctor) reports PD058 when `sync-config.yaml` or the manifest is stale or missing, and when the config does not compile to Sync Streams.

## Why [#why]

* **Under-sync, never over-sync.** A device keeps synced rows offline, so a row the policy denies must never reach it. Every grant that does not compile is left out, deny grants drop the stream, and the server still decides every request ([threat model](/docs/security/threat-model)).
* **The `database`-mode tables, not the token.** The sync service evaluates the queries when a row or membership changes, so a membership change re-syncs without a new token. A `jwt`-mode `memberships` claim is not compiled; it is planned.
* **One file, checked in.** `generate --check`, `verify` and PD058 all compare the same bytes, so CI fails before a stale file is deployed.
