Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/visible-retry-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ashiba-ts/driver-adapter-core": minor
"@ashiba-ts/driver-adapter-pg": minor
---

Add a visible retry boundary helper and PostgreSQL transient-error classification for caller-owned retry policies. The helper requires an explicit retry classifier and keeps transaction safety, idempotency, SAGA compensation, and final error handling in application code.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ Ashiba chooses DBMS and wrapped driver explicitly. PostgreSQL is the most comple

| DBMS | Wrapped driver/tool | Package | Current status |
|---|---|---|---|
| Shared driver contracts | driver adapter core | `@ashiba-ts/driver-adapter-core` | Shared feature query contracts and cardinality helpers. No ORM model or SQL DSL. |
| PostgreSQL | `pg` | `@ashiba-ts/driver-adapter-pg` | Most complete starter path. Includes generated query metadata, mapper-test lane, named-parameter binding, safe sort, optional-condition metadata, and tutorial coverage. |
| Shared driver contracts | driver adapter core | `@ashiba-ts/driver-adapter-core` | Shared feature query contracts, cardinality helpers, and explicit retry-policy helpers. No ORM model or SQL DSL. |
| PostgreSQL | `pg` | `@ashiba-ts/driver-adapter-pg` | Most complete starter path. Includes generated query metadata, mapper-test lane, named-parameter binding, safe sort, optional-condition metadata, transient-error classification, and tutorial coverage. |
| PostgreSQL | `pg` testkit | `@ashiba-ts/testkit-adapter-pg` | ZTD mapper-test adapter used by the PostgreSQL starter. |
| PostgreSQL | `pg_dump` | `@ashiba-ts/ddl-pull-pg-dump` | Optional helper for comparing production DDL from `pg_dump` with local DDL. |
| MySQL | `mysql2` | `@ashiba-ts/driver-adapter-mysql2` | Driver adapter exists. Full `ashiba init` starter and testkit path are not complete yet. |
Expand Down Expand Up @@ -259,7 +259,7 @@ npx ashiba config

Ashiba is not an ORM runtime or SQL DSL. The CLI generator, SQL analysis, scaffolding, tests, and drift checks are development-time tools.

At runtime, applications may use a selected thin SQL execution adapter such as `@ashiba-ts/driver-adapter-pg`. That adapter owns narrow execution concerns: named-parameter binding, metadata-backed optional-condition compression, metadata-backed safe sort, stale metadata rejection, and observer events. It does not own entities, relations, lazy loading, unit of work, dirty tracking, or query planning.
At runtime, applications may use a selected thin SQL execution adapter such as `@ashiba-ts/driver-adapter-pg`. That adapter owns narrow execution concerns: named-parameter binding, metadata-backed optional-condition compression, metadata-backed safe sort, stale metadata rejection, observer events, and transient-error classification for explicit retry policies. It does not own entities, relations, lazy loading, unit of work, dirty tracking, query planning, hidden automatic retry, or SAGA compensation.

The `.sql` file is the canonical source. Generated `query.sql.ts` is a runtime snapshot and must not be hand edited. After SQL-only edits, run refresh/check so `.sql`, `query.sql.ts`, and `query.meta.ts` stay synchronized.

Expand Down
2 changes: 1 addition & 1 deletion docs/concepts/concept-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ These concepts belong to driver-neutral SQL libraries, production driver adapter

| ID | Display name | Status | Notes |
|---|---|---|---|
| `thin-driver-adapter` | Thin Driver Adapter | mostly done | `pg` adapter owns named binding, parameter checks, query-model-gated safe sort, optional-condition compression, stale metadata rejection, and observer events while avoiding ORM, relation loading, unit-of-work, query DSL, and transaction ownership. Starter code should keep the standard path option-light, expose frequent connection and transaction controls nearby, and leave rare driver policy to customer-owned code. Wrapper package names include the wrapped driver or tool name. |
| `thin-driver-adapter` | Thin Driver Adapter | mostly done | `pg` adapter owns named binding, parameter checks, query-model-gated safe sort, optional-condition compression, stale metadata rejection, observer events, and transient-error classification while avoiding ORM, relation loading, unit-of-work, query DSL, hidden automatic retry, SAGA compensation, and transaction ownership. Starter code should keep the standard path option-light, expose frequent connection, transaction, and explicit retry controls nearby, and leave business retry safety to customer-owned code. Wrapper package names include the wrapped driver or tool name. |
| `named-parameter-binding` | Named Parameter Binding | mostly done | Source SQL uses named parameters such as `:name` or `@name`; DB driver wrappers compile them to driver placeholders. |
| `parameter-contract-check` | Parameter Contract Check | mostly done | Missing and unused parameters fail before execution in binder and PostgreSQL adapter paths. |
| `safe-sort-profile` | Safe Sort Profile | mostly done | DB driver wrapper-owned safe sort surface based on whitelisted profiles and CLI-generated query model metadata: source hash, root query shape, insertion position, order-by/comma mode, and sortable dictionary. Sort keys must exactly match the query model whitelist. |
Expand Down
40 changes: 40 additions & 0 deletions docs/guide/runtime-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,49 @@ At runtime, the selected driver adapter can be used as a thin SQL execution boun
- metadata-backed optional-condition compression
- metadata-backed safe sort
- observer events for logging
- transient DB error classification for caller-owned retry policies

The adapter still executes SQL through the wrapped driver. It does not expose a SQL builder DSL, ORM model, object graph, or relation loader.

## Visible Retry Boundary

Thin drivers may help applications handle transient database failures, but retry must stay visible.

`@ashiba-ts/driver-adapter-core` provides `withAshibaRetry`, a small helper for caller-owned retry loops. It retries only when the caller passes an explicit `retryOn` classifier. The final error is rethrown as-is, and retry / give-up events can be observed by application logging.

`@ashiba-ts/driver-adapter-pg` provides `classifyPostgresTransientError` and `isPostgresTransientError` for common PostgreSQL SQLSTATE and connection failures such as serialization failure, deadlock, shutdown, connection failure, and transient network errors.

Example:

```ts
import { withAshibaRetry } from '@ashiba-ts/driver-adapter-core';
import { classifyPostgresTransientError } from '@ashiba-ts/driver-adapter-pg';

await withAshibaRetry(
{
maxAttempts: 3,
retryOn(error) {
const result = classifyPostgresTransientError(error);
return { retry: result.retryable, reason: result.reason };
},
},
async () => {
// Application-owned transaction or query boundary call.
},
);
```

This is intentionally not hidden automatic retry. Application code still owns:

- whether the operation is safe to run again
- transaction scope
- idempotency keys or optimistic locking
- external side effects
- SAGA / compensation workflows
- how retries are logged, alerted, and capped

Do not use a thin-driver retry helper to silently re-run arbitrary mutations after the application has performed non-database side effects. For commit-unknown or compensation-heavy workflows, keep the retry decision in application/SAGA code and use the driver classifier only as one input.

## Feature Query Boundary

Generated feature query code depends on the shared `FeatureQueryExecutor` contract from `@ashiba-ts/driver-adapter-core`.
Expand Down
6 changes: 6 additions & 0 deletions packages/driver-adapter-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ It provides shared types and helpers for:

- masked parameter logging
- logger-ready execution events
- explicit retry policy helpers for visible transient-failure retry boundaries
- feature query boundary types
- `many` / `one` / `oneOrNull` cardinality helpers
- dialect-extensible query model binding slots
Expand All @@ -24,3 +25,8 @@ It provides shared types and helpers for:

Application projects that use generated feature query boundaries should install
this package alongside a concrete adapter such as `@ashiba-ts/driver-adapter-pg`.

`withAshibaRetry` is intentionally policy-driven. It does not decide that SQL,
transactions, external side effects, or SAGA workflows are safe to execute again.
Application code must pass an explicit classifier and own idempotency,
compensation, and logging policy.
196 changes: 196 additions & 0 deletions packages/driver-adapter-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,93 @@ export type AshibaSqlExecutionObserver = {
emit(event: AshibaSqlExecutionEvent): void;
};

/**
* Caller-owned decision returned by a visible retry policy.
*/
export type AshibaRetryDecision =
| boolean
| {
retry: boolean;
reason?: string;
delayMs?: number;
};

/**
* Context passed to retry policy functions after a failed attempt.
*/
export type AshibaRetryContext = {
attempt: number;
maxAttempts: number;
elapsedMs: number;
error: unknown;
};

/**
* Structured retry event emitted by retry helpers.
*/
export type AshibaRetryEvent = {
phase: 'retry' | 'give-up';
attempt: number;
maxAttempts: number;
elapsedMs: number;
delayMs?: number;
reason?: string;
error: {
name: string;
message: string;
code?: string;
cause?: string;
nextAction?: string;
};
};

/**
* Application-provided observer hook for retry visibility.
*/
export type AshibaRetryObserver = {
emit(event: AshibaRetryEvent): void;
};

/**
* Explicit retry policy for thin-driver retry boundaries.
*
* The policy intentionally requires a retry classifier. Ashiba does not infer
* that arbitrary SQL or workflow code is safe to execute again.
*/
export type AshibaRetryPolicy = {
maxAttempts: number;
retryOn(error: unknown, context: AshibaRetryContext): AshibaRetryDecision;
delayMs?: number | ((context: AshibaRetryContext) => number);
observer?: AshibaRetryObserver;
};

/**
* Error raised when an invalid retry policy is passed to the shared helper.
*/
export class AshibaRetryPolicyError extends Error {
readonly code: 'ASHIBA_RETRY_POLICY_INVALID';
readonly causeText: string;
readonly nextAction: string;
readonly details?: {
operationError?: ReturnType<typeof normalizeError>;
policyError?: ReturnType<typeof normalizeError>;
};

constructor(message: string, details?: { operationError?: unknown; policyError?: unknown }) {
super(message, details?.policyError ? { cause: details.policyError } : undefined);
this.name = 'AshibaRetryPolicyError';
this.code = 'ASHIBA_RETRY_POLICY_INVALID';
this.causeText = 'The retry helper received a policy that cannot define a visible retry boundary.';
this.nextAction = 'Pass maxAttempts >= 1 and an explicit retryOn classifier. Do not rely on hidden automatic retry.';
this.details = details
? {
...(details.operationError !== undefined ? { operationError: normalizeError(details.operationError) } : {}),
...(details.policyError !== undefined ? { policyError: normalizeError(details.policyError) } : {}),
}
: undefined;
}
}

/**
* Allowed direction values for safe sort rendering.
*/
Expand Down Expand Up @@ -315,6 +402,72 @@ export async function queryOneOrNull<T = unknown>(
return rows[0] ?? null;
}

/**
* Run caller-owned work under an explicit retry boundary.
*
* This helper retries only when the provided policy says the thrown error is
* retryable. It does not wrap the final error or decide business idempotency.
*/
export async function withAshibaRetry<T>(
policy: AshibaRetryPolicy,
operation: (context: { attempt: number; maxAttempts: number }) => Promise<T>,
): Promise<T> {
assertRetryPolicy(policy);
const startedAt = Date.now();
let attempt = 1;

for (;;) {
try {
return await operation({ attempt, maxAttempts: policy.maxAttempts });
} catch (error) {
const context: AshibaRetryContext = {
attempt,
maxAttempts: policy.maxAttempts,
elapsedMs: Date.now() - startedAt,
error,
};
let decision: { retry: boolean; reason?: string; delayMs?: number };
try {
decision = normalizeRetryDecision(policy.retryOn(error, context));
} catch (policyError) {
safeEmitRetryEvent(policy, {
phase: 'give-up',
attempt,
maxAttempts: policy.maxAttempts,
elapsedMs: context.elapsedMs,
reason: 'retryOn threw while classifying the operation failure',
error: normalizeError(error),
});
throw new AshibaRetryPolicyError(
'Retry policy retryOn threw while classifying an operation failure.',
{ operationError: error, policyError },
);
}
const shouldRetry = decision.retry && attempt < policy.maxAttempts;
const delayMs = shouldRetry ? resolveRetryDelayMs(policy, context, decision) : undefined;
safeEmitRetryEvent(policy, {
phase: shouldRetry ? 'retry' : 'give-up',
attempt,
maxAttempts: policy.maxAttempts,
elapsedMs: context.elapsedMs,
...(delayMs !== undefined ? { delayMs } : {}),
...(decision.reason ? { reason: decision.reason } : {}),
error: normalizeError(error),
});

if (!shouldRetry) {
throw error;
}

const retryDelayMs = delayMs ?? 0;
if (retryDelayMs > 0) {
await sleep(retryDelayMs);
}
attempt += 1;
}
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Error raised when a safe sort request violates the reviewed query model or sort profile.
*/
Expand Down Expand Up @@ -397,6 +550,49 @@ export function normalizeError(error: unknown): { name: string; message: string;
};
}

function assertRetryPolicy(policy: AshibaRetryPolicy): void {
if (!Number.isInteger(policy.maxAttempts) || policy.maxAttempts < 1) {
throw new AshibaRetryPolicyError('Retry policy maxAttempts must be an integer greater than or equal to 1.');
}
if (typeof policy.retryOn !== 'function') {
throw new AshibaRetryPolicyError('Retry policy must provide an explicit retryOn classifier.');
}
}

function normalizeRetryDecision(decision: AshibaRetryDecision): { retry: boolean; reason?: string; delayMs?: number } {
if (typeof decision === 'boolean') {
return { retry: decision };
}
return {
retry: decision.retry,
...(decision.reason ? { reason: decision.reason } : {}),
...(decision.delayMs !== undefined ? { delayMs: decision.delayMs } : {}),
};
}

function resolveRetryDelayMs(
policy: AshibaRetryPolicy,
context: AshibaRetryContext,
decision: { delayMs?: number },
): number {
const value = decision.delayMs ?? (typeof policy.delayMs === 'function' ? policy.delayMs(context) : policy.delayMs) ?? 0;
return Number.isFinite(value) ? Math.max(0, value) : 0;
}

function sleep(delayMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}

function safeEmitRetryEvent(policy: AshibaRetryPolicy, event: AshibaRetryEvent): void {
try {
policy.observer?.emit(event);
} catch {
// Observer errors must not mask the operation error.
}
}

function describeSortErrorCause(code: AshibaSortError['code']): string {
switch (code) {
case 'ASHIBA_EMPTY_SORT_PROFILE':
Expand Down
Loading
Loading