pgsqlasync2fast-fastapi 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi.egg-info → pgsqlasync2fast_fastapi-0.5.0}/PKG-INFO +2 -1
  2. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/pyproject.toml +6 -0
  3. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/__init__.py +21 -0
  4. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/__version__.py +1 -0
  5. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/__init__.py +49 -0
  6. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/env.py +120 -0
  7. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/registry.py +70 -0
  8. pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi/migrations/runner.py +163 -0
  9. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/seeder.py +59 -0
  10. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/skills/SKILL.md +8 -2
  11. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0/src/pgsqlasync2fast_fastapi.egg-info}/PKG-INFO +2 -1
  12. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt +7 -1
  13. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/requires.txt +1 -0
  14. pgsqlasync2fast_fastapi-0.5.0/tests/test_migration_chains.py +411 -0
  15. pgsqlasync2fast_fastapi-0.5.0/tests/test_sync_sequence.py +201 -0
  16. pgsqlasync2fast_fastapi-0.4.0/src/pgsqlasync2fast_fastapi/__version__.py +0 -1
  17. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/LICENSE +0 -0
  18. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/MANIFEST.in +0 -0
  19. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/README.md +0 -0
  20. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/docs/.env.example +0 -0
  21. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/docs/seeder-format.md +0 -0
  22. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/basic_usage.py +0 -0
  23. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/database_creation.py +0 -0
  24. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/fastapi_integration.py +0 -0
  25. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/multi_database.py +0 -0
  26. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/categories.json +0 -0
  27. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/permissions.json +0 -0
  28. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/dev/roles.json +0 -0
  29. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/manifest.json +0 -0
  30. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/categories.json +0 -0
  31. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/permissions.json +0 -0
  32. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/examples/seeders/prod/roles.json +0 -0
  33. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/setup.cfg +0 -0
  34. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/connection.py +0 -0
  35. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/database.py +0 -0
  36. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/dependencies.py +0 -0
  37. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi/settings.py +0 -0
  38. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt +0 -0
  39. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/src/pgsqlasync2fast_fastapi.egg-info/top_level.txt +0 -0
  40. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_completo.py +0 -0
  41. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_insert_if_missing.py +0 -0
  42. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder.py +0 -0
  43. {pgsqlasync2fast_fastapi-0.4.0 → pgsqlasync2fast_fastapi-0.5.0}/tests/test_seeder_override.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Simple and fast PostgreSQL async module for FastAPI with multi-database support
5
5
  Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
6
6
  License: MIT License
@@ -50,6 +50,7 @@ Requires-Dist: sqlalchemy>=2.0.0
50
50
  Requires-Dist: sqlmodel>=0.0.22
51
51
  Requires-Dist: asyncpg>=0.29.0
52
52
  Requires-Dist: greenlet>=3.0.0
53
+ Requires-Dist: alembic==1.20.0
53
54
  Dynamic: license-file
54
55
 
55
56
  # pgsqlasync2fast-fastapi
@@ -34,6 +34,9 @@ dependencies = [
34
34
  "sqlmodel>=0.0.22",
35
35
  "asyncpg>=0.29.0",
36
36
  "greenlet>=3.0.0",
37
+ # Exact pin per the alembic-2fast migration change: deliberate re-pins
38
+ # require a version bump and a full test run. Never use a range here.
39
+ "alembic==1.20.0",
37
40
  ]
38
41
 
39
42
  [dependency-groups]
@@ -72,6 +75,9 @@ version = {attr = "pgsqlasync2fast_fastapi.__version__.__version__"}
72
75
  [tool.setuptools.package-data]
73
76
  pgsqlasync2fast_fastapi = [
74
77
  "skills/*.md",
78
+ # Shared Alembic env implementation + any future chain data shipped as
79
+ # wheel package data (resolved from the installed release, never a checkout).
80
+ "migrations/**/*.py",
75
81
  ]
76
82
 
77
83
  [tool.pytest.ini_options]
@@ -52,6 +52,19 @@ from pgsqlasync2fast_fastapi.seeder import (
52
52
  seed_all,
53
53
  # Shared idempotent insert-if-missing primitive
54
54
  insert_if_missing,
55
+ # Shared PostgreSQL sequence re-sync primitive
56
+ sync_table_sequence,
57
+ )
58
+
59
+ # Re-export migration-specific exports
60
+ from pgsqlasync2fast_fastapi.migrations import (
61
+ # Chain spec + runner
62
+ ChainSpec,
63
+ run_migrations,
64
+ # Lane-keyed chain registry
65
+ register_chain,
66
+ get_lane_chains,
67
+ clear_chain_registry,
55
68
  )
56
69
 
57
70
  __all__ = [
@@ -90,4 +103,12 @@ __all__ = [
90
103
  "seed_all",
91
104
  # Shared idempotent insert-if-missing primitive
92
105
  "insert_if_missing",
106
+ # Shared PostgreSQL sequence re-sync primitive
107
+ "sync_table_sequence",
108
+ # Migration infrastructure (alembic-2fast)
109
+ "ChainSpec",
110
+ "run_migrations",
111
+ "register_chain",
112
+ "get_lane_chains",
113
+ "clear_chain_registry",
93
114
  ]
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -0,0 +1,49 @@
1
+ """
2
+ pgsqlasync2fast_fastapi.migrations - shared Alembic infrastructure for the
3
+ 2fast family.
4
+
5
+ This package ships the shared pieces of the ``alembic-2fast`` migration
6
+ overhaul (Metal ERP change):
7
+
8
+ - ``env.py`` - shared async env implementation (official Alembic async recipe:
9
+ ``async_engine_from_config`` + ``NullPool`` + ``dispose()`` per run). Each
10
+ consumer chain dir ships a thin ``env.py`` shim that imports
11
+ ``run_migrations_offline`` / ``run_migrations_online`` from here.
12
+ - ``registry.py`` - lane-keyed chain registry (``register_chain`` /
13
+ ``get_lane_chains``), same idiom as ``register_seeder``.
14
+ - ``runner.py`` - ``ChainSpec`` and ``run_migrations(connection_name, chains)``:
15
+ reads the URL from manager config only (never ``DatabaseManager._engines``)
16
+ and upgrades each chain to head in declaration order.
17
+
18
+ Consumers resolve chains from the INSTALLED release via alembic-native
19
+ ``pkg:path`` resource strings (``coerce_resource_to_filename``), never from a
20
+ repository checkout.
21
+ """
22
+
23
+ from .env import (
24
+ do_run_migrations,
25
+ run_async_migrations,
26
+ run_migrations_offline,
27
+ run_migrations_online,
28
+ )
29
+ from .registry import (
30
+ clear_chain_registry,
31
+ get_lane_chains,
32
+ register_chain,
33
+ )
34
+ from .runner import ChainSpec, run_migrations
35
+
36
+ __all__ = [
37
+ # env impl
38
+ "do_run_migrations",
39
+ "run_async_migrations",
40
+ "run_migrations_offline",
41
+ "run_migrations_online",
42
+ # registry
43
+ "clear_chain_registry",
44
+ "get_lane_chains",
45
+ "register_chain",
46
+ # runner
47
+ "ChainSpec",
48
+ "run_migrations",
49
+ ]
@@ -0,0 +1,120 @@
1
+ """
2
+ Shared Alembic async env implementation (design D1 of the ``alembic-2fast``
3
+ change).
4
+
5
+ Consumer packages ship a ~12-line ``env.py`` shim inside each chain directory
6
+ that imports ``run_migrations_offline`` / ``run_migrations_online`` from this
7
+ module and dispatches on ``context.is_offline_mode()``. Alembic's
8
+ ``env_py_location`` is fixed at ``<script_location>/env.py``, so the shared
9
+ logic lives here and the per-chain shims stay thin.
10
+
11
+ Configuration contract (set programmatically by ``runner._chain_config``):
12
+
13
+ - main option ``script_location`` - ``"<package>:<chain_path>"`` (installed
14
+ release resolution via ``coerce_resource_to_filename``).
15
+ - main option ``sqlalchemy.url`` - the connection URL (config only).
16
+ - main option ``version_table`` - the chain's version table. Alembic 1.20.0
17
+ does NOT transport a ``version_table`` main option into ``MigrationContext``
18
+ (it is read from configure ``opts`` only), so it is passed EXPLICITLY to
19
+ ``context.configure(version_table=...)`` below (design D1).
20
+ - attribute ``target_metadata`` - chain-owned ``MetaData`` for autogenerate.
21
+ - attributes ``include_object`` / ``include_name`` - ownership filters from
22
+ ``runner._owner_filter`` (design D9).
23
+
24
+ Async pattern (official recipe): a fresh engine per run via
25
+ ``async_engine_from_config`` with ``NullPool``, disposed after the run. The
26
+ runner executes each chain via ``asyncio.to_thread(command.upgrade, ...)``, so
27
+ every run owns its own event loop in its own worker thread.
28
+
29
+ IMPORTANT (shared-module reuse): this module is imported ONCE per process
30
+ while Alembic re-executes each chain's ``env.py`` shim on every run. Nothing
31
+ here may bind ``context.config`` at module level - every function resolves
32
+ ``context.config`` lazily at call time so each run sees its own ``Config``.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ from typing import TYPE_CHECKING, Any
39
+
40
+ from alembic import context
41
+ from sqlalchemy import pool
42
+ from sqlalchemy.engine import Connection
43
+ from sqlalchemy.ext.asyncio import async_engine_from_config
44
+
45
+ if TYPE_CHECKING:
46
+ from alembic.config import Config
47
+
48
+ __all__ = [
49
+ "do_run_migrations",
50
+ "run_async_migrations",
51
+ "run_migrations_offline",
52
+ "run_migrations_online",
53
+ ]
54
+
55
+
56
+ def _shared_configure_kwargs(config: "Config") -> dict[str, Any]:
57
+ """Options shared by the offline and online ``configure`` calls."""
58
+ kwargs: dict[str, Any] = {
59
+ "target_metadata": config.attributes.get("target_metadata"),
60
+ "include_object": config.attributes.get("include_object"),
61
+ "include_name": config.attributes.get("include_name"),
62
+ }
63
+ version_table = config.get_main_option("version_table")
64
+ if version_table:
65
+ # Deliberate explicit pass-through: alembic 1.20.0 reads version_table
66
+ # from the MigrationContext opts only, never from main options.
67
+ kwargs["version_table"] = version_table
68
+ return kwargs
69
+
70
+
71
+ def do_run_migrations(connection: Connection) -> None:
72
+ """Configure ``context`` for an online run against ``connection``."""
73
+ config = context.config
74
+ context.configure(
75
+ connection=connection,
76
+ **_shared_configure_kwargs(config),
77
+ )
78
+ with context.begin_transaction():
79
+ context.run_migrations()
80
+
81
+
82
+ def run_migrations_offline() -> None:
83
+ """Render the chain's SQL without a database connection (offline mode)."""
84
+ config = context.config
85
+ kwargs = _shared_configure_kwargs(config)
86
+ kwargs.update(
87
+ {
88
+ "url": config.get_main_option("sqlalchemy.url"),
89
+ "literal_binds": True,
90
+ "dialect_opts": {"paramstyle": "named"},
91
+ }
92
+ )
93
+ context.configure(**kwargs)
94
+ with context.begin_transaction():
95
+ context.run_migrations()
96
+
97
+
98
+ async def run_async_migrations() -> None:
99
+ """Fresh NullPool async engine, upgraded, disposed - all per run."""
100
+ config = context.config
101
+ connectable = async_engine_from_config(
102
+ config.get_section(config.config_ini_section, {}),
103
+ prefix="sqlalchemy.",
104
+ poolclass=pool.NullPool,
105
+ )
106
+
107
+ async with connectable.connect() as connection:
108
+ await connection.run_sync(do_run_migrations)
109
+
110
+ await connectable.dispose()
111
+
112
+
113
+ def run_migrations_online() -> None:
114
+ """Entry point called by chain shims in online mode.
115
+
116
+ Runs in the worker thread that ``runner.run_migrations`` spawned via
117
+ ``asyncio.to_thread``, so no event loop is running here and
118
+ ``asyncio.run`` owns a fresh loop (and therefore the engine) per run.
119
+ """
120
+ asyncio.run(run_async_migrations())
@@ -0,0 +1,70 @@
1
+ """
2
+ Lane-keyed chain registry (design D6 of the ``alembic-2fast`` change).
3
+
4
+ Mirrors the ``register_seeder`` idiom: consumers register their chain specs at
5
+ import time, the app composes lanes explicitly, and the runner executes them
6
+ in registration order per lane.
7
+
8
+ - AUTH lane orders ``oauth`` -> ``permissions-global`` -> ``tenant2fast-auth``
9
+ (FK-proven); registration order within a lane is preserved.
10
+ - TENANT lane chains (``tenant2fast-rbac``, ``app``) are order-independent;
11
+ callers may still declare an explicit preferred order.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from .runner import ChainSpec
20
+
21
+ __all__ = [
22
+ "clear_chain_registry",
23
+ "get_lane_chains",
24
+ "register_chain",
25
+ ]
26
+
27
+ _CHAIN_REGISTRY: dict[str, list["ChainSpec"]] = {}
28
+
29
+
30
+ def register_chain(lane: str, chain: "ChainSpec") -> None:
31
+ """
32
+ Register a chain spec under a lane.
33
+
34
+ Re-registering the same ``(package, script_path)`` key updates that entry
35
+ in place (idempotent, position preserved); a distinct chain appends.
36
+ Order of registration is the order ``get_lane_chains`` returns, which is
37
+ the execution order for ordering-sensitive lanes.
38
+
39
+ Args:
40
+ lane: Lane name, e.g. ``"auth"`` or ``"tenant"``.
41
+ chain: The chain spec to register.
42
+ """
43
+ chains = _CHAIN_REGISTRY.setdefault(lane, [])
44
+ for index, existing in enumerate(chains):
45
+ if (
46
+ existing.package == chain.package
47
+ and existing.script_path == chain.script_path
48
+ ):
49
+ chains[index] = chain
50
+ return
51
+ chains.append(chain)
52
+
53
+
54
+ def get_lane_chains(lane: str) -> list["ChainSpec"]:
55
+ """
56
+ Return a defensive copy of the chains registered for a lane.
57
+
58
+ Args:
59
+ lane: Lane name, e.g. ``"auth"`` or ``"tenant"``.
60
+
61
+ Returns:
62
+ List of ``ChainSpec`` objects in registration order (empty if none).
63
+ """
64
+ return list(_CHAIN_REGISTRY.get(lane, []))
65
+
66
+
67
+ def clear_chain_registry() -> None:
68
+ """Clear all registered chains. Useful for testing."""
69
+ global _CHAIN_REGISTRY
70
+ _CHAIN_REGISTRY = {}
@@ -0,0 +1,163 @@
1
+ """
2
+ Async migration runner for package-owned Alembic chains (design D3 of the
3
+ ``alembic-2fast`` change).
4
+
5
+ ``run_migrations(connection_name, chains)`` upgrades each chain to ``head``
6
+ in declaration order. Key properties:
7
+
8
+ - URL resolution is CONFIG-ONLY: ``manager.config.get_connection_url(name)``.
9
+ ``DatabaseManager._engines`` (pooled app engines) is never read or reused -
10
+ each chain run gets a fresh ``NullPool`` async engine inside env.py that is
11
+ disposed after the run.
12
+ - Each chain runs in a worker thread via ``asyncio.to_thread``, so env.py's
13
+ ``asyncio.run`` owns a fresh event loop (and engine) per run - it can never
14
+ collide with the app's lifespan loop.
15
+ - ``script_location`` is an alembic-native ``"<package>:<chain_path>"``
16
+ resource string: Alembic resolves it from the INSTALLED release via
17
+ ``coerce_resource_to_filename`` (importlib.resources), never from a
18
+ checkout.
19
+ - Ownership filtering (design D9): ``include_object`` / ``include_name``
20
+ attributes admit only tables (and their child objects) listed in
21
+ ``owned_tables``, so autogenerate on a shared ``MetaData`` never proposes
22
+ foreign tables.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ from dataclasses import dataclass
29
+ from typing import Any, Callable, Sequence
30
+
31
+ from alembic import command
32
+ from alembic.config import Config
33
+ from sqlalchemy import MetaData
34
+
35
+ from ..connection import get_manager
36
+
37
+ __all__ = [
38
+ "ChainSpec",
39
+ "run_migrations",
40
+ ]
41
+
42
+ #: Object types whose ownership derives from their parent table (type names
43
+ #: verified against alembic 1.20.0 ``NameFilterType``).
44
+ _OWNED_PARENT_TYPES = frozenset(
45
+ {
46
+ "index",
47
+ "unique_constraint",
48
+ "foreign_key_constraint",
49
+ "check_constraint",
50
+ }
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ChainSpec:
56
+ """
57
+ Description of one package-owned Alembic chain shipped inside a wheel.
58
+
59
+ Attributes:
60
+ package: Import name of the wheel's package, e.g. ``"oauth2fast_fastapi"``.
61
+ script_path: Chain directory inside the package, e.g. ``"migrations/oauth"``.
62
+ version_table: Name of the chain's version table, e.g.
63
+ ``"alembic_version_oauth"`` (per-chain, never the default).
64
+ target_metadata: Chain-owned ``MetaData`` for autogenerate comparison.
65
+ owned_tables: Table names this chain owns; the autogenerate ownership
66
+ filter admits only these (design D9).
67
+ """
68
+
69
+ package: str
70
+ script_path: str
71
+ version_table: str
72
+ target_metadata: MetaData
73
+ owned_tables: frozenset[str]
74
+
75
+
76
+ def _owner_filter(owned_tables: frozenset[str]) -> Callable[..., bool]:
77
+ """
78
+ Build an ownership filter usable for BOTH alembic include hooks (D9).
79
+
80
+ Alembic calls ``include_object`` with
81
+ ``(object_, name, type_, reflected, compare_to)`` and ``include_name``
82
+ with ``(name, type_, parent_names)``; the returned callable dispatches on
83
+ the argument count:
84
+
85
+ - ``type_ == "table"`` -> the table's own name must be owned.
86
+ - index / unique / FK / check constraint -> the object's *parent table*
87
+ must be owned (no cross-lane objects are proposed).
88
+ - anything else -> admitted.
89
+
90
+ Args:
91
+ owned_tables: Table names owned by the chain.
92
+
93
+ Returns:
94
+ A callable assigning to ``cfg.attributes["include_object"]`` and
95
+ ``cfg.attributes["include_name"]``.
96
+ """
97
+
98
+ def _filter(*args: Any) -> bool:
99
+ if len(args) == 3: # include_name(name, type_, parent_names)
100
+ name, type_, parent_names = args
101
+ parent_table = None
102
+ else: # include_object(object_, name, type_, reflected, compare_to)
103
+ object_, name, type_, _, _ = args
104
+ parent_table = getattr(object_, "table", None)
105
+
106
+ if type_ == "table":
107
+ return name in owned_tables
108
+ if type_ in _OWNED_PARENT_TYPES:
109
+ if parent_table is not None:
110
+ return parent_table.name in owned_tables
111
+ return parent_names.get("table_name") in owned_tables
112
+ return True
113
+
114
+ return _filter
115
+
116
+
117
+ def _chain_config(chain: ChainSpec, url: str) -> Config:
118
+ """
119
+ Build the programmatic Alembic ``Config`` for one chain run (no ini file).
120
+
121
+ Args:
122
+ chain: The chain spec to configure.
123
+ url: The connection URL (already resolved from manager config).
124
+
125
+ Returns:
126
+ A ``Config`` whose main options and attributes drive the shared env.
127
+ """
128
+ cfg = Config()
129
+ cfg.set_main_option(
130
+ "script_location", f"{chain.package}:{chain.script_path}"
131
+ )
132
+ cfg.set_main_option("sqlalchemy.url", url)
133
+ cfg.set_main_option("version_table", chain.version_table)
134
+ cfg.attributes["target_metadata"] = chain.target_metadata
135
+ cfg.attributes["include_object"] = _owner_filter(chain.owned_tables)
136
+ cfg.attributes["include_name"] = _owner_filter(chain.owned_tables)
137
+ return cfg
138
+
139
+
140
+ async def run_migrations(
141
+ connection_name: str, chains: Sequence[ChainSpec]
142
+ ) -> None:
143
+ """
144
+ Upgrade every chain to head for a named connection, in declared order.
145
+
146
+ Args:
147
+ connection_name: Named connection from the manager config (e.g.
148
+ ``"auth"``, ``"tenant_1"``).
149
+ chains: Chain specs to apply, in FK-proven order.
150
+
151
+ Raises:
152
+ ValueError: If the connection name is not configured.
153
+ Any exception raised by Alembic's ``command.upgrade`` (fail-fast per
154
+ chain; the caller decides lane error policy).
155
+ """
156
+ manager = get_manager()
157
+ # Config only - pooled app engines under manager._engines are never used.
158
+ url = manager.config.get_connection_url(connection_name)
159
+
160
+ for chain in chains:
161
+ await asyncio.to_thread(
162
+ command.upgrade, _chain_config(chain, url), "head"
163
+ )
@@ -593,6 +593,60 @@ def _resolve_load_order(manifest: dict[str, Any]) -> list[str]:
593
593
  # ============================================================================
594
594
 
595
595
 
596
+ async def sync_table_sequence(
597
+ session: Any,
598
+ model_class: type,
599
+ ) -> None:
600
+ """
601
+ Re-sync a PostgreSQL table identity sequence to its current MAX(id).
602
+
603
+ The seeder inserts rows with explicit ``id`` values straight from the
604
+ manifest JSON. PostgreSQL's plain ``serial``/``sequence``-backed columns do
605
+ NOT advance the sequence when a row is inserted with an explicit id, so a
606
+ later sequence-backed insert (e.g. ``insert_if_missing``) can generate an
607
+ id that collides with an existing explicit-id row (UniqueViolation).
608
+
609
+ This issues one ``setval`` to ``MAX(id)`` after the explicit-id inserts so
610
+ the next auto-generated id continues past the seeded rows. It only runs
611
+ against PostgreSQL — SQLite assigns ``INTEGER PRIMARY KEY`` as max+1
612
+ automatically and has no incompatible sequence catalog, so it is a no-op
613
+ there.
614
+
615
+ Args:
616
+ session: SQLModel AsyncSession bound to the target DB.
617
+ model_class: The SQLModel/SQLAlchemy table class to re-sync.
618
+ """
619
+ from sqlalchemy import text
620
+
621
+ try:
622
+ bind = getattr(session, "bind", None)
623
+ if bind is None:
624
+ return
625
+ dialect = getattr(bind.dialect, "name", None)
626
+ if dialect != "postgresql":
627
+ return
628
+
629
+ table = model_class.__tablename__
630
+ seq = f"{table}_id_seq"
631
+ async with bind.begin() as conn:
632
+ max_id = await conn.scalar(
633
+ text(f'SELECT COALESCE(MAX(id), 0) FROM "{table}"')
634
+ )
635
+ if max_id:
636
+ await conn.execute(
637
+ text("SELECT setval(:seq, :val)"),
638
+ {"seq": seq, "val": max_id},
639
+ )
640
+ logger.info(
641
+ f"Synced sequence '{seq}' to MAX(id)={max_id} "
642
+ f"(table '{table}')"
643
+ )
644
+ except Exception as exc: # pragma: no cover - defensive, non-fatal
645
+ logger.warning(
646
+ f"Could not re-sync sequence for '{model_class.__name__}': {exc}"
647
+ )
648
+
649
+
596
650
  async def _seed_table_idempotent(
597
651
  session: Any,
598
652
  table_name: str,
@@ -658,6 +712,9 @@ async def _seed_table_idempotent(
658
712
  logger.error(f"Failed to insert row in table '{table_name}': {e}")
659
713
  raise
660
714
 
715
+ if model_class is not None:
716
+ await sync_table_sequence(session, model_class)
717
+
661
718
  return rows_inserted, rows_skipped
662
719
 
663
720
 
@@ -727,6 +784,8 @@ async def _seed_table_idempotent_generic(
727
784
  logger.error(f"Failed to insert row in table '{table_name}': {e}")
728
785
  raise
729
786
 
787
+ await sync_table_sequence(session, model_class)
788
+
730
789
  return rows_inserted, rows_skipped
731
790
 
732
791
 
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  name: pgsqlasync2fast-fastapi
3
- description: "Trigger: working on or with pgsqlasync2fast-fastapi. Multi-database async engine manager for FastAPI: DB_CONNECTIONS config, get_db_session deps, seeder orchestrator, tenant engines. Prevails over the 2fast-handbook base skill for this package."
3
+ description: "Trigger: working on or with pgsqlasync2fast-fastapi. Multi-database async engine manager for FastAPI: DB_CONNECTIONS config, get_db_session deps, seeder orchestrator, tenant engines, shared Alembic migration runner. Prevails over the 2fast-handbook base skill for this package."
4
4
  license: MIT
5
5
  metadata:
6
6
  author: AngelDanielSanchezCastillo
7
- version: "2.1"
7
+ version: "2.2"
8
8
  ---
9
9
 
10
10
  ## Purpose
@@ -33,6 +33,12 @@ engines/session factories keyed by connection name (`default`, `auth`,
33
33
  reuse this instead of hand-rolling per package (RBAC standardization D2). No
34
34
  commit — caller owns the transaction boundary. Also re-exported from the top-level
35
35
  `pgsqlasync2fast_fastapi` package.
36
+ - Migration infra (`pgsqlasync2fast_fastapi.migrations`, alembic-2fast change):
37
+ - `ChainSpec(package, script_path, version_table, target_metadata, owned_tables)` — frozen spec of one wheel-shipped chain.
38
+ - `register_chain(lane, chain)` / `get_lane_chains(lane)` / `clear_chain_registry()` — lane-keyed registry, same idiom as `register_seeder` (same-key re-registration updates in place; registration order = execution order).
39
+ - `run_migrations(connection_name, chains)` — async runner per the OFFICIAL alembic recipe: URL config-only (`manager.config.get_connection_url`), each chain upgraded to `head` via `asyncio.to_thread`; shared `migrations/env.py` creates a fresh `async_engine_from_config` + `NullPool` engine and disposes it per run. **NEVER touches `DatabaseManager._engines`** (pooled app engines).
40
+ - env contract: `script_location` = `"<pkg>:<chain_path>"` resource string resolved from the INSTALLED release (alembic `coerce_resource_to_filename`), never a checkout; `version_table` read via `config.get_main_option` and passed EXPLICITLY to `context.configure(version_table=...)` (alembic 1.20.0 does not auto-transport it); include_object/include_name ownership filters admit only `owned_tables` (+ their indexes/constraints).
41
+ - `alembic==1.20.0` pinned EXACTLY in runtime deps (deliberate re-pin rule).
36
42
 
37
43
  ## Architecture
38
44
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgsqlasync2fast-fastapi
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Simple and fast PostgreSQL async module for FastAPI with multi-database support
5
5
  Author-email: Angel Daniel Sanchez Castillo <angeldaniel.sanchezcastillo@gmail.com>
6
6
  License: MIT License
@@ -50,6 +50,7 @@ Requires-Dist: sqlalchemy>=2.0.0
50
50
  Requires-Dist: sqlmodel>=0.0.22
51
51
  Requires-Dist: asyncpg>=0.29.0
52
52
  Requires-Dist: greenlet>=3.0.0
53
+ Requires-Dist: alembic==1.20.0
53
54
  Dynamic: license-file
54
55
 
55
56
  # pgsqlasync2fast-fastapi
@@ -27,8 +27,14 @@ src/pgsqlasync2fast_fastapi.egg-info/SOURCES.txt
27
27
  src/pgsqlasync2fast_fastapi.egg-info/dependency_links.txt
28
28
  src/pgsqlasync2fast_fastapi.egg-info/requires.txt
29
29
  src/pgsqlasync2fast_fastapi.egg-info/top_level.txt
30
+ src/pgsqlasync2fast_fastapi/migrations/__init__.py
31
+ src/pgsqlasync2fast_fastapi/migrations/env.py
32
+ src/pgsqlasync2fast_fastapi/migrations/registry.py
33
+ src/pgsqlasync2fast_fastapi/migrations/runner.py
30
34
  src/pgsqlasync2fast_fastapi/skills/SKILL.md
31
35
  tests/test_completo.py
32
36
  tests/test_insert_if_missing.py
37
+ tests/test_migration_chains.py
33
38
  tests/test_seeder.py
34
- tests/test_seeder_override.py
39
+ tests/test_seeder_override.py
40
+ tests/test_sync_sequence.py
@@ -5,3 +5,4 @@ sqlalchemy>=2.0.0
5
5
  sqlmodel>=0.0.22
6
6
  asyncpg>=0.29.0
7
7
  greenlet>=3.0.0
8
+ alembic==1.20.0