Scope Modeling
Scope design is the highest leverage selective-sync decision in Synchro.
The main rule is:
- optimize for low row-change fanout, not low total scope count
What matters is not how many scopes exist globally. What matters is how many scope instances a single changed row forces the server to update.
What a scope boundary means
Section titled “What a scope boundary means”A scope is not only a visibility boundary. It is also the unit for:
- continuation state
- checksum and rebuild behavior
- change propagation
- operational debugging
That means scope design should follow runtime behavior, not only who can read a row.
Recommended baseline model
Section titled “Recommended baseline model”Use:
- the default identity scope
user:{user_id}for private data - shared scopes for shared data, such as
templates_public - one local table may be fed by multiple scopes
Example:
- every user should see the public template catalog
- every user should also see their own private templates
Model that as:
templates_publicuser:{user_id}
Do not copy the public template catalog into every private scope. One public row change should update one shared scope, not one scope per user.
Membership and assignment
Section titled “Membership and assignment”A registered membership function determines which scopes contain each row. That does not determine which clients can read those scopes.
The server computes the assigned scope set for each canonical user from four sources:
- the identity scope
user:{user_id}, unless an operator revoked it - every registered shared scope
- each explicit grant from
synchro.synchro_grant_user_scope(user_id, scope_id) - the results of the registered assignment function
Use synchro.synchro_revoke_user_scope(user_id, scope_id) to revoke an explicit assignment.
For example, a private project scope needs both a membership function that returns project:123 and an explicit grant:
SELECT synchro.synchro_grant_user_scope('alice', 'project:123');Run this administrative operation through a trusted server-side authorization decision. Do not accept a requested scope or user ID from an untrusted client as authorization. A granted scope is not a portable public scope. See server setup for a complete default-scope example.
Assignment function
Section titled “Assignment function”Use an assignment function when application data already records which users can read a scope. The function derives scopes from that data, so you do not maintain a separate grant for each user.
This example reads project membership from an application table:
CREATE TABLE public.project_members ( user_id text NOT NULL, project_id uuid NOT NULL, PRIMARY KEY (user_id, project_id));
CREATE FUNCTION public.project_scopes(p_user_id text)RETURNS SETOF textLANGUAGE sqlSTABLESET search_path = pg_catalog, synchroBEGIN ATOMIC SELECT 'project:' || project_id::text FROM public.project_members WHERE user_id = p_user_id;END;
REVOKE ALL ON FUNCTION public.project_scopes(text) FROM PUBLIC;GRANT EXECUTE ON FUNCTION public.project_scopes(text) TO synchro_owner;
SELECT synchro.synchro_register_assignment_function('public.project_scopes', 100);Run the example as a role with synchro_operator membership. That role must own the function.
The extension evaluates the function as its owner in a security-restricted operation.
Thus the owner must be able to read each relation that the function reads.
The membership function for project rows must return the same project:{project_id} scope IDs.
Registration rejects a function unless every rule below is true:
- It is a plain
LANGUAGE sqlfunction with a parsed SQL-standard body, such asBEGIN ATOMIC. - It has exactly one
textargument, with no default and no variadic form. The argument is the canonical user ID. - It returns
SETOF text. - It is
STABLEand notSECURITY DEFINER. - Its
search_pathsetting is exactlypg_catalog, synchro. - The registration actor owns it.
synchro_ownerhas EXECUTE on it. PUBLIC does not have EXECUTE on it.- It calls no function outside
pg_catalog. - It reads no relation in schema
synchro. - The function owner has SELECT on each relation that the function reads and USAGE on the schema of that relation.
The function runs with search_path set to pg_catalog, synchro.
Schema-qualify every application relation in the function body.
Only one assignment function can be registered.
A second registration replaces the first registration.
The registration function keeps its (text, integer) identity and returns void.
Only synchro_operator can execute registration and removal.
The second argument sets max_scopes. The default is 1000.
Each non-NULL bound must be an integer from 1 through 1000.
An operator can select SQL NULL for the complete DISTINCT results without an assignment-result count limit:
SELECT synchro.synchro_register_assignment_function('public.project_scopes', NULL);This choice removes only the result count limit.
Authorization, function restrictions, definition hash checks, result validation, and reconciliation remain required.
A NULL function name returns SQL NULL and changes no registration.
This rule also applies when the bound is NULL or invalid.
Use synchro.synchro_unregister_assignment_function() to remove the registration.
Connect and pull call the function once with the canonical user ID.
Each result must be non-null and nonempty after trim. A result must not start with user:.
A NULL bound does not permit a NULL scope result.
These result rules apply to bounded and unbounded evaluation.
If max_scopes is non-NULL, more than max_scopes distinct results is a failure.
If max_scopes is NULL, evaluation returns the complete DISTINCT result set.
The server also confirms that the function exists and that its definition is unchanged since registration.
A failure aborts the connect or pull and returns 500 sync_integrity_failure. It changes no assignment state.
After you change the function definition, register the function again.
synchro_health_detail reports a failed assignment check when the function is missing or its definition changed.
Assignment reconciliation
Section titled “Assignment reconciliation”Connect and pull compute the assigned scope set with the same rule.
A pull reconciles the set after all request validation passes and before it builds scope_updates.
A grant, a revoke, or an application data change that alters the function results therefore takes effect at the next successful connect or pull.
When the set changes:
scope_set_versionadvances by exactly one.- The same response reports the change in
scope_updates. - An added scope has a null cursor and requires rebuild.
- A removed scope causes local provenance cleanup.
An unchanged set writes no assignment state. A failed pull writes no assignment state.
Rebuild does not reconcile assignment. For a scope that is no longer assigned, a request of an existing rebuild session or continuation returns 409 rebuild_restart_required. A new rebuild request for that scope returns 400 invalid_request.
Shared scope grouping
Section titled “Shared scope grouping”Do not create seed-only scopes. The seed should export the same shared runtime scopes that clients keep syncing after login.
When deciding whether datasets belong in one shared scope or several, use operational behavior:
- use one shared
globalscope only when datasets are small, stable, and tend to change together - split into multiple shared scopes when datasets differ in size, change rate, rebuild needs, or likely subscription patterns
Practical examples:
- tiny lookup tables can live in one
reference_publicscope - a larger public catalog should usually get its own scope such as
templates_public
Anti-patterns
Section titled “Anti-patterns”Avoid these patterns:
- duplicating shared data into every private scope
- creating separate seed-only copies of runtime scopes
- splitting scopes by table name alone when the data has the same change and rebuild behavior
- stuffing unrelated large datasets into one
globalscope only because they are all public
Portable seed relationship
Section titled “Portable seed relationship”Bundled portable seeds should include:
- schema
- any explicitly declared portable scopes
- the continuation metadata for those scopes
Clients then log in normally and continue sync from that seeded state. They do not wipe the database first.