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
13 changes: 5 additions & 8 deletions Backend/src/gists/dto/query-gists.dto.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsLatitude,
IsLongitude,
IsOptional,
IsNumber,
Min,
Max,
IsOptional,
IsString,
Max,
MaxLength,
IsBoolean,
Min,
} from 'class-validator';
import { IsLatitude, IsLongitude, IsOptional, IsNumber, Min, Max, IsString, IsBoolean, MaxLength } from 'class-validator';
import { Type, Transform } from 'class-transformer';

export class QueryGistsDto {
@ApiProperty({ description: 'Latitude to search from', example: 9.0579 })
Expand Down Expand Up @@ -63,8 +62,6 @@ export class QueryGistsDto {
authorAddress?: string;

@ApiPropertyOptional({
description: 'Return count grouped by location_cell for heatmap data',
example: true,
description: 'When true, count endpoint returns breakdown by location_cell',
example: false,
})
Expand Down
22 changes: 3 additions & 19 deletions Backend/src/gists/gist.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface NearbyQuery {
lon: number;
radiusMeters?: number;
limit?: number;
cursor?: string; // base64 encoded cursor or raw ISO date string
cursor?: string;
authorAddress?: string;
}

Expand All @@ -32,13 +32,6 @@ export interface CreateGistData {
export class GistRepository {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}

/**
* Persist a new gist row. When a transactional `EntityManager` is supplied
* (e.g. from `GistsService.create`), the INSERT joins the caller's
* transaction so the write can be rolled back atomically. When no manager
* is provided (e.g. from `IndexerService` on the connection pool), the
* INSERT runs in its own implicit transaction.
*/
async create(data: CreateGistData, manager?: EntityManager): Promise<Gist> {
const {
content,
Expand All @@ -52,9 +45,7 @@ export class GistRepository {
expires_at,
} = data;

// Default expiry: 24 hours from now
const expiresAt = expires_at ?? new Date(Date.now() + 24 * 60 * 60 * 1000);

const queryRunner = manager ?? this.dataSource;

const result = await queryRunner.query<Gist[]>(
Expand Down Expand Up @@ -86,18 +77,15 @@ export class GistRepository {
const params: unknown[] = [lon, lat, radiusMeters, limit];
const clauses: string[] = [];

// Issue #604 — exclude expired gists
clauses.push(`g.expires_at > NOW()`);

if (cursor) {
// Support both base64 encoded cursors and raw ISO strings
const decoded = PaginationHelper.decodeCursor(cursor) ?? cursor;
params.push(decoded);
clauses.push(`g.created_at < $${params.length}`);
}

if (authorAddress) {
// Case-sensitive exact match — Stellar addresses are encoded payloads
params.push(authorAddress);
clauses.push(`g.author_address = $${params.length}`);
}
Expand Down Expand Up @@ -129,7 +117,7 @@ export class GistRepository {
$3
)
${extraWhere}
ORDER BY g.created_at DESC
ORDER BY distance_meters ASC, g.created_at DESC
LIMIT $4
`,
params,
Expand Down Expand Up @@ -181,14 +169,14 @@ export class GistRepository {
return parseInt(row.cnt, 10) > 0;
}

/** Issue #604 — delete rows whose expiry has passed (called by cron job). */
async deleteExpired(): Promise<number> {
const result = await this.dataSource.query<Array<{ count: string }>>(
`WITH deleted AS (DELETE FROM gists WHERE expires_at <= NOW() RETURNING id)
SELECT COUNT(*) AS count FROM deleted`,
);
return parseInt(result[0].count, 10);
}

async countNearby(lat: number, lon: number, radiusMeters: number): Promise<number> {
const [row] = await this.dataSource.query<Array<{ count: string }>>(
`SELECT COUNT(*) AS count FROM gists
Expand All @@ -206,10 +194,6 @@ export class GistRepository {
query: Pick<NearbyQuery, 'lat' | 'lon' | 'radiusMeters'>,
): Promise<Array<{ cell: string; count: number }>> {
const { lat, lon, radiusMeters = 500 } = query;
lat: number,
lon: number,
radiusMeters: number,
): Promise<Array<{ cell: string; count: number }>> {
const rows = await this.dataSource.query<Array<{ location_cell: string; count: string }>>(
`SELECT location_cell, COUNT(*) AS count FROM gists
WHERE ST_DWithin(
Expand Down
3 changes: 0 additions & 3 deletions Backend/src/gists/gists.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ export class GistsController {
@Get('count')
@SkipThrottle()
@ApiOperation({ summary: 'Count gists near a location (optionally broken down by cell)' })
@Get('count')
@SkipThrottle()
@ApiOperation({ summary: 'Count active gists within a radius' })
countNearby(@Query() query: QueryGistsDto) {
return this.gistsService.countNearby(query);
}
Expand Down
37 changes: 8 additions & 29 deletions Backend/src/gists/gists.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import { stripHtml } from '../common/utils/sanitize';

const DEFAULT_TTL_HOURS = 24;

export interface CountNearbyResult {
count: number;
radius: number;
lat: number;
lon: number;
breakdown?: Array<{ cell: string; count: number }>;
}

@Injectable()
export class GistsService {
private readonly logger = new Logger(GistsService.name);
Expand All @@ -25,24 +33,8 @@ export class GistsService {
private readonly sorobanService: SorobanService,
) {}

/**
* Create a gist end-to-end.
*
* Issue #98 — atomicity:
* - External side-effects (sanitize, geo-encode, IPFS pin, Soroban post)
* happen OUTSIDE the database transaction because they cannot be
* rolled back from Postgres and would just block a connection slot.
* - The actual database INSERT runs inside `dataSource.transaction()` so
* any error thrown during the write rolls back the row atomically and
* future related writes (audit log, related tables) join the same tx.
* - A duplicate `stellar_gist_id` (e.g. retried Soroban post) raises a
* Postgres unique-violation (SQLSTATE 23505); we catch it and return
* the existing row so the API becomes safely idempotent.
*/
async create(dto: CreateGistDto): Promise<Gist> {
// Issue 87 — sanitize content before storing
const content = stripHtml(dto.content);

const locationCell = this.geoService.encode(dto.lat, dto.lon);

const { cid } = await this.ipfsService.pinJson({
Expand All @@ -57,7 +49,6 @@ export class GistsService {

this.logger.log(`Gist posted → cell=${locationCell} cid=${cid} gistId=${gistId}`);

// Issue #604 — compute expiry from ttlHours (default 24 h)
const ttlHours = dto.ttlHours ?? DEFAULT_TTL_HOURS;
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000);

Expand All @@ -81,9 +72,6 @@ export class GistsService {
} catch (err) {
const code = (err as { code?: string })?.code;
if (code === PG_UNIQUE_VIOLATION) {
// Concurrent or retried create with the same on-chain gist ID — the
// winning transaction already persisted the row. Return it so the
// caller observes a logically idempotent success.
this.logger.debug(
`Gist ${gistId} already indexed — returning existing row (SQLSTATE ${PG_UNIQUE_VIOLATION})`,
);
Expand Down Expand Up @@ -122,16 +110,7 @@ export class GistsService {
return { count: total, radius, lat, lon, breakdown: rows };
}

const count = await this.gistRepository.countNearby({ lat, lon, radiusMeters: radius });
async countNearby(
query: QueryGistsDto,
): Promise<{ count: number; radius: number; lat: number; lon: number; breakdown?: Array<{ cell: string; count: number }> }> {
const { lat, lon, radius = 500, breakdown } = query;
const count = await this.gistRepository.countNearby(lat, lon, radius);
if (breakdown) {
const cells = await this.gistRepository.countNearbyByCell(lat, lon, radius);
return { count, radius, lat, lon, breakdown: cells };
}
return { count, radius, lat, lon };
}
}
76 changes: 76 additions & 0 deletions infrastructure/docs/storage-tiering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Storage Tiering Automation

This repository now has a small storage-tiering workflow for long-lived S3 data:

- Terraform lifecycle rules for the backup bucket
- An access-analysis script that scores objects by recency and request volume
- Retrieval commands for archived objects
- A JSON report that can be used for cost review

## Terraform

The backup bucket is configured to transition through cheaper tiers over time:

- `STANDARD_IA` after 30 days
- `GLACIER_IR` after 90 days
- `DEEP_ARCHIVE` after 180 days
- noncurrent versions are expired after 365 days

See [`infrastructure/terraform/s3-tiering.tf`](../terraform/s3-tiering.tf) and the existing bucket definitions in [`infrastructure/terraform/s3-buckets.tf`](../terraform/s3-buckets.tf).

## Access Analysis

Use [`infrastructure/scripts/analyze-access.sh`](../scripts/analyze-access.sh) to turn an access inventory into a recommendation report.

The script expects JSON like:

```json
[
{
"key": "backups/2026-06-01.sql.gz",
"bucket": "gistpin-backups",
"storage_class": "STANDARD",
"size_bytes": 12345,
"last_accessed_days": 42,
"request_count_30d": 8
}
]
```

Run it with a file or by piping JSON on stdin:

```bash
bash infrastructure/scripts/analyze-access.sh \
--input /tmp/s3-access-inventory.json \
--bucket gistpin-backups
```

The script writes a report to `infrastructure/ci/reports/storage-tiering/` by default. The report includes:

- object counts per tier
- normalized storage-cost estimates
- objects that should move to a cheaper class
- restore commands for archived objects
- a lifecycle recommendation block for the backup bucket

## Retrieval

For objects recommended for archive storage, the report includes restore commands using `aws s3api restore-object`.

Example:

```bash
aws s3api restore-object \
--bucket gistpin-backups \
--key backups/2026-06-01.sql.gz \
--restore-request '{"Days":7,"GlacierJobParameters":{"Tier":"Standard"}}'
```

## Cost Review

Treat the generated report as an operational cost review artifact:

1. Run the access analysis on the latest inventory export.
2. Review objects recommended for tier changes.
3. Apply the lifecycle rules or restore commands as needed.
4. Re-run the report to confirm the savings trend.
Loading
Loading