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
15 changes: 12 additions & 3 deletions src/bin/gqm/gqm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { generateGraphqlApiTypes, generateGraphqlClientTypes } from './codegen';
import { parseFunctionsFile } from './parse-functions';
import { parseKnexfile } from './parse-knexfile';
import { parseModels } from './parse-models';
import { parsePermissionsConfig } from './parse-permissions-config';
import { parseScopes } from './parse-scopes';
import { generatePermissionTypes } from './permissions';
import { readLine } from './readline';
import { getSetting, writeToFile } from './settings';
Expand Down Expand Up @@ -53,11 +55,14 @@ const readCurrentBranch = (): string | undefined => {
if (statSync(gitDir).isFile()) {
const pointer = readFileSync(gitDir, 'utf8').trim();
const match = /^gitdir:\s*(.+)$/.exec(pointer);
if (!match) return undefined;
if (!match) {
return undefined;
}
gitDir = match[1];
}
const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim();
const match = /^ref:\s*refs\/heads\/(.+)$/.exec(head);

return match ? match[1] : undefined;
} catch {
return undefined;
Expand Down Expand Up @@ -106,7 +111,9 @@ program
const models = await parseModels();
const functionsPath = await getSetting('functionsPath');
const parsedFunctions = parseFunctionsFile(functionsPath);
const migrations = await new MigrationGenerator(db, models, parsedFunctions).generate();
const scopes = await parseScopes();
const permissionsConfig = await parsePermissionsConfig();
const migrations = await new MigrationGenerator(db, models, parsedFunctions, scopes, permissionsConfig).generate();

writeToFile(`migrations/${date || getMigrationDate()}_${name}.ts`, migrations);
} finally {
Expand All @@ -125,7 +132,9 @@ program
const models = await parseModels();
const functionsPath = await getSetting('functionsPath');
const parsedFunctions = parseFunctionsFile(functionsPath);
const mg = new MigrationGenerator(db, models, parsedFunctions);
const scopes = await parseScopes();
const permissionsConfig = await parsePermissionsConfig();
const mg = new MigrationGenerator(db, models, parsedFunctions, scopes, permissionsConfig);
await mg.generate();

if (mg.needsMigration) {
Expand Down
36 changes: 36 additions & 0 deletions src/bin/gqm/parse-permissions-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { existsSync } from 'fs';
import { IndentationText, Project } from 'ts-morph';
import { PermissionsConfig } from '../../permissions/generate';
import { getSetting } from './settings';
import { staticEval } from './static-eval';
import { findDeclarationInFile } from './utils';

/**
* Parse the file referenced by `permissionsConfigPath` (default
* `'src/config/permissions/index.ts'`) and return its top-level
* `permissionsConfig` declaration. Returns `undefined` if the file or
* the declaration is missing — e.g., when scope-derivation isn't needed.
*/
export const parsePermissionsConfig = async (): Promise<PermissionsConfig | undefined> => {
const permissionsConfigPath = await getSetting('permissionsConfigPath');

if (!existsSync(permissionsConfigPath)) {
return undefined;
}

const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
},
});
const sourceFile = project.addSourceFileAtPath(permissionsConfigPath);

let declaration;
try {
declaration = findDeclarationInFile(sourceFile, 'permissionsConfig');
} catch {
return undefined;
}

return staticEval(declaration, {}) as PermissionsConfig;
};
36 changes: 36 additions & 0 deletions src/bin/gqm/parse-scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { existsSync } from 'fs';
import { IndentationText, Project } from 'ts-morph';
import { ScopesConfig } from '../../permissions/scopes';
import { getSetting } from './settings';
import { staticEval } from './static-eval';
import { findDeclarationInFile } from './utils';

/**
* Parse the optional scopes config file declared by `scopesPath` in
* `.gqmrc.json`. Returns `{}` if the file does not exist or does not
* export a `scopes` declaration.
*/
export const parseScopes = async (): Promise<ScopesConfig> => {
const scopesPath = await getSetting('scopesPath');

if (!existsSync(scopesPath)) {
return {};
}

const project = new Project({
manipulationSettings: {
indentationText: IndentationText.TwoSpaces,
},
});
const sourceFile = project.addSourceFileAtPath(scopesPath);

let declaration;
try {
declaration = findDeclarationInFile(sourceFile, 'scopes');
} catch {
// No `scopes` export — treat as empty (file may exist but be unused).
return {};
}

return staticEval(declaration, {}) as ScopesConfig;
};
15 changes: 15 additions & 0 deletions src/bin/gqm/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ const DEFAULTS = {
ensureFileExists(path, `export const functions: string[] = [];\n`);
},
},
scopesPath: {
// Optional file declaring permission scope-anchors (see ScopesConfig).
// The migration generator reads this to emit `CREATE MATERIALIZED VIEW`
// for each anchor. If the file does not exist, no scope views are
// managed.
defaultValue: 'src/config/permissions/scopes.ts',
},
permissionsConfigPath: {
// Optional file declaring `permissionsConfig: PermissionsConfig`. The
// migration generator reads this to derive scope-view SQL from the
// permission tree (one UNION clause per chain reaching the anchor).
// Required only if `scopesPath` is set and any anchor relies on auto-
// derivation (i.e. didn't provide an explicit `sql` body).
defaultValue: 'src/config/permissions/index.ts',
},
generatedFolderPath: {
question: 'What is the path for generated stuff?',
defaultValue: 'src/generated',
Expand Down
11 changes: 11 additions & 0 deletions src/bin/gqm/static-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ const VISITOR: Visitor<unknown, Dictionary<unknown>> = {
undefined: () => undefined,
[SyntaxKind.BindingElement]: (node: BindingElement, context) => context[node.getName()],
[SyntaxKind.VariableDeclaration]: (node, context) => staticEval(node.getInitializer(), context),
[SyntaxKind.EnumDeclaration]: (node, context) => {
const result: Dictionary<unknown> = {};
for (const member of node.getMembers()) {
const initializer = member.getInitializer();
result[member.getName()] = initializer
? staticEval(initializer, context)
: member.getValue();
}

return result;
},
[SyntaxKind.ArrayLiteralExpression]: (node, context) => {
const values: unknown[] = [];
for (const value of node.getElements()) {
Expand Down
2 changes: 2 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Knex } from 'knex';
import { Models } from './models/models';
import { MutationHook, QueryHook } from './models/mutation-hook';
import { Permissions } from './permissions/generate';
import { ScopesConfig } from './permissions/scopes';
import { AliasGenerator } from './resolvers/utils';
import { AnyDateType } from './utils';

Expand All @@ -21,6 +22,7 @@ export type Context<DateType extends AnyDateType = AnyDateType> = {
user?: User;
models: Models;
permissions: Permissions;
scopes?: ScopesConfig;
mutationHook?: MutationHook<DateType>;
queryHook?: QueryHook<DateType>;
};
Expand Down
139 changes: 139 additions & 0 deletions src/migrations/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ import {
validateCheckConstraint,
validateExcludeConstraint,
} from '../models/utils';
import { generatePermissions, PermissionsConfig } from '../permissions/generate';
import {
ScopesConfig,
deriveScopeSourceTables,
deriveScopeViewSql,
getScopeAnchorIdColumn,
getScopeViewName,
} from '../permissions/scopes';
import { Value } from '../values';
import { ParsedFunction } from './types';
import {
Expand Down Expand Up @@ -54,6 +62,8 @@ export class MigrationGenerator {
knex: Knex,
private models: Models,
private parsedFunctions?: ParsedFunction[],
private scopes?: ScopesConfig,
private permissionsConfig?: PermissionsConfig,
) {
this.knex = knex;
this.schema = SchemaInspector(knex);
Expand Down Expand Up @@ -540,6 +550,8 @@ export class MigrationGenerator {
up,
);

await this.handleScopes(up, down);

writer.writeLine(`import { Knex } from 'knex';`);
if (this.uuidUsed) {
writer.writeLine(`import { randomUUID } from 'crypto';`);
Expand All @@ -562,6 +574,133 @@ export class MigrationGenerator {
return writer.toString();
}

/**
* Emit `CREATE MATERIALIZED VIEW "<Anchor>Scope"` migrations for declared
* scope-anchors. The SQL body comes from `scope.sql` if explicitly
* provided, or is auto-derived from the permissions tree (one UNION
* clause per role-chain that reaches the anchor model from `me`).
*
* Materialized (not plain) because plain views inline the UNION per
* outer row when used in a correlated EXISTS, which is dramatically
* slower than the original (un-shortcut) permission predicate. The
* materialized form gives Postgres a real table with statistics and
* indexes, so the EXISTS collapses to a hash semi-join lookup.
*
* Refresh: AFTER triggers on every source table fire at end of
* transaction (DEFERRABLE INITIALLY DEFERRED) and call REFRESH
* MATERIALIZED VIEW CONCURRENTLY. Within a transaction, an advisory
* xact_lock dedupes so REFRESH runs once even if many rows changed.
* Refresh runs inside the writer's commit, so commit latency grows by
* the cost of refresh — fine for small/medium scopes (ms-scale on tens
* of thousands of rows). Larger scopes can opt out with
* `refreshTriggers: false` and refresh externally.
*
* Source tables come from `scope.sourceTables` if explicitly provided,
* or are auto-derived from the permissions tree (every model traversed
* by any chain reaching the anchor).
*
* Emits only when the materialized view doesn't already exist. To
* regenerate, drop the materialized view first.
*/
private async handleScopes(up: Callbacks, down: Callbacks) {
if (!this.scopes || Object.keys(this.scopes).length === 0) {
return;
}

const derivedPermissions = this.permissionsConfig ? generatePermissions(this.models, this.permissionsConfig) : undefined;

const matResult = await this.knex.raw(`SELECT matviewname FROM pg_matviews WHERE schemaname = 'public'`);
const existingMatViews = new Set<string>(
'rows' in matResult && Array.isArray((matResult as { rows: unknown }).rows)
? (matResult as { rows: { matviewname: string }[] }).rows.map((r) => r.matviewname)
: [],
);

for (const [anchor, config] of Object.entries(this.scopes)) {
const viewName = getScopeViewName(anchor);
const anchorIdCol = getScopeAnchorIdColumn(anchor);

const sqlBody = config.sql
? config.sql.trim()
: derivedPermissions
? deriveScopeViewSql(this.models, derivedPermissions, anchor)
: null;

if (!sqlBody) {
// No SQL provided and no permissions config to derive from.
continue;
}

if (existingMatViews.has(viewName)) {
continue;
}

const refreshTriggers = config.refreshTriggers !== false;
const sourceTables =
config.sourceTables ?? (derivedPermissions ? deriveScopeSourceTables(derivedPermissions, anchor) : []);

this.needsMigration = true;
up.push(() => {
this.writer.writeLine(`await knex.raw(\`CREATE MATERIALIZED VIEW "${viewName}" AS`);
this.writer.writeLine(sqlBody);
this.writer.writeLine('`);');
this.writer.blankLine();
// Unique index serves both REFRESH MATERIALIZED VIEW CONCURRENTLY
// (which requires one) and per-userId lookups via the EXISTS short-
// circuit.
this.writer.writeLine(
`await knex.raw(\`CREATE UNIQUE INDEX "${viewName}_userId_${anchorIdCol}" ON "${viewName}" ("userId", "${anchorIdCol}")\`);`,
);
// Secondary index on the anchor-id column supports reverse lookups.
this.writer.writeLine(
`await knex.raw(\`CREATE INDEX "${viewName}_${anchorIdCol}" ON "${viewName}" ("${anchorIdCol}")\`);`,
);
this.writer.blankLine();

if (refreshTriggers && sourceTables.length > 0) {
// Refresh function: pg_try_advisory_xact_lock dedupes so REFRESH
// runs at most once per transaction even when the trigger fires
// for many rows. The lock auto-releases at txn end.
const fnName = `refresh_${viewName}`;
this.writer.writeLine(`await knex.raw(\`CREATE OR REPLACE FUNCTION "${fnName}"() RETURNS TRIGGER AS $$`);
this.writer.writeLine(`BEGIN`);
this.writer.writeLine(` IF pg_try_advisory_xact_lock(hashtext('refresh:${viewName}')) THEN`);
this.writer.writeLine(` REFRESH MATERIALIZED VIEW CONCURRENTLY "${viewName}";`);
this.writer.writeLine(` END IF;`);
this.writer.writeLine(` RETURN NULL;`);
this.writer.writeLine(`END;`);
this.writer.writeLine('$$ LANGUAGE plpgsql`);');
this.writer.blankLine();

for (const table of sourceTables) {
const triggerName = `${fnName}_on_${table}`;
// CONSTRAINT TRIGGER + DEFERRABLE INITIALLY DEFERRED fires
// once per row event at end of txn. Multiple fires per txn
// are dedup'd by the advisory lock above.
this.writer.writeLine(
`await knex.raw(\`CREATE CONSTRAINT TRIGGER "${triggerName}" AFTER INSERT OR UPDATE OR DELETE ON "${table}" DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION "${fnName}"()\`);`,
);
}
this.writer.blankLine();
}
});

down.push(() => {
if (refreshTriggers && sourceTables.length > 0) {
const fnName = `refresh_${viewName}`;
for (const table of sourceTables) {
const triggerName = `${fnName}_on_${table}`;
this.writer.writeLine(`await knex.raw(\`DROP TRIGGER IF EXISTS "${triggerName}" ON "${table}"\`);`);
}
this.writer.writeLine(`await knex.raw(\`DROP FUNCTION IF EXISTS "${fnName}"()\`);`);
this.writer.blankLine();
}
this.writer.writeLine(`await knex.raw(\`DROP MATERIALIZED VIEW IF EXISTS "${viewName}"\`);`);
this.writer.blankLine();
});
}
}

private renameFields(tableName: string, fields: EntityField[], up: Callbacks, down: Callbacks) {
if (!fields.length) {
return;
Expand Down
Loading