Skip to content
Draft
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
3 changes: 3 additions & 0 deletions examples/react/action-enforcement/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
*.tsbuildinfo
3 changes: 3 additions & 0 deletions examples/react/action-enforcement/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
link-workspace-packages=false
prefer-workspace-packages=false
shared-workspace-lockfile=false
45 changes: 45 additions & 0 deletions examples/react/action-enforcement/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# React Action-Enforcement Example

This example demonstrates how to enforce action-only mutations with TanStack DB.

## What is enforced

- Feature/UI code (`src/features/**`) can import collections for read/query use.
- Feature/UI code cannot call collection mutation methods directly (`insert`, `update`, `delete`, `upsert`).
- Writes are performed through `src/db/actions/*` only.

This is enforced by a custom ESLint rule in `eslint-rules/no-direct-collection-mutations.js`, wired in `eslint.config.mjs`.
The rule uses `collectionImportPatterns` (list of regex strings) to match collection import paths.
An alternative strict import-ban approach using `no-restricted-imports` is included as commented config in `eslint.config.mjs`.

## Run

```bash
pnpm install
pnpm dev
```

## Real pattern shown

- `src/db/collections/todoCollection.ts`: collection wiring
- `src/db/actions/todoActions.ts`: `createOptimisticAction` mutations
- `src/features/todos/TodoApp.tsx`: reads directly with `useLiveQuery` and includes an intentional invalid direct mutation example
- `eslint-rules/no-direct-collection-mutations.js`: custom lint rule

## Intentional anti-pattern (will fail lint)

```ts
// src/features/todos/TodoApp.tsx
import { todoCollection } from '@/db/collections/todoCollection'

todoCollection.insert({
id: crypto.randomUUID(),
text: 'bad write',
completed: false,
createdAt: new Date(),
})
```

Running `pnpm lint` will reject this mutation call.

If you want a clean lint pass, remove the intentional direct mutation call in `src/features/todos/TodoApp.tsx`.
9 changes: 9 additions & 0 deletions examples/react/action-enforcement/eslint-rules/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import noDirectCollectionMutations from './no-direct-collection-mutations.js'

const tanstackArchitecturePlugin = {
rules: {
'no-direct-collection-mutations': noDirectCollectionMutations,
},
}

export default tanstackArchitecturePlugin
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
const DEFAULT_IMPORT_PATTERNS = ['^@/db/collections/']
const DEFAULT_MUTATION_METHODS = ['insert', 'update', 'delete', 'upsert']

function unwrapExpression(node) {
let current = node

while (current) {
if (
current.type === 'TSAsExpression' ||
current.type === 'TSTypeAssertion' ||
current.type === 'TSNonNullExpression' ||
current.type === 'ChainExpression' ||
current.type === 'ParenthesizedExpression'
) {
current = current.expression
continue
}

return current
}

return current
}

function getPropertyName(memberExpression) {
if (
!memberExpression.computed &&
memberExpression.property.type === 'Identifier'
) {
return memberExpression.property.name
}

if (
memberExpression.computed &&
memberExpression.property.type === 'Literal' &&
typeof memberExpression.property.value === 'string'
) {
return memberExpression.property.value
}

return null
}

function getRootIdentifierName(node) {
const expression = unwrapExpression(node)

if (!expression) {
return null
}

if (expression.type === 'Identifier') {
return expression.name
}

if (expression.type === 'MemberExpression') {
return getRootIdentifierName(expression.object)
}

return null
}

export default {
meta: {
type: 'problem',
docs: {
description:
'disallow direct TanStack DB collection mutations in feature modules',
},
schema: [
{
type: 'object',
additionalProperties: false,
properties: {
collectionImportPatterns: {
type: 'array',
items: {
type: 'string',
},
minItems: 1,
},
mutationMethods: {
type: 'array',
items: {
type: 'string',
},
minItems: 1,
},
},
},
],
messages: {
noDirectMutation:
"Direct collection mutation '{{method}}' on '{{collection}}' is not allowed in feature code.",
},
},
create(context) {
const options = context.options[0] ?? {}
const configuredImportPatterns =
options.collectionImportPatterns ?? DEFAULT_IMPORT_PATTERNS
const importPatterns = configuredImportPatterns.map(
(pattern) => new RegExp(pattern),
)
const mutationMethods = new Set(
options.mutationMethods ?? DEFAULT_MUTATION_METHODS,
)
const trackedCollectionIdentifiers = new Set()

function trackImportedIdentifier(localName) {
trackedCollectionIdentifiers.add(localName)
}

function trackAlias(aliasName, sourceExpression) {
const sourceRootName = getRootIdentifierName(sourceExpression)

if (sourceRootName && trackedCollectionIdentifiers.has(sourceRootName)) {
trackedCollectionIdentifiers.add(aliasName)
}
}

return {
ImportDeclaration(node) {
if (
typeof node.source.value !== 'string' ||
!importPatterns.some((pattern) => pattern.test(node.source.value))
) {
return
}

for (const specifier of node.specifiers) {
trackImportedIdentifier(specifier.local.name)
}
},
VariableDeclarator(node) {
if (node.id.type !== 'Identifier' || !node.init) {
return
}

trackAlias(node.id.name, node.init)
},
AssignmentExpression(node) {
if (node.operator !== '=' || node.left.type !== 'Identifier') {
return
}

trackAlias(node.left.name, node.right)
},
CallExpression(node) {
const callee = unwrapExpression(node.callee)

if (!callee || callee.type !== 'MemberExpression') {
return
}

const methodName = getPropertyName(callee)
if (!methodName || !mutationMethods.has(methodName)) {
return
}

const rootIdentifierName = getRootIdentifierName(callee.object)
if (
!rootIdentifierName ||
!trackedCollectionIdentifiers.has(rootIdentifierName)
) {
return
}

context.report({
node: callee.property,
messageId: 'noDirectMutation',
data: {
method: methodName,
collection: rootIdentifierName,
},
})
},
}
},
}
72 changes: 72 additions & 0 deletions examples/react/action-enforcement/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import js from '@eslint/js'
import tsParser from '@typescript-eslint/parser'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import globals from 'globals'
import tanstackArchitecturePlugin from './eslint-rules/index.js'

export default [
{
ignores: ['dist', 'node_modules'],
},
{
files: ['src/**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.es2022,
},
},
plugins: {
'@typescript-eslint': tsPlugin,
'tanstack-architecture': tanstackArchitecturePlugin,
},
rules: {
...js.configs.recommended.rules,
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
{
files: ['src/features/**/*.{ts,tsx}'],
rules: {
// Features can read from collections, but write operations must go through actions.
'tanstack-architecture/no-direct-collection-mutations': [
'error',
{
collectionImportPatterns: ['^@/db/collections/'],
mutationMethods: ['insert', 'update', 'delete', 'upsert'],
},
],
// Alternative (stricter) approach: ban collection imports in features entirely.
// This forces all reads/writes through query hooks and action modules.
// 'no-restricted-imports': [
// 'error',
// {
// patterns: [
// {
// group: ['@/db/collections/*'],
// message:
// 'Feature modules cannot import collections directly. Use query hooks and action modules.',
// },
// ],
// },
// ],
},
},
]
12 changes: 12 additions & 0 deletions examples/react/action-enforcement/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TanStack DB Action Enforcement</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
32 changes: 32 additions & 0 deletions examples/react/action-enforcement/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@tanstack/db-example-react-action-enforcement",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@tanstack/query-core": "^5.90.20",
"@tanstack/query-db-collection": "npm:@tanstack/query-db-collection@^1.0.26",
"@tanstack/react-db": "npm:@tanstack/react-db@^0.1.73",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.55.0",
"@typescript-eslint/parser": "^8.55.0",
"@vitejs/plugin-react": "^5.1.3",
"eslint": "^9.39.2",
"globals": "^16.5.0",
"typescript": "^5.9.2",
"vite": "^7.3.0",
"vite-tsconfig-paths": "^5.1.4"
}
}
Loading
Loading