Compare commits

..
Author SHA1 Message Date
james.maesandCodex b199b24f01 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:09:27 -05:00
83 changed files with 126 additions and 12324 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
qqq-orb: kingsrook/qqq-orb@0.7.12
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
-24
View File
@@ -1,24 +0,0 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1
with:
runner: linux-small
browsers: true
browser-runner: browser-build
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
-72
View File
@@ -1,72 +0,0 @@
name: Release candidate
'on':
workflow_dispatch:
inputs:
dry_run:
description: Plan a cut without changing release state
type: boolean
default: false
finalize:
description: Retry finalization of an existing cut; do not cut another RC
type: boolean
default: false
recover:
description: Recover the same published RC after a deployment timeout; never
reupload
type: boolean
default: false
circle_build:
description: Original release run id (recovery only)
type: string
default: ''
merge_sha:
description: Existing cut merge_sha (finalize retry only)
type: string
default: ''
bump_sha:
description: Existing cut bump_sha (finalize retry only)
type: string
default: ''
branch:
description: Existing cut branch (finalize retry only)
type: string
default: ''
version:
description: Existing cut version (finalize retry only)
type: string
default: ''
pull_request:
types:
- closed
branches:
- develop
permissions:
contents: read
jobs:
release-rc:
# The old qbit-rc-published repository_dispatch is replaced by the already-proven workflow_dispatch
# completion: the publisher's own ActionsWRITE job dispatches this workflow with finalize=true plus
# merge_sha/bump_sha/branch/version (same inputs as the manual finalize retry).
permissions:
contents: write
actions: read
pull-requests: write
if: github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qbit-release-rc.yaml@qrun-release-v1
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
finalize: ${{ github.event_name == 'workflow_dispatch' && inputs.finalize }}
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
}}
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|| '' }}
merge_sha: ${{ inputs.merge_sha || '' }}
bump_sha: ${{ inputs.bump_sha || '' }}
branch: ${{ inputs.branch || '' }}
version: ${{ inputs.version || '' }}
secrets:
QRUN_TAG_SIGNING_KEY: ${{ secrets.QRUN_TAG_SIGNING_KEY }}
+1 -1
View File
@@ -1,5 +1,5 @@
profile: qbit
orb_version: '0.8'
orb_version: 0.7.12
default_branch: develop
java_version: '21'
browsers: true
+1 -1
View File
@@ -42,7 +42,7 @@ This file provides guidance to Codex when working with code in this repository.
## Dependencies
- Java 21.
- Parent `com.kingsrook:qbit-build-parent:2.1.1`. The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-SNAPSHOT`; build the matching Core integration branch first (see `docs/CORE-DEPENDENCY.md`). Public RC.21 lacks required rollback callbacks.
- Parent `com.kingsrook:qbit-build-parent:2.0.0` (qqq 4.0.0). The default-active `qqq-snapshot` profile imports `qqq-bom-pom:4.1.0-RC.5` ahead of it, as in qbit-quick-search (ADR-0007). Remove that activation when the parent moves to 4.1.
- Tests: JUnit 5, AssertJ.
## Release
-4
View File
@@ -6,8 +6,6 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Added
- Attachment lifecycle SPI (`AttachmentLifecyclePolicy`, `CollaborationQBitConfig.withAttachmentLifecycle`) for retention, registration and mandatory access events; durable upload journal (`collaboration_upload_journal`, H2/MySQL/PostgreSQL), upload recovery and existing-attachment adoption processes, durable cleanup queue with operator settlement, verified/authenticated delivery (`AttachmentDownload`, `AttachmentContentIntegrityException`), media policy, aggregate guard and bounded attachment pagination. See `docs/ATTACHMENT-LIFECYCLE.md`, `docs/ATTACHMENT-RECOVERY.md`, `docs/RETAINED-UPLOAD-JOURNAL.md`, `docs/ATTACHMENT-PAGINATION.md`.
- Requires a core whose `QBackendTransaction` provides `supportsRollbackCallbacks()` and `addAfterRollbackCallback(Runnable)` (not in 4.1.0-RC.21); see `docs/CORE-DEPENDENCY.md`.
- Feature Settings table action **Create rows from configuration**: writes the code configuration out as editable `collaboration_feature_setting` rows (insert-only, only when the table is empty, no change in behavior).
- Setup card on the Collaboration home: each record feature, whether it is on, for which tables, and where that comes from.
- Host-neutral QBIT help bundle covering tables, fields, sections, built-in actions, the app, widgets and possible-value sources; core registration and `HelpBundleAssertions` coverage replace the temporary help loader.
@@ -15,7 +13,6 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Changed
- UI API extension collaboration 1.2.0, additive (#54): presence viewers carry `connections` (live leases merged into the viewer), snapshots carry `leaseCount`, and `GET /presence?perConnection=true` adds `leases`, one viewer per connection with an opaque `connectionId`, `mode`, `fieldName`, `sessionId` and `since`. `presence.changed` events always carry `leaseCount` and `leases`. Leases keep an opaque HMAC-SHA256 session id (secret `QQQ_PRESENCE_SESSION_SECRET` or random per process), never the session uuid, which is a bearer credential. `PresenceRegistry.connectionCounts()` counts leases next to the unchanged user `counts()`; the Who's here card shows both.
- UI API extension collaboration 1.1.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and published presence request/snapshot/viewer schemas to resolve dangling references. Retain CollaborationErrorV1 as deprecated.
- Upgrade the default QQQ BOM to `4.1.0-RC.7` from Maven Central.
@@ -30,7 +27,6 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Fixed
- QBit metadata reports the Maven artifact version from a filtered resource, including release versions.
- `presence.changed` events and presence snapshots always carry `viewers`, sending `[]` once the last viewer leaves, as the schema requires (#52).
- Timeline audit summaries read "Edited by Alice Morgan: Title, Total" or "Created by Alice Morgan" instead of "Record was Edited"; detail rows are unchanged.
- The demo server chooses its user per browser session (`?user=bob`, remembered in a cookie) instead of once per JVM, and sends the person's name to the frontend so it no longer shows Anonymous.
+8 -22
View File
@@ -1,8 +1,5 @@
# QBit Collaboration
> **Core integration verified locally (2026-11-04).** All 269 Collaboration tests pass against the matching Core snapshot, including attachment recovery and both journal providers. See [current integration status](docs/QBIT-RELEASE-STATUS.md), [dependency setup](docs/CORE-DEPENDENCY.md) and [remaining release requirements](docs/QBIT-KNOWN-ISSUES.md). This feature branch is not a published release.
Record collaboration for QQQ: attachments, tags, custom fields, comments with mentions, presence, sharing, favorites, follow, timeline and tasks. **Implemented now:** feature settings and people tables, favorites and recents services (phase b), per-user security, daily recents retention, phase-c attachments (upload/list/download/rename/delete, previews and parent cleanup), and the Collaboration app home with real session-scoped counts. Phase e adds comments/mentions, follow actions and digests, the audit/activity timeline, and automatic people-directory updates. Phase d adds tags, vocabulary policies, management and bulk actions, and core-integrated tag filtering/search. Phases h/i add sharing/access requests, tasks/reminders, assignments and My work services. Phases f/g add the custom-field designer and ephemeral replicated presence. Runtime overrides and TAGS mode settings resolve through core.
Design: [QRun-IO/qqq PR #1136](https://github.com/QRun-IO/qqq/pull/1136), `docs/COLLABORATION.md`. See [the phase table](docs/DESIGN.md).
@@ -31,10 +28,10 @@ Comment parents list the selected thread's root and replies. Feature-setting sco
## Requirements
- Java 21.
- QQQ `4.1.0-SNAPSHOT` built from the compatible Core integration branch; see [Core dependency setup](docs/CORE-DEPENDENCY.md). Middleware Javalin is a compile dependency. Public RC.21 lacks the required rollback API.
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use memory, H2 and disposable PostgreSQL/MySQL containers.
- QQQ `4.1.0-RC.7` from Maven Central; middleware Javalin is a compile dependency.
- A configured QQQ backend supporting query, get, insert, update, delete, count and aggregate actions. PostgreSQL 15+ is the intended persistent store (the generated personal-target indexes use `NULLS NOT DISTINCT`); tests use the memory backend.
- Attachments require `qbit-object-storage:0.1.0-RC.2` (a pinned dependency), installed with a PRIVATE bucket alias matching `attachmentsBucketName`. Configure its connection and credentials in the host; this qbit does not provision buckets.
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use the same matching Core snapshot.
- Core feature resolution, permissions, reference security, summaries, query expansion and realtime use QQQ `4.1.0-RC.7`.
## Followers and secured data
@@ -57,7 +54,7 @@ Comment bodies remain user content; realtime field-name filtering is handled by
## Demo server
`CollaborationDevServer` is a disposable, memory-backed Sales CRM for trying the
Next dashboard and taking screenshots. It needs Java 21 and the matching Core integration snapshot. No database, Docker or MinIO is needed. It binds
Next dashboard and taking screenshots. It needs Java 21 and QQQ `4.1.0-RC.7` from Maven Central. No database, Docker or MinIO is needed. It binds
to `127.0.0.1`; stopping the JVM discards edits and uploaded attachments.
Build the Next static export in `qqq-frontend-next`:
@@ -413,7 +410,7 @@ The runtime provider adds this field and its search entry where TAGS is enabled.
## UI API
The producer registers extension `collaboration` version `1.2.0` at
The producer registers extension `collaboration` version `1.1.0` at
`/qqq/v1/qbit/collaboration`. It uses the signed-in user's session and existing
services. Disable registration with `new CollaborationQBitConfig().withUiApi(false)`.
Requires QQQ `4.1.0-RC.15` and UI API `1.6.0`. Operations carry stable operationIds,
@@ -724,7 +721,7 @@ Enable `RecordFeature.PRESENCE` on the table. The typed endpoints below
| --- | --- |
| `POST /presence` | `{connectionId, table, pk?, mode: "VIEWING"|"EDITING", fieldName?}` |
| `DELETE /presence` | `{connectionId, table, pk?}` |
| `GET /presence?table=...&pk=...&perConnection=true` | Omit pk for a table snapshot; `perConnection` is optional |
| `GET /presence?table=...&pk=...` | Omit pk for a table snapshot |
Session identity supplies userId and displayName. Every call rechecks table READ and record
GetAction visibility. Disabled features and denied/missing parents return the same 404. A
@@ -736,17 +733,6 @@ sweep and expiry checks on reads. The cap is 50,000 leases per pod: new targets
a warning while existing leases can renew. Tabs merge per user; EDITING wins and the latest
editor supplies focus. Nothing is persisted, including directory rows.
Per-connection presence (extension 1.2.0, #54): each merged viewer carries `connections`, the
number of live leases (tabs, terminals) behind it, and every snapshot carries `leaseCount`, so
two terminals of one user read as one person with two connections. `perConnection=true` adds
`leases`, one viewer per lease with an opaque `connectionId` (`c` and the first 16 hex digits of
SHA-256 of `userId`, a newline and the client connection id), its `mode`, `fieldName` (record
targets only), `sessionId` and `since`, the lease start. `presence.changed` events always carry
`leaseCount` and `leases`. Session uuids are bearer credentials, so a lease never stores or
publishes one: it keeps `sessionId`, `s` and the first 16 hex digits of HMAC-SHA256 of the
session uuid under `QQQ_PRESENCE_SESSION_SECRET` (system property or environment; set the same
value on every pod so ids agree) or else a random per-process secret. `PresenceRegistry.counts()` still counts users; `connectionCounts()` counts leases.
Every heartbeat/leave replicates over core's bus on `instance:presence`, a channel refused by
core client subscriptions. `presence.changed` is emitted to record and table channels. Table
snapshots aggregate users without record ids or focused field names; those stay on record
@@ -755,5 +741,5 @@ refetch after reconnecting.
The initial overlay refresh starts the registry on every pod, including those without local
viewers. Endpoints also initialize it lazily. At shutdown call `PresenceRegistry.close(instance)`
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user and
connection counts per table, and core record-feature summaries include the presence snapshot.
before `RealtimeBus.close(instance)`. The admin-only **Who's here** home card shows user counts
per table, and core record-feature summaries include the presence snapshot.
-81
View File
@@ -1,81 +0,0 @@
# Attachment lifecycle extension
Collaboration owns standard attachment storage and record access. A host can configure `CollaborationQBitConfig.withAttachmentLifecycle(new QCodeReference(HostPolicy.class))` to enforce retention, registration and mandatory access-event persistence. `HostPolicy` extends `AttachmentLifecyclePolicy`. Collaboration has no dependency on Record Files, generation, scripting or a particular event store.
The default policy preserves legacy behavior for ordinary attachments. Invalid policy loading fails before storage mutation. This is a trusted Java extension, never an entry point for untrusted user scripts. Policy implementations must be safe for concurrent requests and may not depend on per-instance state surviving between operations.
## Contract
| Hook | Timing and meaning |
| --- | --- |
| `isRetained(attachment)` | Called before object storage during intake with complete metadata but no generated id. Its result is persisted in the server-controlled `retained` field. Also consulted for legacy rows during deletion and display hints. A persisted true value always wins. |
| `beforeStored(attachment)` | Called with canonical metadata and actual checksum immediately before the first object PUT, after matching retry replay returns. Retained files default to refusal; a mandatory provider commits storage intent independently. Retained uploads reject ambient transactions. |
| `afterStored(attachment)` | Called after object and attachment metadata insertion, before upload returns. Register the retained identity and ingestion status. Failure preserves the object and inserted row for reconciliation when the enclosing transaction permits it. |
| `beforeAccess(attachment, inline)` | Called after parent authorization, before signing either standard preview or download URLs. Throw to deny. Runtime policy failures are converted to checked failures so preview code cannot silently swallow them. |
| `beforeMetadataChange(attachment, changes)` | Called after parent/ownership checks, before applying filename/description changes. The second record contains id and changed fields only. This represents an intent, not a committed change. |
Callbacks receive record copies; scalar attachment metadata is the contract. They must not mutate the passed records to influence storage or authorization. They may throw `QException` to fail the operation. A failed registration must remain inaccessible through the policy until reconciliation succeeds; absence of a retained registry entry must never mean unrestricted access.
## Persistent retention
`collaboration_attachment.retained` is a required boolean with false default for existing ordinary attachments. Apply the generated PostgreSQL schema migration through the host's normal migration tooling before installing this version. The migration is `ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE`. It does not automatically classify pre-existing files; any historical backfill requires a deliberate host migration.
There is no public API to clear retention. Direct user table writes remain denied. Deletion first authorizes and checks retention for the complete selection; it checks retention again immediately before storage deletion. Parent cleanup tombstones retained attachment rows and excludes completed tombstones from subsequent pages. The original bytes remain. The ordinary parent-based UI remains inaccessible after parent deletion; a retained-history view must supply its own preserved authorization policy. This extension does not grant archive access.
Removing the configured provider cannot restore deletion of rows already marked retained. The no-op provider also refuses signed URLs and metadata edits for those retained rows. Replacing a provider is a trusted host operation: the replacement must still enforce audit and quarantine for every row marked retained, even if its table is no longer configured for new retention.
Attachment DTOs expose separate `canEdit` and `canDelete` hints. Both require current ownership/admin and attach capability; retention disables deletion. Existing constructor overloads preserve source compatibility. The server rechecks all permissions on mutation. The old `canDelete` flag must no longer control client rename/description actions.
## Verified content for integrations
`AttachmentsService.readContent(attachmentId, maximumBytes)` authorizes the actual parent/current actor, enforces both content-read policy phases, validates stored length and SHA-256, closes storage on all paths and returns immutable `AttachmentContent(attachmentId, fileName, contentType, sha256, bytes)`. No storage coordinates or signed URL escape. The caller chooses a limit up to 20 MiB; ordinary large-file download remains a separate streaming/presigned path. No bytes leave this method before integrity verification and the result audit hook succeed.
`beforeContentRead(attachment)` must authorize and durably record the attempt before storage opens. `afterContentRead(attachment, bytesRead)` must record verified server-read completion before returning content. Both default to refusal for retained files until the lifecycle provider implements them. These facts are distinct from URL issuance, user download, printer spooling and physical output.
## Retryable intake
An upload overload adds `intakeKey` and `manifestSha256` after the ordinary upload arguments. Both are server-coordinator SHA-256 identifiers. The owner additionally hashes actual authenticated actor and canonical parent into the storage scope; never trust a client key as authority. The intake fingerprint includes the caller's manifest, filename, declared type, description, actual length and actual content hash. Every retry reads and checks its supplied bytes. A matching attachment is returned and its idempotent registration hook is retried; changed inputs conflict.
This path rejects ambient transactions. It conditionally creates a deterministic storage key with fingerprint metadata and never overwrites or deletes an uncertain original. If storage succeeded but metadata insertion failed, the next attempt verifies the existing bytes/fingerprint and completes registration. Host migration must add nullable `intake_key`/`intake_fingerprint` columns and the unique nonnull intake-key index from `CollaborationSchema`. That index is the database race boundary; sequential memory fixtures do not prove its concurrency behavior. The calling coordinator still owns durable request reservation, lifecycle and reconciliation scheduling.
Retryable files are bounded to 20 MiB and use a conditional single-object PUT. The inspected object-storage multipart implementation does not carry conditional-create semantics through completion; it is deliberately not used here. Larger retryable originals need a separately qualified conditional multipart path before this bound changes. This is an advertised intake limit, not a claim of unlimited file sizes or storage immutability. Real storage conditional behavior, object version/lock and backup retention still require provider acceptance.
## Atomic display metadata auditing
`beforeMetadataChange` records an independent attempt. Before the update, `validateMetadataTransaction(attachment, transaction)` validates the actual transaction and audit backend; retained files default to denial. After the update, `afterMetadataChange(attachment, changes, transaction)` inserts the success event into that same transaction and also defaults to denial for retained files. The provider must not commit or close the supplied transaction. Reject no-op transactions before mutation; memory tests only establish callback behavior.
When no caller transaction exists, Collaboration opens, commits, rolls back and closes the owner transaction. If a caller supplied a transaction, that caller owns completion and must roll back on any propagated failure. Successful operation and its event become durable together on commit. Record Files must qualify this with a real supported JDBC backend; this generic owner SPI cannot prove provider durability.
## Durability boundaries and remaining integration
The callback is not an audit database. Before returning from `beforeAccess`, a mandatory audit provider must have durably committed its intent independently of a caller transaction that may later roll back. An event means URL issuance was requested, not completed download or physical printing. Storage signing can still fail after the intent is recorded. Signed URLs can be reused until expiration; each use is not observed by Collaboration. Storage access telemetry or a serving gateway is required for transfer events.
Retained originals are never deleted when metadata insertion fails; ordinary non-retained failed inserts retain legacy cleanup. A committed STORAGE_WRITE_ATTEMPT can identify an orphan even when no attachment row was inserted.
An object put and QQQ metadata insert are not an atomic transaction. If a surrounding transaction rolls back after object creation, reconciliation must recover an orphaned object. The retained-only journal integration below provides explicit registration recovery; it does not provide malware scanning, WORM storage protection, bucket lifecycle protection or backup verification. Those remain deployment requirements.
QQQ record-reference write validation issues an internal GetAction using global metadata. Attachment previews use the existing `QueryOrGetInputInterface.getShouldGenerateDisplayValues()` contract: visibility filtering always runs, while URL generation runs only when display values were requested. Core validation GETs default to false; the standard middleware's display GET/query executors explicitly request true. This avoids incidental signing during validation without removing validators or modifying QQQ core. Trusted callers requesting presentation URLs directly through GetAction/QueryAction must explicitly enable display values; `AttachmentsService.downloadUrl` remains an explicit authorized access operation.
## Aggregate visibility policy
The optional configuration-only `requiresPinnedAggregateParent()` hook defaults to false. Owners whose parent visibility relies on dynamic archive/project authorization opt in. AttachmentAggregateGuard preserves the normal collaboration permission checker and additionally requires Count/Aggregate inputs to contain one literal top-level AND equality for both `tableName` and `recordId`. It checks current native parent visibility before SQL; joins, set-operation filters, OR roots, missing and conflicting pins are rejected. It does not grant view access.
Opt-in table metadata hides generic TABLE_COUNT and QUERY_STATS capabilities in both instance and table metadata. The current frontend's use-filter-setup hook already skips totals when TABLE_COUNT is absent and represents them as unknown; record listings and the attachment panel remain available. Native CountAction still permits an authorized pinned count. Do not substitute zero for an unavailable total or issue an unpinned aggregate and rely on a post-query filter: aggregate SQL cannot use that filter. Ordinary policies preserve the existing count capability. Verification is pending the new focused guard suite.
## Retained upload journal integration (verification in progress)
Public attachment services always use JdbcUploadJournal for retained originals. Ordinary non-retained streaming behavior is unchanged. Apply UploadJournalTable.h2Script() as an owner migration before enabling this path: the immutable physical manifest includes private storage coordinates and lease tokens which deliberately are not QQQ metadata fields. Generating SQL from metadata alone is insufficient. Only shared file-H2 with actual WRITE_DELAY=0 is currently qualified; memory journals are explicitly injected test fixtures only.
Retained uploads are capped at20MiB and use conditional single-request creation. The journal commits the candidate, fences a lease, and records UNKNOWN before PUT. A conditional collision or uncertain response is accepted only after reading the bounded original and verifying actual length and SHA-256. Metadata insertion and journal REGISTERED checkpoint share one real owner transaction. The mandatory idempotent afterStored lifecycle callback runs after that commit; COMPLETE follows only after it succeeds. A failed callback leaves the same attachment id recoverable. Source archival refuses transfer while the owner checkpoint is incomplete.
Renewal occurs at synchronous stage boundaries. A storage call that outlives its lease cannot register under its expired fence; a newer recovery may inspect an existing matching original but never overwrite or delete it. No background task synthesizes or carries a browser session. Provider request deadlines and operational supervision remain host acceptance requirements.
The native Retained Upload Journal table exposes status to its original actor only, subject to current source visibility; after a completed archive transfer, visibility follows the exact attachment's current parent. Native totals are disabled and private-field predicate/order/unique-key/join probes are denied. The Recover retained upload action reauthorizes the original actor and current attach capability, verifies existing bytes and retries registration. It does not accept bytes, a locator, another actor or a coordinator scope. Coordinated intakeKey entries must retry through their original coordinator, preserving origin/version/provenance. The beforeRecovery(candidate) lifecycle hook is default-deny for retained originals and records a recovery read attempt separately from STORAGE_WRITE_ATTEMPT and user downloads.
Missing bytes or corrupt bytes remain unresolved, never timeout into cancellation or permission to delete possible evidence. A disabled/deleted original actor has no administrative impersonation escape; an explicitly audited trusted administrative reconciliation path remains to be designed. Existing attachment panels also need discoverable pending-upload recovery navigation; the standard table action alone is not a complete panel UX.
### Recovery navigation metadata
Retention owners can call public UploadRecoveryNavigation.configure(sourceTable) after producing source metadata. It adds ATTACHMENTS.recoveryView containing the native owner table, label and actor/status filter. The generic Files panel adds literal canonical tableName/recordId AND criteria and navigates to the standard QQQ table and its recovery action. CurrentUser expressions are accepted only for createdByUserId/EQUALS with one value. Filters aid discovery and never replace original-actor/current-parent authorization. Missing or malformed metadata hides this optional navigation on ordinary installations. An interrupted-upload message does not claim saved/completed bytes; recovery still verifies the owner journal and exact object.
-45
View File
@@ -1,45 +0,0 @@
# Standard attachment list pagination
The standard `GET /records/{table}/{pk}/attachments` endpoint now applies its
page offset and limit inside the native QQQ attachment query. It reads at most
`limit + 1` rows (maximum 101), projects the requested page, and uses the extra
authorized row only to determine whether to emit the next cursor. It performs no
count query and does not load the complete attachment list before slicing it.
The route, response schema, default limit 20, allowed limits 1–100, and existing
version-1 scoped offset cursor bytes are unchanged. Sorting is still descending
`createDate`, then descending `id`; equal timestamps therefore retain a stable tie
order. These are offset pages, not snapshots: concurrent insertion or deletion can
shift offsets. High offsets can require backend scanning; bounded returned rows
and host allocation are not a constant-time database performance guarantee.
Each request still touches the current person, resolves and authorizes the actual
source record, canonicalizes its primary key, excludes deleted-parent attachments,
and uses the existing per-entry edit/delete policy. The same entry projection is
evaluated for the lookahead row. There is no generic post-filter refill or inference
from a denied row. Sources transferred to retained archive records use the exact
current parent identity and its native authorization policy. This owner artifact
does not import Record Files or reconstruct its archive policy.
Metadata visibility is separate from content release. Listing retained or
quarantined metadata does not issue a download, preview or print capability; those
paths retain their lifecycle and current authorization checks. The cursor is a
routing/offset hint, not a signed authorization token. Current source access is
required even when the caller presents a previously valid cursor.
`AttachmentsService.listPage` is the bounded public service operation. The older
unpaged `list` method remains for compatibility; new page consumers should use
`listPage`. Cursor parsing/encoding is shared with the existing complete-list
pagination helper, so other Collaboration endpoints keep their previous behavior.
Verification: local focused run `target/attachment-pagination-focused.log` passed
40 tests on 2026-10-31 at 02:01:44, with zero failures/errors/skips and Checkstyle.
Two new tests execute the actual QQQ RDBMS action against H2 and observe native
offset/limit and returned-row bounds, equal timestamp ordering, deleted/other
source exclusion, retained flags, terminal pages and current revocation before
attachment SQL. The other 38 tests cover existing attachment service behavior and
the unchanged OpenAPI fixture. Root HTTP run47446 also passed all12 tests, including
the new regression covering three pages,
cursor scope, malformed/oversized input and a denied actor. Cross-qbit archive integration, full release verification and installation
of this new owner snapshot remain separate gates. No installed or frozen consumer
artifact has been replaced by this change.
-37
View File
@@ -1,37 +0,0 @@
# Attachment cleanup recovery
Deletion writes a standard `collaboration_attachment_cleanup` row and removes attachment metadata in the same backend transaction. Only after commit does it delete the stored object, then acknowledge the cleanup row. PostgreSQL rejection of metadata deletion leaves both the original row and bytes intact. A provider failure or lost callback leaves the cleanup locator available after restart. The service returns a failure when immediate cleanup fails; the logical attachment deletion may already have committed.
The cleanup table is visible in the Collaboration app to administrators. Direct table mutations are denied, including to administrators, so callers cannot forge deletion targets. Recovery runs through `collaborationRetryAttachmentCleanup`, guarded for system sessions or `collaboration.admin`. With the host's configured scheduler, it retries a bounded batch of 100 every minute. Kof22 already supplies a scheduler name. Without a scheduler, operators must invoke the recovery process; it is not safe to assume background retries exist.
Apply the generated PostgreSQL schema before enabling this version; it creates the additional cleanup table and due-time index. Attachment ID is optional because rejected uploads may never have a committed ID. The upgrade preserves existing cleanup rows and removes the earlier attachment-ID uniqueness constraint. Do not remove that table during application rollback while cleanup remains pending. Existing releases do not know how to drain it.
Verification covers real PostgreSQL metadata rejection, outer upload/delete rollback, commit, provider failure followed by a new recovery service, loss of post-commit callbacks, failed queue acknowledgement and repeat replay. Deterministic storage faults also cover a provider that writes bytes before reporting upload failure and two concurrent cleanup workers. A real MinIO fixture, accessed through the published object-storage QBit, verifies deletion replay in plain and versioned buckets while preserving another object's exact bytes. This is not evidence for every object-storage provider. The separate abrupt-JVM test described below uses fixture file storage.
Failed upload rollback compensation now records its locator independently of the rolled-back transaction before attempting storage cleanup. Failed worker attempts persist a next-attempt time at least 60 seconds later; this lets healthy requests beyond a failing batch proceed. Tests verify later recovery once the failed requests become due.
Upload I/O and runtime failures attempt durable compensation and preserve the original exception, adding cleanup failure as a suppressed exception. Deferred provider completion is covered by the settlement protocol below.
Full Maven verification including operator settlement passes 206 tests, zero failures, errors or skips, plus packaging, help coverage, architecture guardrails and JaCoCo checks. PostgreSQL tests cover the retained receipt, repeated requests, active owners, existing references, rejected receipt writes, provider failure and the real form/Review/Result flow with permission revocation. Coordinated host adoption and staging rollback still require separate verification.
On backends supporting transactional FOR UPDATE SKIP LOCKED (including the PostgreSQL host), upload now commits a cleanup locator before calling storage and holds its row lock for the upload transaction. Metadata insertion and removal of that locator share the same transaction. Recovery workers skip active uploads and hold the same lock while deleting abandoned bytes and acknowledging cleanup. An existing caller transaction retains ownership until it ends; otherwise the service starts its own transaction after spooling the input. Backends without this locking capability retain callback-based compensation and are not covered by this ownership guarantee.
Focused PostgreSQL tests verify the locator exists before provider I/O, cleanup skips an active upload, and recovery succeeds after an interrupted upload whose rollback callbacks were deliberately suppressed. That test simulates callback loss. A separate child JVM executes the actual upload service against the disposable PostgreSQL database, persists fixture bytes, then calls Runtime.halt before returning from the provider. The parent verifies exit code 73, absent metadata, retained intent and bytes, and successful idempotent recovery using the original locator. This proves abrupt JVM termination recovery, not power-loss durability or late remote-provider completion. Another test rejects intent insertion in PostgreSQL and verifies zero provider writes or deletes; a completed upload in an open outer transaction remains protected from cleanup until commit. Concurrent workers verify that only one worker enters provider deletion for a locator.
Issue #1289 remains open for coordinated host adoption and application rollback acceptance. Row locking does not make external object storage and the database one atomic transaction.
## Unreleased late-upload settlement change
A PostgreSQL regression reproduced a PUT finishing after timeout rollback had deleted the still-empty location and acknowledged its cleanup row. Upload intents (no attachment ID) now use AttachmentStorage.cleanupUpload, which removes current bytes and reports whether an earlier PUT can still complete. The generic adapter cannot prove settlement and returns false. Recovery retains the locator for later sweeps, with the existing 60-second backoff and 100-row batch. Ordinary committed deletion and successful upload acknowledgement remain unchanged.
Adapters may report settlement only from authoritative completion/cancellation information, never merely object absence. A deterministic deferred-write fixture verifies the late-completion ordering; this does not establish remote S3 settlement. Generic uncertain-upload records remain pending until the provider proves settlement or an administrator performs the explicit operation below. There is no age-based expiry.
## Resolve a settled upload
In the Attachment Cleanup table, select upload intents and run **Resolve settled uploads**. An identified user with `collaboration.admin` must supply provider completion or cancellation evidence covering every selected locator, then confirm the standard Review screen. A support case or request receipt establishing completion is suitable; object absence, an elapsed timeout, or an application process stopping does not establish that an external provider has stopped writing. If settlement cannot be established, leave the intent pending.
Apply rechecks permission, locks each row, rejects active ownership and existing attachment references, and deletes current bytes before saving completion. Provider or database failures leave the intent unresolved for retry. Committed attachment deletions use the ordinary cleanup retry operation instead.
The service-managed row retains `resolvedAt`, `resolvedBy` and `settlementEvidence`, together with the original locator. Repeated settlement does not overwrite that receipt; scheduled retries exclude resolved rows. Direct edits and deletes remain denied even to administrators. These fields provide the durable operational receipt without relying on the framework's best-effort audit writer. Store a reference to provider evidence, never credentials or private object contents.
Apply the additive settlement columns and pending-row index before this code runs. Kof22's corresponding migration is `2026-10-26-collaboration-upload-settlement.sql`. Retain receipts and pending intents during rollback. Do not start an earlier cleanup worker that ignores `resolvedAt`: it could consume retained receipts. Roll back to the pre-cleanup application with cleanup processing disabled, or use a version that understands settlement receipts.
-37
View File
@@ -1,37 +0,0 @@
# Core dependency: after-rollback callbacks
Collaboration's retained-upload and cleanup paths compensate external object storage only after a transaction's
rollback is confirmed. They need two methods on `com.kingsrook.qqq.backend.core.actions.QBackendTransaction` that the
released core `4.1.0-RC.21` does not declare. This module does not compile against RC.21 until a core release that has them
is available. This branch now defaults to `4.1.0-SNAPSHOT`; the verified source is Core commit `551397c7c59b0f77833fc81e195f4c01cb1c282f`. Build/install that Core branch first for local development. No compatibility shim is provided (a shim could not confirm rollback and would weaken the guarantee).
## Required API (reviewed implementation: `b6fb2382`, `third-party-src/qqq-core`)
`qqq-backend-core`, class `QBackendTransaction`:
```java
public boolean supportsRollbackCallbacks() // default: false
public void addAfterRollbackCallback(Runnable callback) // throws UnsupportedOperationException unless supportsRollbackCallbacks(); Objects.requireNonNull(callback)
protected void runAfterRollbackCallbacks() // run once, in order, then clear; exceptions (incl. LinkageError) logged and swallowed
```
Pending rollback callbacks are preserved when a required operation marks the transaction rollback-only, and run only after confirmed JDBC rollback. A failed physical commit clears them because its outcome is uncertain. Successful commit discards them.
`qqq-backend-module-rdbms`, class `RDBMSTransaction`:
```java
@Override public boolean supportsRollbackCallbacks() // true
@Override public void addAfterRollbackCallback(Runnable callback) // super.add...; then rolledBackSinceCommit = false
// rollback()/close(): after connection.rollback() succeeds -> rolledBackSinceCommit = true; runAfterRollbackCallbacks()
// close() rolls back (and so runs callbacks) unless a rollback already happened since the last commit / callback registration
```
## Callers in this module
- `attachments/AttachmentsService.storeUpload`: refuses an ambient transaction where `!supportsRollbackCallbacks()`; registers storage compensation.
- `attachments/AttachmentCleanupQueue.beginUpload`: registers the same cleanup on both `addAfterRollbackCallback` and `addAfterCommitCallback`.
## Core tests to carry with the change
- Base class: `supportsRollbackCallbacks()` is false; `addAfterRollbackCallback` throws `UnsupportedOperationException`.
- RDBMS: callback runs after `rollback()`; does not run after `commit()`; runs when a transaction with a registered callback is closed without commit;
a callback registered after an earlier rollback still runs on close; a throwing callback does not stop later callbacks.
- Integration (already in this module, needs the core): `attachments/AttachmentTransactionsPostgresTest` (needs Docker) incl. `interruptedUploadSurvivesLostRollbackCallback`.
-9
View File
@@ -1,9 +0,0 @@
# QBit integration known issues
Updated: 2026-11-04.
- This branch requires the Core integration snapshot described in [CORE-DEPENDENCY.md](CORE-DEPENDENCY.md). Public Core RC.21 lacks rollback callbacks; public Collaboration RC.6 lacks the retained-upload SPI. A compatible published dependency set remains a release requirement.
- Local owner verification is complete: 269 tests and style/coverage gates pass, including both journal providers. Deployment-specific object storage, database failover, backup/restore and retention policies still require host qualification.
- Docker must be running for attachment transaction tests. Enable both `mysqlJournalTests` and `postgresJournalTests` for the additional journal-provider cases. On Windows use `-Dexec.skip=true` for the inherited POSIX console-summary command; all verification gates still run.
- Redis presence and unrelated private-cohort features were intentionally excluded. This attachment integration has no compile-time dependency on Record Files, Scripting or Document Generation.
- No release tag, public artifact publication or deployment is claimed by this branch. The matching Next UI and host configuration must be verified before rollout.
-27
View File
@@ -1,27 +0,0 @@
# Collaboration attachment integration status
Updated: 2026-11-04. Implementation and local owner verification complete; this feature branch is not a published release.
## What changed
The attachment lifecycle, retained upload journal, recovery and cleanup APIs are preserved on canonical Collaboration. The default Core dependency is now the compatible `4.1.0-SNAPSHOT` from Core commit `551397c7c59b0f77833fc81e195f4c01cb1c282f` (see [setup](CORE-DEPENDENCY.md)). Recovery queries now handle tables without supplemental metadata instead of throwing a null-pointer exception.
## Verification
Using Java 21, the Maven wrapper, Docker and that locally installed Core snapshot:
```powershell
.\mvnw.cmd '-DmysqlJournalTests=true' '-DpostgresJournalTests=true' '-Dexec.skip=true' install
```
All **269 tests passed**, with zero failures, errors or skips. Checkstyle and the JaCoCo coverage gates passed. This includes 29 PostgreSQL attachment transaction/recovery cases, 11 MySQL journal cases, 12 PostgreSQL journal cases, HTTP API tests and dependency-boundary guardrails. Each provider fixture creates and removes its own disposable services; no existing database is used. The first run exposed the fixed null handling and one transient Windows socket allocation failure; the complete corrected run passed.
`-Dexec.skip=true` avoids the inherited POSIX console-summary command on Windows; it does not skip tests, Checkstyle or coverage. The artifact was installed into an isolated local Maven repository as `com.kingsrook.qbits:qbit-collaboration:0.1.0-SNAPSHOT`.
## Downstream consumer verification
Record Files `develop` at `162af9a7b8cd9c2673c26c88f00323cd90e85e48` compiled and passed its default `verify` lifecycle against this locally installed Collaboration snapshot and Core `4.1.0-SNAPSHOT` using `-Dqqq.version=4.1.0-SNAPSHOT`. Surefire reported 278 tests: 220 executed successfully and 58 optional MySQL/PostgreSQL cases skipped. Style/coverage gates passed. This proves consumer API compatibility and the exercised HTTP, retention and recovery paths; it does not claim the skipped Record Files provider qualification or its full release.
## Release boundary
Core and Collaboration must be published as a compatible dependency set before adoption from public Maven coordinates. Passing local fixtures does not qualify a deployment's storage, backup/restore or database failover. See [known issues and remaining release requirements](QBIT-KNOWN-ISSUES.md).
-49
View File
@@ -1,49 +0,0 @@
# Retained upload journal
The Collaboration-owned journal is wired into retained `AttachmentsService` upload and recovery. It persists candidate metadata before object creation, owns the attachment/checkpoint transaction, and exposes native read-only recovery navigation. It has no Record Files dependency and stores no second copy of file bytes.
Historical private-cohort evidence (superseded by [current verification](QBIT-RELEASE-STATUS.md)): the integrated owner snapshot passed all **214 tests**, including 10 real MySQL provider cases and 11 H2 journal regressions, and was installed under `0.1.0-record-files-SNAPSHOT`. The subsequent doublewrite/session-read-only delta compiles and awaits its provider rerun. This document does not qualify Aurora, other SQL vendors, external object storage, disks, backups, or a host deployment.
## Ownership and recovery
1. `prepare` independently commits the immutable server-built candidate before the first PUT. Matching retries reuse it; changed manifests conflict.
2. `reserve` locks the row and grants a bounded lease with a monotonically increasing fence. `renew` extends only a still-live exact reservation at synchronous stage boundaries.
3. `recordStorage(UNKNOWN)` precedes conditional object creation. `VERIFIED` means the storage owner proved the exact length and SHA-256.
4. `register` passes its real transaction to the attachment registrar. The attachment insert and REGISTERED checkpoint commit together; callback failure rolls both back.
5. The retained lifecycle registration runs after commit and is retried idempotently. The journal becomes COMPLETE only after it succeeds. Completed coordinated retries still invoke required lifecycle registration without adding a second attachment or activity event.
6. Recovery reauthorizes the original actor and current source. It verifies the stored original without overwriting it and resumes registration. Missing or unreadable bytes remain uncertain; timeout never proves absence.
The owner rejects ambient/no-op transactions. SQL values are bound; mutable operations lock rows, check the token/fence/expiry, and require exactly one guarded update. Reads reconstruct manifest hashes. Private locator and lease columns are absent from QQQ metadata and forbidden in native filters/projections; native direct writes and unscoped totals are denied. Current actor/source authorization remains in the owner service and native journal security, not in the low-level JDBC port.
Disabled or deleted original actors still need an explicitly designed administrative reconciliation flow. No fabricated session or broad archive permission substitutes for that flow. Broad post-filtered listings can contain short pages; source-pinned native recovery navigation is the supported user entry point.
## Database boundaries
Both owner tables must use the same QQQ backend. Hosts apply DDL through their migrations; runtime code never creates tables or changes server-global settings.
| Backend | Runtime requirements | Schema |
| --- | --- | --- |
| File H2 | Native `h2`, `jdbc:h2:file:`, actual configured connection, writable real transaction, live `WRITE_DELAY=0` | `UploadJournalTable.h2Script()` |
| MySQL 8.4 | Native `mysql`, actual MySQL 8.4 product/version, exact configured URL target and catalog, writable real transaction, both owner tables InnoDB, fixed owner table mappings, journal `utf8mb4_0900_bin` columns/table, single-column unique locator/intake indexes, synchronous commit settings | `UploadJournalTable.mysqlScript()` |
| PostgreSQL 17 | Native `postgres` backend, configured database, permanent owner tables, deterministic comparison, READ COMMITTED, UTC, 1–5000 ms lock timeout, synchronous commits with fsync/full_page_writes enabled | `UploadJournalTable.postgresScript()` |
| Aurora MySQL, MariaDB, other versions/providers | Refused; no inferred compatibility or durability claim | None |
The MySQL adapter resolves explicit or native-generated URLs through QQQ `ConnectionManager`. It accepts a single ordinary `jdbc:mysql://host:port/catalog` target with a simple catalog identifier. Proxy/load-balancing URLs, alternate catalogs or table remapping fail closed. Error messages do not echo URLs or credentials. Driver options remain host-owned; TLS and credentials are not changed by the journal.
Each MySQL operation checks `innodb_flush_log_at_trx_commit=1`, and `sync_binlog=1` when binary logging is enabled; a read-only server or session is refused. It also requires `innodb_doublewrite` to be `ON` or `DETECT_AND_RECOVER`; [detect-only mode does not retain recoverable page copies](https://dev.mysql.com/doc/refman/8.4/en/innodb-doublewrite-buffer.html). These settings follow the [MySQL 8.4 InnoDB durability documentation](https://dev.mysql.com/doc/refman/8.4/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit) and [binary-log durability documentation](https://dev.mysql.com/doc/refman/8.4/en/replication-options-binary-log.html#sysvar_sync_binlog). Hardware that misreports flush completion can still lose data; replication/failover, backups, restore and disk guarantees remain deployment qualifications. Runtime settings are checked at transaction entry; an administrator changing server settings concurrently is outside the application trust boundary.
The MySQL journal uses millisecond timestamps with explicit UTC JDBC calendars, InnoDB, enforced CHECK constraints and binary NO PAD string comparisons. Its live session must report UTC (+00:00/UTC, or SYSTEM with a UTC system zone); the journal does not silently change a pooled session timezone. Hosts should configure the native JDBC driver timezone consistently for generic QQQ date fields as well. The host must configure each journal connection with `innodb_lock_wait_timeout` between 1 and 5 seconds, through its backend JDBC sessionVariables/pool setup (or a dedicated database default). Qualification reads and preserves that session setting; it never changes it or other pooled-session/global settings. Each statement also has a ten-second query timeout. Duplicate error 1062 is distinguished from unrelated integrity failures; deadlock/lock-timeout failures remain typed BUSY responses, never fabricated success.
## Reproducible tests
Use Java 21, the Maven wrapper and the matching Core integration snapshot. Docker is required by the attachment transaction tests. Enable both additional journal-provider fixtures for complete verification:
```powershell
.\mvnw.cmd '-DmysqlJournalTests=true' '-DpostgresJournalTests=true' '-Dexec.skip=true' verify
```
`MySqlUploadJournalTest` creates only its own Testcontainers MySQL 8.4.13 container, random synthetic credentials and private anonymous data volume, and destroys that container on completion. It does not use existing host services. The image is pinned to `mysql@sha256:a0225d1ea6631c8bc2385dc4c2faba17bbf1834ecf369e1659a9a382bd8d5dd7`. Credentials are not printed by the fixture.
The eleven provider cases cover acknowledged prepare surviving a real database process restart, attachment/checkpoint rollback and completion replay, simultaneous prepare/lease fencing, wrong backend/catalog/mapping/ambient/Aurora refusal, actual non-durable server settings and MyISAM refusal, binary identifier comparisons and native write denial, actual lock-timeout/BUSY behavior, column/index drift and immutable conflict, and lease instants across differing JVM default zones, preservation/refusal of host session lock-timeout settings, and refusal of detect-only doublewrite/read-only sessions without changing their settings. The attachment schema in this suite is deliberately a minimal transaction fixture; this is not an end-to-end MySQL object-storage or tenant/archive qualification. Record Files owns its separate provider integration tests.
First provider run compiled but failed its restart fixture because Docker reassigned the dynamic port and Testcontainers retained a cached mapping; two subsequent setup errors followed that stale endpoint. The fixture now refreshes the inspected binding after restart and uses bounded JDBC readiness checks. Docker wire logs are suppressed before container creation. Expanded snapshot verification: **20 tests passed (9 MySQL, 11 H2), zero failures/errors/skips**, `target/mysql-journal-focused-verification.log`, completed 2026-10-31 00:01:44. The tenth provider case and full 214-test owner quality gates subsequently passed; the corrected development-GAV artifact SHA-256 is `72D6E36E77B92BE3ECFE9BE7042D292C76B308AA05A9E43BD490B229BD1A75E2`. Fresh verification on 2026-11-04 passed all 269 tests, including all 11 MySQL journal cases, 12 PostgreSQL journal cases and 29 PostgreSQL attachment transaction cases. Checkstyle and coverage gates passed against Core commit `551397c7c59b0f77833fc81e195f4c01cb1c282f`; no provider cases were skipped.
+2 -51
View File
@@ -110,47 +110,6 @@
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<!-- upload journal and attachment transaction suites: real JDBC (H2, MySQL and PostgreSQL 17 containers) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.4.240</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-mysql</artifactId>
<version>2.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.8</version>
<scope>test</scope>
</dependency>
<!-- native PostgreSQL module (vendor/type "postgres"), version managed by the qqq BOM -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
</exclusion>
<exclusion>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
@@ -174,14 +133,6 @@
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId><artifactId>maven-resources-plugin</artifactId><version>3.3.1</version>
<executions><execution><id>package-license-notices</id><phase>process-resources</phase><goals><goal>copy-resources</goal></goals>
<configuration><outputDirectory>${project.build.outputDirectory}/META-INF</outputDirectory><resources><resource>
<directory>${project.basedir}</directory><filtering>false</filtering><includes><include>LICENSE</include><include>NOTICE</include></includes>
</resource></resources></configuration>
</execution></executions>
</plugin>
<!-- the artifact version, for the help bundle version test -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -274,14 +225,14 @@
<profiles>
<!-- Built against qqq 4.1 (QBitMetaDataProducer). Until qbit-build-parent -->
<!-- imports the released 4.1 BOM, this profile is active by default and -->
<!-- imports the Core integration snapshot with confirmed rollback callbacks. -->
<!-- imports qqq-bom-pom 4.1.0-RC.15 ahead of the parent's 4.0.0 BOM. -->
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-SNAPSHOT</qqq.snapshot.version>
<qqq.snapshot.version>4.1.0-RC.15</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
@@ -64,7 +64,6 @@ public class CollaborationQBitConfig implements QBitConfig
private Boolean adminApp = true;
private Boolean uiApi = true;
private QCodeReference attachmentStorage = new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentStorage.class);
private QCodeReference attachmentLifecycle = new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentLifecyclePolicy.class);
private Integer recentsPerUser = 200;
private String attachmentsBucketName = "collaboration";
private Long defaultMaxBytes = 100L * 1024 * 1024;
@@ -104,23 +103,6 @@ public class CollaborationQBitConfig implements QBitConfig
return (this);
}
/*******************************************************************************
** Trusted host policy for retained attachments and access auditing.
*******************************************************************************/
public QCodeReference getAttachmentLifecycle()
{
return (attachmentLifecycle);
}
/*******************************************************************************
** Configure lifecycle enforcement without coupling to a consuming qbit.
*******************************************************************************/
public CollaborationQBitConfig withAttachmentLifecycle(QCodeReference attachmentLifecycle)
{
this.attachmentLifecycle = java.util.Objects.requireNonNull(attachmentLifecycle, "attachmentLifecycle");
return (this);
}
/*******************************************************************************
@@ -183,10 +183,6 @@ public class CollaborationQBitProducer implements QBitMetaDataProducer<Collabora
com.kingsrook.qbits.collaboration.tags.TagProcesses.processesFor(table).forEach(output::add);
}
output.add(DeleteAttachmentsProcess.metadata());
output.add(com.kingsrook.qbits.collaboration.attachments.RetryAttachmentCleanupProcess.metadata(qBitConfig));
output.add(com.kingsrook.qbits.collaboration.attachments.ResolveAttachmentUploadProcess.metadata());
output.add(com.kingsrook.qbits.collaboration.attachments.RecoverRetainedUploadProcess.metadata());
output.add(com.kingsrook.qbits.collaboration.attachments.AdoptRetainedAttachmentProcess.metadata());
output.add(FeatureSettingsDefaultsProcess.process());
for(QPossibleValueSource source : CollaborationTables.possibleValueSources())
{
@@ -143,10 +143,7 @@ public final class CollaborationRecords
public static QTableMetaData cleanupMetadata(String table)
{
QTableMetaData metadata = serviceMetadata(table);
if(metadata.getSupplementalMetaData() != null)
{
metadata.getSupplementalMetaData().remove(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.NAME);
}
metadata.getSupplementalMetaData().remove(com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordReferenceTableMetaData.NAME);
metadata.setRecordSecurityLocks(null);
return (metadata);
}
@@ -57,7 +57,7 @@ public final class CollaborationSchema
for(QFieldMetaData field : table.getFields().values())
{
String type = field.getName().equals(table.getPrimaryKeyField()) ? "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY" : sqlType(field);
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") || field.getName().equals("retained") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
columns.add(" " + field.getBackendName() + " " + type + (field.getName().equals("parentDeleted") ? " DEFAULT FALSE" : "") + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
}
statements.add("CREATE TABLE IF NOT EXISTS " + table.getName() + " (\n" + String.join(",\n", columns) + "\n)");
for(QFieldMetaData field : table.getFields().values())
@@ -78,10 +78,6 @@ public final class CollaborationSchema
}
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_record_tag_tag ON collaboration_record_tag (tag_id)");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_parent ON collaboration_attachment (table_name, record_id)");
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE");
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS intake_key VARCHAR(64)");
statements.add("ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS intake_fingerprint VARCHAR(64)");
statements.add("CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_attachment_intake ON collaboration_attachment (intake_key) WHERE intake_key IS NOT NULL");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_parent ON collaboration_comment (table_name, record_id, last_activity_at, id)");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_created ON collaboration_comment (table_name, record_id, create_date)");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_comment_thread ON collaboration_comment (thread_id, create_date, id)");
@@ -99,15 +95,6 @@ public final class CollaborationSchema
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_owner ON collaboration_record_assignment (owner_user_id)");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_assignment_assignee ON collaboration_record_assignment (assignee_user_id)");
statements.add("ALTER TABLE collaboration_mention ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ");
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS next_attempt_at TIMESTAMPTZ");
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ");
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS resolved_by VARCHAR(255)");
statements.add("ALTER TABLE collaboration_attachment_cleanup ADD COLUMN IF NOT EXISTS settlement_evidence VARCHAR(2000)");
statements.add("ALTER TABLE collaboration_attachment_cleanup ALTER COLUMN attachment_id DROP NOT NULL");
statements.add("DROP INDEX IF EXISTS uq_collaboration_attachment_cleanup_1");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_storage_key ON collaboration_attachment USING HASH (storage_key)");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_cleanup_due ON collaboration_attachment_cleanup (next_attempt_at, id)");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_attachment_cleanup_pending ON collaboration_attachment_cleanup (next_attempt_at, id) WHERE resolved_at IS NULL");
statements.add("CREATE INDEX IF NOT EXISTS idx_collaboration_mention_unread ON collaboration_mention (mentioned_user_id) WHERE notified_at IS NOT NULL AND read_at IS NULL");
return (String.join(";\n", statements) + ";\n");
}
@@ -56,7 +56,6 @@ public class CollaborationSummaryProvider implements RecordFeatureSummaryProvide
entry.put("displayName", viewer.displayName());
entry.put("mode", viewer.mode());
entry.put("fieldName", viewer.fieldName());
entry.put("connections", viewer.connections());
return (entry);
}).toList());
}
@@ -52,7 +52,7 @@ public final class CollaborationApi
*******************************************************************************/
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.2.0")
return (new MiddlewareSpecExtension().withName("collaboration").withSemanticVersion("1.1.0")
.withDescription("Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence")
.withSpec(PostPresenceSpec.class).withSpec(DeletePresenceSpec.class).withSpec(GetPresenceSpec.class)
.withSpec(ListFavoritesSpec.class)
@@ -161,14 +161,13 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
case "cursor" -> "Opaque nextCursor returned by the previous page; omit for the first page.";
case "before" -> "Timeline continuation token; alias for cursor. Supply only one.";
case "includeDone" -> "Include completed and cancelled tasks; default false.";
case "perConnection" -> "Also return one viewer per live connection (lease) in leases; default false. Since collaboration 1.2.0.";
case "status" -> "Task status: OPEN (default), DONE or CANCELLED.";
case "types" -> "Comma-separated AUDIT, ACTIVITY or activity type names (for example COMMENTED).";
case "q" -> "Text to search in display names or vocabulary.";
default -> name;
};
return (new Parameter().withName(name).withIn(location).withRequired(required).withDescription(description)
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") || name.equals("perConnection") ? Type.BOOLEAN : Type.STRING)));
.withSchema(new Schema().withType(name.equals("limit") ? Type.INTEGER : name.equals("includeDone") ? Type.BOOLEAN : Type.STRING)));
}
/*******************************************************************************
@@ -214,19 +213,6 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
protected static <T> Page<T> page(JsonInput input, List<T> values) throws QBadRequestException
{
int limit = limit(input);
PagePosition position = pagePosition(input);
int start = Math.min(position.offset(), values.size());
int end = start + Math.min(limit, values.size() - start);
String next = end < values.size() ? position.cursor(end) : null;
return (new Page<>(values.subList(start, end), next));
}
/*******************************************************************************
** Shared existing offset cursor contract, also used by bounded native lists.
** Scope is a routing check, never authorization or a signed capability.
*******************************************************************************/
protected static PagePosition pagePosition(JsonInput input) throws QBadRequestException
{
int offset = 0;
String scope = input.getSpec().defineBasicOperation().getPath() + input.getPathParams().toString()
+ ":" + input.getQueryParam("table") + ":" + input.getQueryParam("pk") + ":" + input.getQueryParam("q");
@@ -252,25 +238,10 @@ abstract class CollaborationJsonSpec extends AbstractJsonEndpointSpec
throw (new QBadRequestException("Invalid cursor"));
}
}
return (new PagePosition(offset, prefix));
}
/*******************************************************************************
** Same version, scope and offset bytes as previously emitted by page().
*******************************************************************************/
protected record PagePosition(int offset, String prefix)
{
/*******************************************************************************
** Encode only a representable nonnegative continuation offset.
*******************************************************************************/
String cursor(long next) throws QBadRequestException
{
if(next < 0 || next > Integer.MAX_VALUE)
{
throw (new QBadRequestException("Invalid cursor"));
}
return (Base64.getUrlEncoder().withoutPadding().encodeToString((prefix + next).getBytes(StandardCharsets.UTF_8)));
}
int start = Math.min(offset, values.size());
int end = start + Math.min(limit, values.size() - start);
String next = end < values.size() ? Base64.getUrlEncoder().withoutPadding().encodeToString((prefix + end).getBytes(StandardCharsets.UTF_8)) : null;
return (new Page<>(values.subList(start, end), next));
}
/*******************************************************************************
@@ -24,7 +24,6 @@ package com.kingsrook.qbits.collaboration.api;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qqq.middleware.javalin.schemabuilder.ToSchema;
import com.kingsrook.qqq.openapi.model.Schema;
@@ -81,22 +80,10 @@ public final class CollaborationPresenceSchemas
}
/*******************************************************************************
** The snapshot is also the data shape of presence.changed events. Viewers are
** included even when empty, whatever inclusion the serializer defaults to.
** The snapshot is also the data shape of presence.changed events.
*******************************************************************************/
public record CollaborationPresenceSnapshotV1(String table, String pk, @JsonInclude(JsonInclude.Include.ALWAYS) List<PresenceRegistry.Viewer> viewers,
Integer leaseCount, @JsonInclude(JsonInclude.Include.NON_NULL) List<PresenceRegistry.Viewer> leases) implements ToSchema
public record CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers) implements ToSchema
{
/*******************************************************************************
** The merged snapshot without per-connection detail.
*******************************************************************************/
public CollaborationPresenceSnapshotV1(String table, String pk, List<PresenceRegistry.Viewer> viewers)
{
this(table, pk, viewers, null, null);
}
/*******************************************************************************
** A table snapshot deliberately excludes record ids and focused field names.
*******************************************************************************/
@@ -106,17 +93,14 @@ public final class CollaborationPresenceSchemas
return (new Schema().withType(Type.OBJECT).withDescription("Live viewers, merged across tabs; presence.changed uses this payload.")
.withProperties(new TreeMap<>(Map.of(
"table", text("Parent table name.", false), "pk", text("Record key; absent for table events.", true),
"viewers", new Schema().withType(Type.ARRAY).withDescription("Live viewers; always present, empty once the last viewer leaves.").withNullable(false).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1")),
"leaseCount", new Schema().withType(Type.INTEGER).withDescription("Live connections (leases) behind viewers; a user with two terminals counts twice. Since 1.2.0.").withNullable(true),
"leases", new Schema().withType(Type.ARRAY).withDescription("One viewer per live connection, with connectionId and since. On GET only with perConnection=true; always on presence.changed. Since 1.2.0.")
.withNullable(true).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
"viewers", new Schema().withType(Type.ARRAY).withItems(new Schema().withRefToSchema("CollaborationPresenceViewerV1"))))));
}
}
/*******************************************************************************
** Only public identity and focus are carried, never field contents.
*******************************************************************************/
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName, Integer connections, String connectionId, String sessionId, String since) implements ToSchema
public record CollaborationPresenceViewerV1(String userId, String displayName, String mode, String fieldName) implements ToSchema
{
/*******************************************************************************
** Editing takes precedence when one user has several open tabs.
@@ -124,14 +108,10 @@ public final class CollaborationPresenceSchemas
@Override
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs (EDITING takes precedence), or one live connection in leases.")
return (new Schema().withType(Type.OBJECT).withDescription("One user merged across live tabs; EDITING takes precedence.")
.withProperties(new TreeMap<>(Map.of(
"userId", text("Stable user identity.", false), "displayName", text("Session display name.", false),
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true),
"connections", new Schema().withType(Type.INTEGER).withDescription("Live connections (tabs, terminals) merged into this viewer; 1 in leases. Since 1.2.0.").withNullable(true),
"connectionId", text("Opaque per-connection id, only in leases: c + the first 16 hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0.", true),
"sessionId", text("Opaque id of the signed-in session behind this connection, only in leases (HMAC of the server session; never the session itself). Since 1.2.0.", true),
"since", text("When this connection's lease started (ISO-8601), only in leases. Since 1.2.0.", true).withFormat("date-time")))));
"mode", text("VIEWING or EDITING.", false).withEnumValues(List.of("VIEWING", "EDITING")), "fieldName", text("Focused field; null on table snapshots.", true)))));
}
}
@@ -70,17 +70,8 @@ public final class CollaborationSchemas
/*******************************************************************************
** An attachment visible through its parent record; no storage coordinates are exposed.
*******************************************************************************/
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName, Boolean canEdit) implements ToSchema
public record CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType, Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName) implements ToSchema
{
/*******************************************************************************
** Preserve construction compatibility with the earlier attachment body.
*******************************************************************************/
public CollaborationAttachmentV1(Integer id, String tableName, String recordId, String fileName, String contentType,
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, String createdByDisplayName)
{
this(id, tableName, recordId, fileName, contentType, sizeBytes, sha256, description, createdByUserId, createDate, canDelete, createdByDisplayName, canDelete);
}
/*******************************************************************************
** Describe this versioned body for generated clients.
*******************************************************************************/
@@ -98,8 +89,7 @@ public final class CollaborationSchemas
Map.entry("description", new Schema().withType(Type.STRING).withDescription("Optional description.").withNullable(true)),
Map.entry("createdByUserId", new Schema().withType(Type.STRING).withDescription("Uploader identity.").withNullable(true)),
Map.entry("createDate", new Schema().withType(Type.STRING).withFormat("date-time").withDescription("Upload time.").withNullable(true)),
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may delete this attachment; retained files cannot be deleted.").withNullable(true)),
Map.entry("canEdit", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change the attachment filename or description.").withNullable(true)),
Map.entry("canDelete", new Schema().withType(Type.BOOLEAN).withDescription("Whether the caller may change or delete this attachment.").withNullable(true)),
Map.entry("createdByDisplayName", new Schema().withType(Type.STRING).withDescription("Uploader display name.").withNullable(true))))));
}
}
@@ -500,16 +490,8 @@ public final class CollaborationSchemas
/*******************************************************************************
** Temporary access to file bytes. Clients follow url.
*******************************************************************************/
public record CollaborationDownloadV1(String url, String delivery) implements ToSchema
public record CollaborationDownloadV1(String url) implements ToSchema
{
/***************************************************************************
** Existing callers retain the storage URL contract.
***************************************************************************/
public CollaborationDownloadV1(String url)
{
this(url, "SIGNED_URL");
}
/*******************************************************************************
** Describe this versioned body for generated clients.
*******************************************************************************/
@@ -517,8 +499,7 @@ public final class CollaborationSchemas
public Schema toSchema()
{
return (new Schema().withType(Type.OBJECT).withDescription("Temporary access to file bytes. Clients follow url.").withProperties(new TreeMap<>(Map.ofEntries(
Map.entry("url", new Schema().withType(Type.STRING).withDescription("Temporary delivery URL; never persist it.").withNullable(true)),
Map.entry("delivery", new Schema().withType(Type.STRING).withDescription("SIGNED_URL or AUTHENTICATED_FETCH. Authenticated fetch requires the trusted native API transport and a durable receipt check.").withNullable(true))))));
Map.entry("url", new Schema().withType(Type.STRING).withDescription("Presigned URL, valid for five minutes. Clients follow url; never persist it.").withNullable(true))))));
}
}
@@ -736,7 +717,7 @@ public final class CollaborationSchemas
schemas.put("CollaborationPresenceRequestV1", new CollaborationPresenceSchemas.CollaborationPresenceRequestV1(null, null, null, null, null).toSchema());
schemas.put("CollaborationPresenceLeaveV1", new CollaborationPresenceSchemas.CollaborationPresenceLeaveV1(null, null, null).toSchema());
schemas.put("CollaborationPresenceSnapshotV1", new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(null, null, null).toSchema());
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null, null, null, null, null).toSchema());
schemas.put("CollaborationPresenceViewerV1", new CollaborationPresenceSchemas.CollaborationPresenceViewerV1(null, null, null, null).toSchema());
schemas.putAll(CollaborationWorkSchemas.components());
return (schemas);
}
@@ -68,7 +68,6 @@ public class DownloadAttachmentSpec extends CollaborationJsonSpec
@Override
protected Object handle(JsonInput input) throws Exception
{
var delivery = new AttachmentsService().downloadDelivery(id(input));
return (new CollaborationDownloadV1(delivery.url().toExternalForm(), delivery.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL"));
return (new CollaborationDownloadV1(new AttachmentsService().downloadUrl(id(input), false).toExternalForm()));
}
}
@@ -23,7 +23,6 @@ package com.kingsrook.qbits.collaboration.api;
import java.util.List;
import com.kingsrook.qbits.collaboration.presence.PresenceService;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
import com.kingsrook.qqq.openapi.model.HttpMethod;
import com.kingsrook.qqq.openapi.model.Parameter;
@@ -49,7 +48,7 @@ public class GetPresenceSpec extends CollaborationJsonSpec
@Override
public List<Parameter> defineRequestParameters()
{
return (parameters("table!", "pk", "perConnection"));
return (parameters("table!", "pk"));
}
/*******************************************************************************
@@ -69,14 +68,6 @@ public class GetPresenceSpec extends CollaborationJsonSpec
{
String table = required(input.getQueryParam("table"), "table");
String pk = input.getQueryParam("pk");
String perConnection = input.getQueryParam("perConnection");
if(perConnection != null && !perConnection.equalsIgnoreCase("true") && !perConnection.equalsIgnoreCase("false"))
{
throw (new QBadRequestException("perConnection must be true or false"));
}
var service = new PresenceService();
var viewers = service.snapshot(table, pk);
var leases = service.connections(table, pk);
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, viewers, leases.size(), "true".equalsIgnoreCase(perConnection) ? leases : null));
return (new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1(table, pk, new PresenceService().snapshot(table, pk)));
}
}
@@ -68,10 +68,7 @@ public class ListAttachmentsSpec extends CollaborationJsonSpec
@Override
protected Object handle(JsonInput input) throws Exception
{
int limit = limit(input);
var position = pagePosition(input);
var page = new AttachmentsService().listPage(table(input), pk(input), position.offset(), limit);
String next = page.hasMore() ? position.cursor((long) position.offset() + page.items().size()) : null;
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), next));
var page = page(input, new AttachmentsService().list(table(input), pk(input)));
return (new CollaborationAttachmentListV1(CollaborationViews.attachments(page.items()), page.nextCursor()));
}
}
@@ -68,7 +68,6 @@ public class PreviewAttachmentSpec extends CollaborationJsonSpec
@Override
protected Object handle(JsonInput input) throws Exception
{
var delivery = new AttachmentsService().downloadDelivery(id(input), true);
return (new CollaborationDownloadV1(delivery.url().toExternalForm(), delivery.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL"));
return (new CollaborationDownloadV1(new AttachmentsService().downloadUrl(id(input), true).toExternalForm()));
}
}
@@ -108,7 +108,7 @@ public final class CollaborationHome
.withPermissionRules(CollaborationPermissions.permissionRules())
.withWidgets(HOME_WIDGETS)
.withSection(new QAppSection().withName("collaboration").withLabel("Collaboration").withIcon(new QIcon("forum"))
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.ATTACHMENT_CLEANUP, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
.withTables(List.of(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER)));
app.setParentAppName(config.getParentAppName());
return (app);
}
@@ -267,19 +267,15 @@ public final class CollaborationHome
List<Map<String, Object>> rows = new ArrayList<>();
if(admin)
{
var registry = PresenceRegistry.get(QContext.getQInstance());
Map<String, Integer> connections = registry.connectionCounts();
registry.counts().forEach((table, viewers) ->
PresenceRegistry.get(QContext.getQInstance()).counts().forEach((table, viewers) ->
{
Map<String, Object> row = new LinkedHashMap<>();
row.put("table", tableLabel(table));
row.put("viewers", viewers);
row.put("connections", connections.getOrDefault(table, viewers));
rows.add(row);
});
}
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right"),
column("Connections", "connections", "right")));
return (table("Who's here", admin ? NOBODY_HERE : PRESENCE_ADMIN_ONLY, rows, column("Table", "table", "left"), column("People", "viewers", "right")));
}
@@ -1,121 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** A visible native adoption action for an existing original. It never
** accepts replacement bytes, a storage path, an actor, or an intake scope.
*******************************************************************************/
public final class AdoptRetainedAttachmentProcess
{
public static final String NAME = "collaborationAdoptRetainedAttachment";
/*******************************************************************************
** Metadata utility.
*******************************************************************************/
private AdoptRetainedAttachmentProcess()
{
}
/*******************************************************************************
** Standard QQQ confirmation and result screens, attached to the attachment table.
*******************************************************************************/
public static QProcessMetaData metadata()
{
return (new QProcessMetaData().withName(NAME).withTableName(CollaborationTables.ATTACHMENT).withLabel("Retain existing original")
.withMinInputRecords(1).withMaxInputRecords(1).withPermissionRules(CollaborationPermissions.permissionRules())
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
.withRecordListMetaData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData().withTableName(CollaborationTables.ATTACHMENT))))
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("confirm").withLabel("Retain this original")
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.EDIT_FORM))
.withFormField(new QFieldMetaData("confirmAdoption", QFieldType.BOOLEAN).withLabel("Verify and retain the existing original without copying bytes").withDefaultValue(false).withIsRequired(true)))
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("adopt").withCode(new QCodeReference(Adopt.class)))
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Original retained")
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.VIEW_FORM))
.withViewField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Retained attachment")
.withPossibleValueSourceName(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT))));
}
/*******************************************************************************
** Resolve one visible attachment selection before displaying confirmation.
*******************************************************************************/
public static final class Prepare implements BackendStep
{
/*******************************************************************************
** Read-only preview under current actor and parent visibility.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(input.getRecords() == null || input.getRecords().size() != 1)
{
throw (new QException("Select exactly one attachment."));
}
var row = new AttachmentsService().requireAdoptable(input.getRecords().getFirst().getValueInteger("id"));
if(row == null)
{
throw (new QException("Attachment is unavailable."));
}
if(row.getValueString("intakeKey") != null)
{
throw (new QException("Retry this upload through its original intake request to preserve its context."));
}
output.addValue("attachmentId", row.getValue("id"));
output.addValue("confirmAdoption", false);
}
}
/*******************************************************************************
** Execution reauthorizes independently of the earlier preview.
*******************************************************************************/
public static final class Adopt implements BackendStep
{
/*******************************************************************************
** The owner returns only after mandatory lifecycle registration completes.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(!Boolean.TRUE.equals(input.getValueBoolean("confirmAdoption")))
{
throw (new QException("Confirm adoption of this existing original to continue."));
}
output.addValue("attachmentId", new AttachmentsService().adoptRetainedAttachment(input.getValueInteger("attachmentId")).id());
}
}
}
@@ -1,201 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.io.Serializable;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Optional owner policy for parents whose dynamic visibility cannot be expressed
** by aggregate SQL. Count/aggregate require one currently authorized parent.
** Ordinary collaboration policies retain their existing count behavior.
*******************************************************************************/
public final class AttachmentAggregateGuard extends AttachmentSecurity
{
private static final String PINNED_POLICY = "collaboration.attachmentPinnedAggregateParent";
/*******************************************************************************
** Hide unscoped list totals in both instance and table metadata. Native count
** remains available to callers supplying the required authorized parent pin.
*******************************************************************************/
public static QTableMetaData configure(QTableMetaData table, CollaborationQBitConfig config)
{
if(policy(config).requiresPinnedAggregateParent())
{
table.withSupplementalMetaData(new PinnedParentPolicy());
}
if(table.getSupplementalMetaData(PINNED_POLICY) != null)
{
table.withoutCapabilities(Capability.TABLE_COUNT, Capability.QUERY_STATS);
}
return (table);
}
/*******************************************************************************
** Preserve ordinary view/write rules, then narrow aggregate inputs only.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
super.checkPermissionsThrowing(input, metadata);
if(!(input instanceof CountInput) && !(input instanceof AggregateInput))
{
return;
}
try
{
CollaborationQBitConfig config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig();
QTableMetaData tableMetadata = QContext.getQInstance().getTable(CollaborationTables.ATTACHMENT);
if(tableMetadata.getSupplementalMetaData(PINNED_POLICY) == null && !policy(config).requiresPinnedAggregateParent() && !hasRetainedOriginal())
{
return;
}
QQueryFilter filter = input instanceof CountInput count ? count.getFilter() : ((AggregateInput) input).getFilter();
var joins = input instanceof CountInput count ? count.getQueryJoins() : ((AggregateInput) input).getQueryJoins();
if(joins != null && !joins.isEmpty() || filter == null || filter.getBooleanOperator() != QQueryFilter.BooleanOperator.AND)
{
throw (new QException("A single pinned attachment parent is required for counts and aggregates."));
}
rejectSetOperations(filter, 0, new int[]{0});
Serializable table = literal(filter, "tableName");
Serializable record = literal(filter, "recordId");
if(!(table instanceof String name) || name.isBlank() || record == null)
{
throw (new QException("A single pinned attachment parent is required for counts and aggregates."));
}
ParentRecordAccess.requireVisibleParent(name, record);
}
catch(QException | RuntimeException denied)
{
throw (new QPermissionDeniedException("Counts and aggregates require one currently readable attachment parent."));
}
}
/*******************************************************************************
** A literal top-level AND equality constrains every row, even when other
** independent subfilters contain OR. Conflicting or computed pins are refused.
*******************************************************************************/
private static Serializable literal(QQueryFilter filter, String field) throws QException
{
Serializable result = null;
for(var criterion : filter.getCriteria() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria>of() : filter.getCriteria())
{
if(field.equals(criterion.getFieldName()) && criterion.getOperator() == QCriteriaOperator.EQUALS)
{
if(criterion.getValues() == null || criterion.getValues().size() != 1)
{
throw (new QException("An exact parent equality is required."));
}
Serializable value = criterion.getValues().getFirst();
if(!(value instanceof String) && !(value instanceof Number) || result != null && !result.equals(value))
{
throw (new QException("Computed or conflicting parent equality is not supported."));
}
result = value;
}
}
return (result);
}
/*******************************************************************************
** Set-operation filters may combine independent branches; never assume their
** SQL obeys an ordinary root AND pin. Bound traversal before SQL execution.
*******************************************************************************/
private static void rejectSetOperations(QQueryFilter filter, int depth, int[] visited) throws QException
{
if(depth > 64 || ++visited[0] > 4096 || filter.getSubFilterSetOperator() != null)
{
throw (new QException("Unsupported aggregate filter shape."));
}
for(QQueryFilter child : filter.getSubFilters() == null ? List.<QQueryFilter>of() : filter.getSubFilters())
{
rejectSetOperations(child, depth + 1, visited);
}
}
/*******************************************************************************
** Capability policy is configuration-only and must not depend on a request.
*******************************************************************************/
private static AttachmentLifecyclePolicy policy(CollaborationQBitConfig config)
{
return (java.util.Objects.requireNonNull(QCodeLoader.getAdHoc(AttachmentLifecyclePolicy.class, config.getAttachmentLifecycle()), "Attachment lifecycle policy is unavailable."));
}
/*******************************************************************************
** A fresh instance may have lost its former lifecycle configuration. Inspect
** only existence through private owner metadata, never return retained rows.
** Failure propagates to aggregate denial; there is no permissive fallback.
*******************************************************************************/
private static boolean hasRetainedOriginal() throws QException
{
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput(CollaborationTables.ATTACHMENT)
.withFilter(new QQueryFilter().withCriteria("retained", QCriteriaOperator.EQUALS, true).withLimit(1))
.withShouldFetchHeavyFields(false).withShouldGenerateDisplayValues(false);
input.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
input.withPermissionBypass("Owner aggregate guard checks only whether retained originals require pinned-parent authorization; no row data is exposed.");
return (!new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(input).getRecords().isEmpty());
}
/*******************************************************************************
** An installed capability restriction survives subsequent lifecycle changes
** and metadata clones. It carries no request or authorization state.
*******************************************************************************/
public static final class PinnedParentPolicy extends QSupplementalTableMetaData
{
/*******************************************************************************
** Stable owner metadata identity.
*******************************************************************************/
@Override
public String getType()
{
return (PINNED_POLICY);
}
/*******************************************************************************
** The marker is immutable and has no nested mutable state.
*******************************************************************************/
@Override
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
{
return (clone);
}
}
}
@@ -66,8 +66,7 @@ public class AttachmentCleanup
while(true)
{
List<QRecord> rows = com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupQuery(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT, new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, table)
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList())
.withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true).withLimit(200));
.withCriteria("recordId", QCriteriaOperator.IN, ids.stream().map(Object::toString).toList()).withLimit(200));
if(rows.isEmpty())
{
return;
@@ -1,527 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.io.Serializable;
import java.time.Instant;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.CapturedContext;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryLockMode;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
/*******************************************************************************
** Durable deletion intent, committed with metadata removal before deleting bytes.
*******************************************************************************/
public class AttachmentCleanupQueue implements CustomPermissionChecker
{
private final AttachmentStorage storage;
/*******************************************************************************
** Metadata permission checks do not create a storage client.
*******************************************************************************/
public AttachmentCleanupQueue()
{
this(new AttachmentStorage());
}
/*******************************************************************************
** Use the same provider boundary as the authorized attachment operation.
*******************************************************************************/
AttachmentCleanupQueue(AttachmentStorage storage)
{
this.storage = storage;
}
/*******************************************************************************
** Only admins may inspect recovery locations; no direct mutations are allowed.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(!(input instanceof QueryInput) && !(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput)
&& !(input instanceof com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput))
{
throw (new QPermissionDeniedException("Attachment cleanup records are service-managed."));
}
requireOperator();
}
/*******************************************************************************
** The caller has already authorized the parent and attachment owner.
*******************************************************************************/
void delete(QRecord row, boolean cleanup) throws QException
{
QBackendTransaction outer = QContext.getQBackendTransaction();
if(outer != null)
{
QRecord pending = enqueueAndDelete(row, cleanup);
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
outer.addAfterCommitCallback(() ->
{
try
{
QContext.withTemporaryContext(context, () -> finish(pending));
}
catch(QException e)
{
throw (new IllegalStateException("Attachment cleanup remains durably queued.", e));
}
});
return;
}
QRecord pending;
try(QBackendTransaction transaction = QBackendTransaction.openFor(new DeleteInput(CollaborationTables.ATTACHMENT)))
{
QContext.setTransaction(transaction);
try
{
pending = enqueueAndDelete(row, cleanup);
transaction.commit();
}
catch(QException | RuntimeException failure)
{
transaction.rollback();
throw (failure);
}
finally
{
QContext.clearTransaction();
}
}
finish(pending);
}
/*******************************************************************************
** Store the recovery locator in the same database transaction as row deletion.
*******************************************************************************/
private QRecord enqueueAndDelete(QRecord row, boolean cleanup) throws QException
{
QRecord intent = new QRecord().withValue("attachmentId", row.getValue("id"))
.withValue("bucketName", row.getValue("bucketName")).withValue("storageKey", row.getValue("storageKey"));
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(intent))
.withTransaction(QContext.getQBackendTransaction());
insert.withPermissionBypass("Authorized attachment deletion records durable storage cleanup.");
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
AttachmentRecords.checkErrors(inserted);
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT).withPrimaryKeys(List.of(row.getValue("id")))
.withTransaction(QContext.getQBackendTransaction());
delete.setTableMetaData(cleanup ? CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT) : AttachmentRecords.storageTable());
delete.withPermissionBypass("Attachment deletion was authorized before recording cleanup.");
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
return (inserted.getFirst());
}
/*******************************************************************************
** Durable upload ownership requires real transactional row locking.
*******************************************************************************/
static boolean supportsUploadOwnership() throws QException
{
var backend = QContext.getQInstance().getBackendForTable(CollaborationTables.ATTACHMENT_CLEANUP);
return (new QBackendModuleDispatcher().getQBackendModule(backend).supportsQueryLockMode(backend, QueryLockMode.FOR_UPDATE_SKIP_LOCKED));
}
/*******************************************************************************
** Commit the locator independently, then lock it before any provider write.
** If a worker claimed it first, abort without uploading any bytes.
*******************************************************************************/
QRecord beginUpload(String bucket, String key) throws QException
{
if(!supportsUploadOwnership())
{
return (null);
}
QBackendTransaction transaction = QContext.getQBackendTransaction();
if(transaction == null)
{
throw (new QException("Durable attachment uploads require an active transaction."));
}
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
QRecord[] pending = new QRecord[1];
QContext.withTemporaryContext(context, () ->
{
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
new QRecord().withValue("bucketName", bucket).withValue("storageKey", key)));
insert.withPermissionBypass("Authorized upload records its immutable locator before external I/O.");
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
AttachmentRecords.checkErrors(inserted);
pending[0] = inserted.getFirst();
});
if(lock(pending[0], transaction) == null)
{
throw (new QException("Upload cleanup intent was already claimed; retry the upload."));
}
Runnable cleanup = () ->
{
try
{
QContext.withTemporaryContext(context, () -> finish(pending[0]));
}
catch(QException e)
{
throw (new IllegalStateException("Upload cleanup remains durably queued.", e));
}
};
transaction.addAfterRollbackCallback(cleanup);
transaction.addAfterCommitCallback(cleanup);
return (pending[0]);
}
/*******************************************************************************
** Publish metadata and retire its upload intent in the same transaction.
*******************************************************************************/
void completeUpload(QRecord pending) throws QException
{
if(pending != null)
{
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP).withPrimaryKey(pending.getValue("id"))
.withTransaction(QContext.getQBackendTransaction());
delete.withPermissionBypass("Attachment metadata now owns the uploaded object in this transaction.");
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
}
}
/*******************************************************************************
** Skip active owners without waiting or updating their locked intent.
*******************************************************************************/
private QRecord lock(QRecord pending, QBackendTransaction transaction) throws QException
{
QueryInput query = new QueryInput(CollaborationTables.ATTACHMENT_CLEANUP).withTransaction(transaction)
.withLockMode(QueryLockMode.FOR_UPDATE_SKIP_LOCKED)
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, pending.getValue("id")));
query.withPermissionBypass("Internal attachment lifecycle owns this immutable cleanup locator.");
List<QRecord> rows = new QueryAction().execute(query).getRecords();
return (rows.isEmpty() ? null : rows.getFirst());
}
/*******************************************************************************
** Distinguish an active owner from a committed row requiring delayed retry.
*******************************************************************************/
private enum CleanupOutcome
{
COMPLETED, ACTIVE, REFERENCED, UNCERTAIN_UPLOAD
}
/*******************************************************************************
** Hold the same row lock as an uploader until deletion and acknowledgement end.
*******************************************************************************/
private CleanupOutcome finish(QRecord pending) throws QException
{
if(!supportsUploadOwnership())
{
return (finishUnlocked(pending));
}
CleanupOutcome[] outcome = { CleanupOutcome.ACTIVE };
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
QContext.withTemporaryContext(context, () ->
{
try(QBackendTransaction transaction = QBackendTransaction.openFor(new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP)))
{
QContext.setTransaction(transaction);
try
{
QRecord locked = lock(pending, transaction);
if(locked != null)
{
outcome[0] = finishUnlocked(locked);
}
transaction.commit();
}
catch(QException | RuntimeException failure)
{
transaction.rollback();
throw (failure);
}
finally
{
QContext.clearTransaction();
}
}
});
return (outcome[0]);
}
/*******************************************************************************
** Never erase bytes while metadata still exists, including nontransactional hosts.
** Provider or queue deletion failure retains the intent for idempotent replay.
*******************************************************************************/
private CleanupOutcome finishUnlocked(QRecord pending) throws QException
{
if(pending.getValue("resolvedAt") != null)
{
return (CleanupOutcome.COMPLETED);
}
if(!AttachmentRecords.query(new QQueryFilter()
.withCriteria("bucketName", QCriteriaOperator.EQUALS, pending.getValue("bucketName"))
.withCriteria("storageKey", QCriteriaOperator.EQUALS, pending.getValue("storageKey"))).isEmpty())
{
return (CleanupOutcome.REFERENCED);
}
if(pending.getValue("attachmentId") == null)
{
if(!storage.cleanupUpload(pending.getValueString("bucketName"), pending.getValueString("storageKey")))
{
return (CleanupOutcome.UNCERTAIN_UPLOAD);
}
}
else
{
storage.delete(pending.getValueString("bucketName"), pending.getValueString("storageKey"));
}
DeleteInput delete = new DeleteInput(CollaborationTables.ATTACHMENT_CLEANUP).withPrimaryKeys(List.of(pending.getValue("id"))).withTransaction(QContext.getQBackendTransaction());
delete.withPermissionBypass("Storage cleanup completed; remove its durable intent.");
AttachmentRecords.checkErrors(new DeleteAction().execute(delete).getRecordsWithErrors());
return (CleanupOutcome.COMPLETED);
}
/*******************************************************************************
** Rollback compensation must survive a provider outage. Persist independently
** of the failed transaction, then attempt cleanup with the original context.
*******************************************************************************/
void compensateUpload(String bucket, String key)
{
CapturedContext context = new CapturedContext(QContext.getQInstance(), QContext.getQSession());
try
{
QContext.withTemporaryContext(context, () ->
{
InsertInput insert = new InsertInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
new QRecord().withValue("bucketName", bucket).withValue("storageKey", key)));
insert.withPermissionBypass("Failed authorized upload retains its immutable storage locator for compensation.");
List<QRecord> inserted = new InsertAction().execute(insert).getRecords();
AttachmentRecords.checkErrors(inserted);
finish(inserted.getFirst());
});
}
catch(QException e)
{
throw (new IllegalStateException("Upload compensation could not complete.", e));
}
}
/*******************************************************************************
** Bounded recovery from committed intents, independent of the original service.
*******************************************************************************/
public int retryPending() throws QException
{
requireOperator();
if(QContext.getQBackendTransaction() != null)
{
throw (new QException("Attachment cleanup must run outside a caller transaction."));
}
QueryInput query = new QueryInput(CollaborationTables.ATTACHMENT_CLEANUP).withFilter(new QQueryFilter()
.withCriteria("resolvedAt", QCriteriaOperator.IS_BLANK).withSubFilter(new QQueryFilter()
.withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK)
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now()))
.withOrderBy(new QFilterOrderBy("id")).withLimit(100));
query.withPermissionBypass("Authorized operator retries committed storage cleanup.");
List<QRecord> pending = new QueryAction().execute(query).getRecords();
int completed = 0;
QException failure = null;
for(QRecord row : pending)
{
try
{
CleanupOutcome outcome = finish(row);
if(outcome == CleanupOutcome.COMPLETED)
{
completed++;
}
else if(outcome == CleanupOutcome.REFERENCED || outcome == CleanupOutcome.UNCERTAIN_UPLOAD)
{
defer(row);
}
}
catch(QException | RuntimeException e)
{
try
{
defer(row);
}
catch(QException | RuntimeException deferFailure)
{
e.addSuppressed(deferFailure);
}
if(failure == null)
{
failure = new QException("Attachment cleanup remains pending; retry after the provider or database recovers.", e);
}
}
}
if(failure != null)
{
throw (failure);
}
return (completed);
}
/*******************************************************************************
** Persist backoff so failing entries do not monopolize the next bounded batch.
*******************************************************************************/
private void defer(QRecord pending) throws QException
{
UpdateInput update = new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP).withRecords(List.of(
new QRecord().withValue("id", pending.getValue("id")).withValue("nextAttemptAt", Instant.now().plusSeconds(60))));
update.withPermissionBypass("Committed cleanup failed or is not yet safe; retain it for a later attempt.");
AttachmentRecords.checkErrors(new UpdateAction().execute(update).getRecords());
}
/*******************************************************************************
** An identified administrator attests provider settlement; absence or age alone
** is not evidence. Keep the receipt on the service-managed row permanently.
*******************************************************************************/
public void resolveUpload(Serializable id, String evidence) throws QException
{
String actor = requireSettlementOperator();
validateEvidence(evidence);
if(id == null || QContext.getQBackendTransaction() != null || !supportsUploadOwnership())
{
throw (new QException("Upload settlement requires a record ID and an independent locking transaction."));
}
try(QBackendTransaction transaction = QBackendTransaction.openFor(new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP)))
{
QContext.setTransaction(transaction);
try
{
QRecord pending = lock(new QRecord().withValue("id", id), transaction);
if(pending == null)
{
throw (new QException("Cleanup record is active or no longer exists. Retry after its owner finishes."));
}
if(pending.getValue("resolvedAt") != null)
{
transaction.commit();
return;
}
if(pending.getValue("attachmentId") != null)
{
throw (new QException("Use normal cleanup retry for committed attachment deletions."));
}
if(!AttachmentRecords.query(new QQueryFilter()
.withCriteria("bucketName", QCriteriaOperator.EQUALS, pending.getValue("bucketName"))
.withCriteria("storageKey", QCriteriaOperator.EQUALS, pending.getValue("storageKey"))).isEmpty())
{
throw (new QException("An attachment still references this object; settlement is not allowed."));
}
storage.delete(pending.getValueString("bucketName"), pending.getValueString("storageKey"));
UpdateInput update = new UpdateInput(CollaborationTables.ATTACHMENT_CLEANUP).withTransaction(transaction)
.withRecords(List.of(new QRecord().withValue("id", pending.getValue("id"))
.withValue("resolvedAt", Instant.now()).withValue("resolvedBy", actor)
.withValue("settlementEvidence", evidence.strip())));
update.withPermissionBypass("Identified collaboration administrator records confirmed upload settlement after storage cleanup.");
AttachmentRecords.checkErrors(new UpdateAction().execute(update).getRecords());
transaction.commit();
}
catch(QException | RuntimeException failure)
{
transaction.rollback();
throw (failure);
}
finally
{
QContext.clearTransaction();
}
}
}
/*******************************************************************************
** The form and service both reject missing or oversized evidence.
*******************************************************************************/
static void validateEvidence(String evidence) throws QException
{
if(evidence == null || evidence.isBlank() || evidence.strip().length() > 2000)
{
throw (new QException("Record provider completion or cancellation evidence (1–2000 characters)."));
}
}
/*******************************************************************************
** Settlement is a deliberate, attributable admin action, never scheduled work.
*******************************************************************************/
static String requireSettlementOperator() throws QPermissionDeniedException
{
var session = QContext.getQSession();
String actor = CollaborationSecurity.userId(session);
if(session == null || session instanceof QSystemUserSession || !session.hasPermission(CollaborationPermissions.ADMIN)
|| actor == null || actor.isBlank() || actor.length() > 255)
{
throw (new QPermissionDeniedException("An identified collaboration administrator must confirm upload settlement."));
}
return (actor);
}
/*******************************************************************************
** Scheduled system work and explicit admin recovery share one authorization rule.
*******************************************************************************/
static void requireOperator() throws QPermissionDeniedException
{
var session = QContext.getQSession();
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN)))
{
throw (new QPermissionDeniedException("This needs the collaboration.admin permission."));
}
}
}
@@ -1,50 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
/*******************************************************************************
** Verified bytes returned only after authorization, integrity and audit checks.
** Storage coordinates never cross this boundary. Content is bounded to 20 MiB.
*******************************************************************************/
public record AttachmentContent(Integer attachmentId, String fileName, String contentType, String sha256, byte[] bytes)
{
/*******************************************************************************
** Own the byte array so a caller cannot change a retained receipt's content.
*******************************************************************************/
public AttachmentContent
{
if(bytes == null || bytes.length > 20 * 1024 * 1024)
{
throw (new IllegalArgumentException("Attachment content is missing or exceeds the supported limit."));
}
bytes = bytes.clone();
}
/*******************************************************************************
** Return caller-owned bytes without exposing the immutable content buffer.
*******************************************************************************/
@Override
public byte[] bytes()
{
return (bytes.clone());
}
}
@@ -1,41 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Exact stored content failed its immutable metadata check. Consumers must not
** retry this as a transport outage or release the unverified bytes.
*******************************************************************************/
public class AttachmentContentIntegrityException extends QException
{
/*******************************************************************************
** Fixed public diagnostic, with no object locator or provider details.
*******************************************************************************/
public AttachmentContentIntegrityException()
{
super("Attachment content failed integrity verification.");
}
}
@@ -1,54 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.net.URL;
import java.util.Objects;
/*******************************************************************************
** A download destination may require the application's authenticated transport.
** The URL is sensitive and never attests that bytes were served.
*******************************************************************************/
public record AttachmentDownload(URL url, boolean authenticated)
{
/***************************************************************************
** Only ordinary HTTP destinations are eligible for attachment delivery.
***************************************************************************/
public AttachmentDownload
{
Objects.requireNonNull(url, "url");
if((!"https".equalsIgnoreCase(url.getProtocol()) && !"http".equalsIgnoreCase(url.getProtocol()))
|| url.getUserInfo() != null || url.getRef() != null || url.getHost() == null || url.getHost().isBlank())
{
throw new IllegalArgumentException("Attachment download requires HTTP or HTTPS");
}
}
/***************************************************************************
** Never disclose storage signatures through ordinary object logging.
***************************************************************************/
@Override
public String toString()
{
return ("AttachmentDownload[authenticated=" + authenticated + ", url=<redacted>]");
}
}
@@ -27,14 +27,6 @@ import java.time.Instant;
** Panel DTO. Storage locations and ephemeral signed URLs stay server-side.
*******************************************************************************/
public record AttachmentEntry(Integer id, String tableName, String recordId, String fileName, String contentType,
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete, Boolean canEdit)
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete)
{
/*******************************************************************************
** Preserve source compatibility for callers using the earlier DTO constructor.
*******************************************************************************/
public AttachmentEntry(Integer id, String tableName, String recordId, String fileName, String contentType,
Long sizeBytes, String sha256, String description, String createdByUserId, Instant createDate, Boolean canDelete)
{
this(id, tableName, recordId, fileName, contentType, sizeBytes, sha256, description, createdByUserId, createDate, canDelete, canDelete);
}
}
@@ -1,214 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Trusted host extension for attachment lifecycle policy. The default preserves
** ordinary Collaboration behavior. Implementations must be concurrency-safe and
** must not retain request context. Callbacks receive defensive record copies.
** This is not an untrusted scripting API or a new file-storage abstraction.
*******************************************************************************/
public class AttachmentLifecyclePolicy
{
/*******************************************************************************
** Configuration-only capability policy, evaluated during metadata production
** without a session. Dynamic parent visibility owners opt in to pinned counts.
*******************************************************************************/
public boolean requiresPinnedAggregateParent()
{
return (false);
}
/*******************************************************************************
** An installed retention owner must authorize an exact archival reference
** transfer in the caller's real transaction. Ordinary policies deny this.
*******************************************************************************/
public void beforeReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction) throws QException
{
throw (new QException("Retained reference transfer requires an archive policy."));
}
/*******************************************************************************
** Persist the reference-transfer outcome in the same transaction. The policy
** must not commit or close the transaction; failure requires caller rollback.
*******************************************************************************/
public void afterReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction) throws QException
{
throw (new QException("Retained reference transfer requires an atomic archive audit policy."));
}
/*******************************************************************************
** Called with canonical source, actual digest/size and persisted retention
** decision immediately before the first object write. There is no row ID yet.
** Retained originals require an independently durable intent before storage.
*******************************************************************************/
public void beforeStored(QRecord attachment) throws QException
{
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
{
throw (new QException("Retained storage requires a durable write-intent policy."));
}
}
/*******************************************************************************
** Recheck current adoption authority after external operations, without
** creating another intent or granting access to the original's bytes.
*******************************************************************************/
public void authorizeAdoption(QRecord attachment) throws QException
{
throw (new QException("Existing-original adoption is not configured."));
}
/*******************************************************************************
** Explicit adoption of an existing owner original, not a new upload. The
** policy must authorize the current source and commit its intent before read.
*******************************************************************************/
public void beforeAdoption(QRecord attachment) throws QException
{
throw (new QException("Existing-original adoption requires an audited retention policy."));
}
/*******************************************************************************
** Record the retention transition in the owner's transaction. Registration
** follows separately; failure after commit leaves a retained retryable row.
*******************************************************************************/
public void afterAdoption(QRecord attachment, QBackendTransaction transaction) throws QException
{
throw (new QException("Existing-original adoption requires an atomic retention audit."));
}
/*******************************************************************************
** Called after storage and metadata insertion, before upload returns. Failure
** leaves the original intact for reconciliation; it is not a rollback hook.
** Enforce incomplete registration/quarantine in beforeAccess as appropriate.
*******************************************************************************/
public void afterStored(QRecord attachment) throws QException
{
}
/*******************************************************************************
** Before reading an existing original for registration recovery. This is not
** a write attempt or a user download; retained owners must audit it explicitly.
*******************************************************************************/
public void beforeRecovery(QRecord candidate) throws QException
{
if(Boolean.TRUE.equals(candidate.getValueBoolean("retained")))
{
throw (new QException("Retained recovery requires a durable recovery-attempt policy."));
}
}
/*******************************************************************************
** Called after parent authorization and before either preview or download URL
** signing. Throw to deny. A successful audit here means access was requested,
** not that bytes were downloaded. Mandatory audit must commit independently
** before returning; the caller transaction may still roll back afterwards.
*******************************************************************************/
public void beforeAccess(QRecord attachment, boolean inline) throws QException
{
}
/*******************************************************************************
** Optional authenticated delivery after ordinary parent/lifecycle checks.
** Return null to retain the existing storage URL. No alternate lookup occurs.
*******************************************************************************/
public AttachmentDownload downloadDelivery(QRecord attachment) throws QException
{
return (null);
}
/*******************************************************************************
** Authorize and durably audit a server-side content read, distinct from URL
** issuance. Retained content requires an implementation of this new contract.
*******************************************************************************/
public void beforeContentRead(QRecord attachment) throws QException
{
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
{
throw (new QException("Retained content requires an audited content-read policy."));
}
}
/*******************************************************************************
** Persist the verified server-read result before content leaves the service.
** This does not attest user download, printer spooling or physical output.
*******************************************************************************/
public void afterContentRead(QRecord attachment, Long bytesRead) throws QException
{
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
{
throw (new QException("Retained content requires an audited read-result policy."));
}
}
/*******************************************************************************
** Return true to prohibit deletion and preserve bytes on parent cleanup.
** Called during intake before storage with complete metadata but no row id;
** true is persisted on the attachment and cannot be cleared by this API.
** It is also queried for legacy rows during deletion/read hints. Failure
** denies deletion. Do not depend on parent existence or caller permissions.
*******************************************************************************/
public boolean isRetained(QRecord attachment) throws QException
{
return (false);
}
/*******************************************************************************
** Called after ownership/parent checks and before applying display metadata.
** The second record contains id and proposed fields, not the complete row.
** This records an intent only; it does not attest that a transaction committed.
*******************************************************************************/
public void beforeMetadataChange(QRecord attachment, QRecord changes) throws QException
{
}
/*******************************************************************************
** Validate the actual owner transaction before applying display metadata.
** Required providers must reject unsupported/no-op transactions and incompatible
** audit backends before the first write. This method does not commit anything.
*******************************************************************************/
public void validateMetadataTransaction(QRecord attachment, QBackendTransaction transaction) throws QException
{
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
{
throw (new QException("Retained metadata requires an atomic audit transaction policy."));
}
}
/*******************************************************************************
** Record the successful display metadata change in the supplied transaction
** before its owner commits. Required providers must validate real transaction
** and backend compatibility, insert their event using this transaction and
** throw on failure. Never commit or close the supplied owner transaction.
*******************************************************************************/
public void afterMetadataChange(QRecord attachment, QRecord changes, QBackendTransaction transaction) throws QException
{
if(Boolean.TRUE.equals(attachment.getValueBoolean("retained")))
{
throw (new QException("Retained metadata requires an atomic audit-result policy."));
}
}
}
@@ -1,87 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.util.Locale;
import java.util.Set;
/*******************************************************************************
** What an uploaded object is allowed to tell a browser. The declared media type
** is client-controlled: it is kept as display metadata, but the object itself is
** stored with an inert type and an inline disposition only when the type is a
** known passive image, PDF, plain-text, audio or video format. Anything else (HTML, SVG,
** XML, script, unknown) is stored as an opaque attachment, so a signed storage
** link can never render or execute uploaded active content inline.
*******************************************************************************/
final class AttachmentMediaPolicy
{
static final String OPAQUE = "application/octet-stream";
private static final Set<String> PASSIVE_INLINE = Set.of(
"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain",
"audio/mpeg", "audio/mp4", "audio/ogg", "audio/wav", "audio/webm",
"video/mp4", "video/webm", "video/ogg");
/***************************************************************************
** Static policy only.
***************************************************************************/
private AttachmentMediaPolicy()
{
}
/***************************************************************************
** The base media type of a declared value: lowercase, without parameters.
** Anything that is not a plain type/subtype token is treated as unknown.
***************************************************************************/
static String baseType(String declared)
{
if(declared == null)
{
return (OPAQUE);
}
String base = declared.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
return (base.matches("[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}") ? base : OPAQUE);
}
/***************************************************************************
** The media type the stored object will be served with.
***************************************************************************/
static String storedType(String declared)
{
String base = baseType(declared);
return (PASSIVE_INLINE.contains(base) ? base : OPAQUE);
}
/***************************************************************************
** Inline only for passive types; every other object is an attachment.
***************************************************************************/
static String disposition(String declared)
{
return (PASSIVE_INLINE.contains(baseType(declared)) ? "inline" : "attachment");
}
}
@@ -46,13 +46,13 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
return (filter(records, input instanceof GetInput, input.getShouldGenerateDisplayValues(), new AttachmentsService()));
return (filter(records, input instanceof GetInput, new AttachmentsService()));
}
/*******************************************************************************
** Keep filtering every row even when the preview generation cap is reached.
*******************************************************************************/
List<QRecord> filter(List<QRecord> records, boolean isGet, boolean generateUrls, AttachmentsService service) throws QException
List<QRecord> filter(List<QRecord> records, boolean isGet, AttachmentsService service) throws QException
{
List<QRecord> visible = new ArrayList<>();
for(QRecord record : records)
@@ -77,21 +77,15 @@ public class AttachmentPreviewCustomizer implements TableCustomizerInterface
QRecord row = new QRecord(record);
row.setValue("previewUrl", null);
row.setValue("downloadUrl", null);
row.setValue("previewDelivery", null);
row.setValue("downloadDelivery", null);
row.withDisplayValue("downloadUrl", null).withDisplayValue("downloadUrl:downloadUrlDynamic", null);
visible.add(row);
if(generateUrls && visible.size() <= 200)
if(visible.size() <= 200)
{
try
{
AttachmentDownload preview = service.delivery(row, true);
AttachmentDownload download = service.delivery(row, false);
row.setValue("previewUrl", preview.url().toString());
row.setValue("previewDelivery", preview.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL");
String downloadUrl = download.url().toString();
row.setValue("previewUrl", service.url(row, true).toString());
String downloadUrl = service.url(row, false).toString();
row.setValue("downloadUrl", downloadUrl);
row.setValue("downloadDelivery", download.authenticated() ? "AUTHENTICATED_FETCH" : "SIGNED_URL");
row.withDisplayValue("downloadUrl", row.getValueString("fileName"))
.withDisplayValue("downloadUrl:downloadUrlDynamic", downloadUrl);
}
@@ -22,17 +22,13 @@
package com.kingsrook.qbits.collaboration.attachments;
import java.net.URL;
import java.util.Optional;
import java.util.UUID;
import com.kingsrook.qbits.objectstorage.api.DeleteMode;
import com.kingsrook.qbits.objectstorage.api.DeleteRequest;
import com.kingsrook.qbits.objectstorage.api.ObjectStorage;
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qbits.objectstorage.api.Range;
import com.kingsrook.qbits.objectstorage.api.StorageBucket;
import com.kingsrook.qbits.objectstorage.api.StoredObject;
/*******************************************************************************
** Narrow storage boundary. The caller must check collaboration access first.
@@ -66,22 +62,6 @@ public class AttachmentStorage
bucket(name).put(request);
}
/*******************************************************************************
** Open content for an already-authorized caller. The service owns closing it.
*******************************************************************************/
public ObjectStream open(String name, String key)
{
return (bucket(name).get(key, Range.all()));
}
/*******************************************************************************
** Inspect a deterministic intake key after uncertain conditional creation.
*******************************************************************************/
public Optional<StoredObject> head(String name, String key)
{
return (bucket(name).head(key));
}
/*******************************************************************************
** Prefer soft deletion; allow permanent deletion only where no soft mode exists.
*******************************************************************************/
@@ -93,18 +73,6 @@ public class AttachmentStorage
}
}
/*******************************************************************************
** Remove failed-upload bytes and report whether no earlier PUT can still finish.
** Generic object storage cannot prove remote settlement after a timeout. Keep
** its durable locator for later sweeps. Adapters may return true only with an
** authoritative completion/cancellation guarantee, never from object absence.
*******************************************************************************/
public boolean cleanupUpload(String name, String key)
{
delete(name, key);
return (false);
}
/*******************************************************************************
** Create a temporary link, never a persistent table value.
*******************************************************************************/
@@ -21,8 +21,6 @@
package com.kingsrook.qbits.collaboration.attachments;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -39,17 +37,14 @@ import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
@@ -73,8 +68,6 @@ public class AttachmentsService
{
private final CollaborationQBitConfig config;
private final AttachmentStorage storage;
private final AttachmentLifecyclePolicy lifecycle;
private final com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal uploadJournal;
/*******************************************************************************
** Resolve the installed configuration without a global mutable singleton.
@@ -84,46 +77,15 @@ public class AttachmentsService
this.config = (CollaborationQBitConfig) QContext.getQInstance().getQBits().values().stream()
.filter(qbit -> qbit.getConfig() instanceof CollaborationQBitConfig).findFirst().orElseThrow().getConfig();
this.storage = com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader.getAdHoc(AttachmentStorage.class, config.getAttachmentStorage());
this.lifecycle = resolveLifecycle(config);
this.uploadJournal = new com.kingsrook.qbits.collaboration.attachments.uploadjournal.JdbcUploadJournal();
}
/*******************************************************************************
** Injectable storage boundary for deterministic service tests.
*******************************************************************************/
public AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage)
{
this(config, storage, resolveLifecycle(config));
}
/*******************************************************************************
** Package-local deterministic lifecycle seam for service tests.
*******************************************************************************/
AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle)
{
this(config, storage, lifecycle, new com.kingsrook.qbits.collaboration.attachments.uploadjournal.JdbcUploadJournal());
}
/*******************************************************************************
** Explicit test-only package seam; public services always use the durable
** qualified journal. It cannot be replaced through application configuration.
*******************************************************************************/
AttachmentsService(CollaborationQBitConfig config, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle,
com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal uploadJournal)
{
this.config = config;
this.storage = storage;
this.lifecycle = java.util.Objects.requireNonNull(lifecycle, "lifecycle");
this.uploadJournal = java.util.Objects.requireNonNull(uploadJournal, "uploadJournal");
}
/*******************************************************************************
** Invalid policy configuration must fail before any storage side effect.
*******************************************************************************/
private static AttachmentLifecyclePolicy resolveLifecycle(CollaborationQBitConfig config)
{
return (java.util.Objects.requireNonNull(com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader
.getAdHoc(AttachmentLifecyclePolicy.class, config.getAttachmentLifecycle()), "Could not load attachment lifecycle policy."));
}
/*******************************************************************************
@@ -131,36 +93,6 @@ public class AttachmentsService
** The caller owns the input stream. Oversize files never reach object storage.
*******************************************************************************/
public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description) throws QException
{
return (uploadInternal(tableName, recordId, fileName, contentType, stream, description, null, null));
}
/*******************************************************************************
** Retryable intake for a server coordinator. Keys and manifests are SHA-256
** identifiers; keys are additionally scoped to actual actor and parent here.
** This path owns persistence and refuses ambient transactions. Replays verify
** supplied bytes and rerun registration; they never create another original.
*******************************************************************************/
public AttachmentEntry upload(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description,
String intakeKey, String manifestSha256) throws QException
{
if(intakeKey == null || !intakeKey.matches("[0-9a-f]{64}") || manifestSha256 == null || !manifestSha256.matches("[0-9a-f]{64}"))
{
throw (new QBadRequestException("Intake key and manifest must be SHA-256 identifiers."));
}
if(QContext.getQBackendTransaction() != null)
{
throw (new QException("Retryable attachment intake cannot join an ambient transaction."));
}
return (uploadInternal(tableName, recordId, fileName, contentType, stream, description, intakeKey, manifestSha256));
}
/*******************************************************************************
** Shared bounded intake; the retryable path preserves uncertain objects for
** reconciliation under a deterministic key and never overwrites them.
*******************************************************************************/
private AttachmentEntry uploadInternal(String tableName, Serializable recordId, String fileName, String contentType, InputStream stream, String description,
String intakeKey, String manifestSha256) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
ParentRecordAccess.requireFeature(tableName, recordId, "attach");
@@ -168,19 +100,13 @@ public class AttachmentsService
String id = canonicalId(tableName, recordId);
String name = sanitize(fileName);
String bucket = config.getAttachmentsBucketName();
String scopedKey = intakeKey == null ? null : tupleHash("attachment-intake-v1", user, tableName, id, intakeKey);
String key = scopedKey == null ? "attachments/" + segment(tableName) + "/" + segment(id) + "/" + UUID.randomUUID() + "/" + name
: "attachments/intake/" + scopedKey;
String key = "attachments/" + segment(tableName) + "/" + segment(id) + "/" + UUID.randomUUID() + "/" + name;
if(tableName.length() > 128 || id.length() > 64 || user.length() > 255 || bucket.length() > 255 || key.length() > 1024
|| (contentType != null && contentType.length() > 255))
{
throw (new QBadRequestException("Attachment metadata exceeds the supported field lengths."));
}
long limit = Math.min(config.getDefaultMaxBytes(), storage.maxBytes(bucket));
if(scopedKey != null)
{
limit = Math.min(limit, 20L * 1024 * 1024);
}
Path temporary = null;
try
{
@@ -203,69 +129,30 @@ public class AttachmentsService
}
}
String type = contentType == null || contentType.isBlank() ? "application/octet-stream" : contentType;
QRecord row = new QRecord().withValue("tableName", tableName).withValue("recordId", id).withValue("fileName", name)
.withValue("contentType", type).withValue("sizeBytes", size).withValue("sha256", HexFormat.of().formatHex(digest.digest()))
.withValue("bucketName", bucket).withValue("storageKey", key).withValue("description", description)
.withValue("createdByUserId", user).withValue("parentDeleted", false);
row.withValue("retained", lifecycle.isRetained(new QRecord(row)));
if(scopedKey != null)
try(InputStream upload = Files.newInputStream(temporary))
{
String fingerprint = tupleHash("attachment-manifest-v1", manifestSha256, name, type, description, String.valueOf(size), row.getValueString("sha256"));
row.withValue("intakeKey", scopedKey).withValue("intakeFingerprint", fingerprint);
QRecord prior = findIntake(scopedKey);
if(prior != null)
{
if(Boolean.TRUE.equals(prior.getValueBoolean("retained")) && uploadJournal.findByLocation(bucket, key).isPresent())
{
return (journaledUpload(row, temporary));
}
return (replayIntake(prior, fingerprint));
}
if(Boolean.TRUE.equals(row.getValueBoolean("retained")))
{
return (journaledUpload(row, temporary));
}
beforeObjectWrite(row);
createIntakeObject(bucket, key, Files.readAllBytes(temporary), type, fingerprint);
}
else
{
if(Boolean.TRUE.equals(row.getValueBoolean("retained")))
{
return (journaledUpload(row, temporary));
}
beforeObjectWrite(row);
////////////////////////////////////////////////////////////////////////
// ordinary uploads keep the transactional cleanup-queue path (355b06d) //
////////////////////////////////////////////////////////////////////////
return (storeUpload(row, temporary));
storage.put(bucket, PutRequest.of(key, upload).withContentLength(size).withContentType(type).withContentDisposition("inline"));
}
boolean rowSaved = false;
try
{
QRecord row = new QRecord().withValue("tableName", tableName).withValue("recordId", id).withValue("fileName", name)
.withValue("contentType", type).withValue("sizeBytes", size).withValue("sha256", HexFormat.of().formatHex(digest.digest()))
.withValue("bucketName", bucket).withValue("storageKey", key).withValue("description", description)
.withValue("createdByUserId", user).withValue("parentDeleted", false);
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withRecords(List.of(row));
input.withPermissionBypass("AttachmentsService checked parent and attach permission before upload.");
List<QRecord> records = new InsertAction().execute(input).getRecords();
AttachmentRecords.checkErrors(records);
rowSaved = true;
lifecycle.afterStored(new QRecord(records.getFirst()));
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(tableName, id, "FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id"));
return (entry(records.getFirst()));
}
catch(QException | RuntimeException failure)
{
if(scopedKey != null)
{
QRecord prior = findIntake(scopedKey);
if(!rowSaved && prior != null)
{
return (replayIntake(prior, row.getValueString("intakeFingerprint")));
}
throw (failure);
}
try
{
if(!rowSaved && !Boolean.TRUE.equals(row.getValueBoolean("retained")))
if(!rowSaved)
{
storage.delete(bucket, key);
}
@@ -297,317 +184,23 @@ public class AttachmentsService
}
}
/*******************************************************************************
** On transactional locking backends, own a transaction only after spooling
** the bounded input. An existing outer transaction retains the upload lock.
*******************************************************************************/
private AttachmentEntry storeUpload(QRecord row, Path temporary) throws QException, IOException
{
var transaction = QContext.getQBackendTransaction();
if(transaction == null && AttachmentCleanupQueue.supportsUploadOwnership())
{
try(var owned = com.kingsrook.qqq.backend.core.actions.QBackendTransaction.openFor(new InsertInput(CollaborationTables.ATTACHMENT)))
{
QContext.setTransaction(owned);
try
{
AttachmentEntry result = storeUpload(row, temporary);
owned.commit();
return (result);
}
catch(QException | IOException | RuntimeException failure)
{
try
{
owned.rollback();
}
catch(QException rollbackFailure)
{
failure.addSuppressed(rollbackFailure);
}
throw (failure);
}
finally
{
QContext.clearTransaction();
}
}
}
if(transaction != null && !transaction.supportsRollbackCallbacks())
{
throw (new QException("Attachment uploads require a transaction with confirmed rollback support."));
}
String bucket = row.getValueString("bucketName");
String key = row.getValueString("storageKey");
AttachmentCleanupQueue cleanup = new AttachmentCleanupQueue(storage);
QRecord pending = cleanup.beginUpload(bucket, key);
try(InputStream upload = Files.newInputStream(temporary))
{
storage.put(bucket, PutRequest.of(key, upload).withContentLength(row.getValueLong("sizeBytes"))
.withContentType(AttachmentMediaPolicy.storedType(row.getValueString("contentType")))
.withContentDisposition(AttachmentMediaPolicy.disposition(row.getValueString("contentType"))));
}
catch(IOException | RuntimeException failure)
{
if(pending == null)
{
try
{
cleanup.compensateUpload(bucket, key);
}
catch(RuntimeException cleanupFailure)
{
failure.addSuppressed(cleanupFailure);
}
}
throw (failure);
}
if(transaction != null && pending == null)
{
var context = new com.kingsrook.qqq.backend.core.context.CapturedContext(QContext.getQInstance(), QContext.getQSession());
transaction.addAfterRollbackCallback(() -> QContext.withTemporaryContext(context,
() -> cleanup.compensateUpload(bucket, key)));
}
boolean rowSaved = false;
try
{
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withRecords(List.of(row));
input.withPermissionBypass("AttachmentsService checked parent and attach permission before upload.");
List<QRecord> records = new InsertAction().execute(input).getRecords();
AttachmentRecords.checkErrors(records);
rowSaved = true;
cleanup.completeUpload(pending);
lifecycle.afterStored(new QRecord(records.getFirst()));
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(row.getValueString("tableName"), row.getValueString("recordId"),
"FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, records.getFirst().getValueString("id"));
return (entry(records.getFirst()));
}
catch(QException | RuntimeException failure)
{
if(!rowSaved && pending == null)
{
try
{
cleanup.compensateUpload(bucket, key);
}
catch(RuntimeException cleanupFailure)
{
failure.addSuppressed(cleanupFailure);
}
}
throw (failure);
}
}
/*******************************************************************************
** Retained bytes cannot be written before durable intent or under an ambient
** transaction whose later rollback could lose their metadata association.
*******************************************************************************/
private void beforeObjectWrite(QRecord row) throws QException
{
if(Boolean.TRUE.equals(row.getValueBoolean("retained")) && QContext.getQBackendTransaction() != null)
{
throw (new QException("Retained attachment storage cannot join an ambient transaction."));
}
lifecycle.beforeStored(new QRecord(row));
}
/*******************************************************************************
** Retained originals use qualified bounded conditional creation and durable
** owner recovery; ordinary uploads retain the established streaming behavior.
*******************************************************************************/
private AttachmentEntry journaledUpload(QRecord row, Path temporary) throws QException, IOException
{
if(row.getValueLong("sizeBytes") > JournaledAttachmentUpload.MAX_BYTES)
{
throw (new QBadRequestException("Retained uploads are limited to 20 MiB by the qualified conditional storage path."));
}
var receipt = new JournaledAttachmentUpload(storage, lifecycle, uploadJournal).store(row, Files.readAllBytes(temporary));
QRecord stored = receipt.attachment();
if(!receipt.alreadyComplete())
{
com.kingsrook.qbits.collaboration.activity.ActivityWriter.write(stored.getValueString("tableName"), stored.getValueString("recordId"),
"FILE_ADDED", "File added", CollaborationTables.ATTACHMENT, stored.getValueString("id"));
}
return (entry(stored));
}
/*******************************************************************************
** Native confirmation discovery checks the configured current adoption policy.
** No original bytes are read and no audit success is asserted during review.
*******************************************************************************/
public QRecord requireAdoptable(Integer attachmentId) throws QException
{
QRecord row = requireMutable(attachmentId);
lifecycle.authorizeAdoption(new QRecord(row));
return (row);
}
/*******************************************************************************
** Adopt this exact visible original without uploading, copying or deleting
** storage. Registration retries retain the same owner attachment identity.
*******************************************************************************/
public AttachmentEntry adoptRetainedAttachment(Integer attachmentId) throws QException
{
new ExistingAttachmentAdoption(this, storage, lifecycle).adopt(attachmentId);
return (entry(requireRow(attachmentId)));
}
/*******************************************************************************
** Recover a visible retained upload without accepting bytes, locators or an
** actor from the client. Original-actor and current-parent checks are mandatory.
*******************************************************************************/
public AttachmentEntry recoverRetainedUpload(Integer journalId) throws QException
{
return (entry(new JournaledAttachmentUpload(storage, lifecycle, uploadJournal).recover(journalId)));
}
/*******************************************************************************
** Conditional single-request puts retain the same original across retries.
** Byte arrays deliberately avoid a multipart provider path whose conditional
** completion support is not qualified. The caller enforces a 20 MiB limit.
*******************************************************************************/
private void createIntakeObject(String bucket, String key, byte[] bytes, String type, String fingerprint) throws QException
{
try
{
storage.put(bucket, PutRequest.of(key, bytes).withContentLength((long) bytes.length).withContentType(AttachmentMediaPolicy.storedType(type))
.withContentDisposition("attachment").withIfNoneMatch(true).withMetadata(Map.of("qqq-intake-fingerprint", fingerprint)));
}
catch(RuntimeException uncertain)
{
try
{
if(storage.head(bucket, key).isEmpty())
{
throw (uncertain);
}
try(ObjectStream existing = storage.open(bucket, key))
{
byte[] original = existing.getInputStream().readNBytes(bytes.length + 1);
if(existing.getHeaders() == null || existing.getHeaders().getUserMetadata() == null
|| !fingerprint.equals(existing.getHeaders().getUserMetadata().get("qqq-intake-fingerprint")) || !MessageDigest.isEqual(original, bytes))
{
throw (new QException("An intake request cannot replace an existing original."));
}
}
}
catch(IOException | RuntimeException verificationFailure)
{
throw (new QException("Could not reconcile attachment intake storage.", verificationFailure));
}
}
}
/*******************************************************************************
** Internal lookup is keyed by authenticated actor and canonical parent scope.
*******************************************************************************/
private QRecord findIntake(String scopedKey) throws QException
{
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("intakeKey", QCriteriaOperator.EQUALS, scopedKey).withLimit(1));
return (rows.isEmpty() ? null : rows.getFirst());
}
/*******************************************************************************
** Reauthorize persisted identity and retry idempotent owner registration.
*******************************************************************************/
private AttachmentEntry replayIntake(QRecord row, String fingerprint) throws QException
{
if(Boolean.TRUE.equals(row.getValueBoolean("parentDeleted")))
{
throw (new QNotFoundException("Not found"));
}
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
if(!fingerprint.equals(row.getValueString("intakeFingerprint")))
{
throw (new QBadRequestException("Intake request conflicts with its original manifest."));
}
lifecycle.afterStored(new QRecord(row));
return (entry(row));
}
/*******************************************************************************
** Length-prefix all tuple members, including null, so delimiters cannot collide.
*******************************************************************************/
private String tupleHash(String... values) throws QException
{
try
{
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try(DataOutputStream output = new DataOutputStream(buffer))
{
for(String value : values)
{
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
output.writeInt(value == null ? -1 : bytes.length);
output.write(bytes);
}
}
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(buffer.toByteArray())));
}
catch(IOException | NoSuchAlgorithmException e)
{
throw (new QException("Could not identify attachment intake.", e));
}
}
/*******************************************************************************
** Newest first, independently of collaboration.view.
*******************************************************************************/
public List<AttachmentEntry> list(String tableName, Serializable recordId) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
String id = canonicalId(tableName, recordId);
List<AttachmentEntry> entries = new ArrayList<>();
for(QRecord row : AttachmentRecords.query(listFilter(tableName, recordId)))
for(QRecord row : AttachmentRecords.query(new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName)
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true)
.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false))))
{
entries.add(entry(row));
}
return (entries);
}
/*******************************************************************************
** Standard Files-tab pages use one bounded native query, with one same-parent
** lookahead row. Authorization is current on every request; offsets are not
** snapshots across concurrent insertions or deletions.
*******************************************************************************/
public AttachmentPage listPage(String tableName, Serializable recordId, int offset, int limit) throws QException
{
if(offset < 0 || limit < 1 || limit > 100)
{
throw (new QBadRequestException("Offset must be nonnegative and limit must be between 1 and 100."));
}
List<QRecord> rows = AttachmentRecords.query(listFilter(tableName, recordId).withSkip(offset).withLimit(limit + 1));
List<AttachmentEntry> entries = new ArrayList<>();
for(QRecord row : rows)
{
// Preserve the same metadata-policy evaluation for the lookahead row.
AttachmentEntry visible = entry(row);
if(entries.size() < limit)
{
entries.add(visible);
}
}
return (new AttachmentPage(List.copyOf(entries), rows.size() > limit));
}
/*******************************************************************************
** Only authorized display metadata and continuation, never a raw total.
*******************************************************************************/
public record AttachmentPage(List<AttachmentEntry> items, boolean hasMore)
{
}
/*******************************************************************************
** Shared canonical source authority, deletion filter and deterministic order.
*******************************************************************************/
private QQueryFilter listFilter(String tableName, Serializable recordId) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
String id = canonicalId(tableName, recordId);
return (new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, tableName)
.withCriteria("recordId", QCriteriaOperator.EQUALS, id).withCriteria("parentDeleted", QCriteriaOperator.NOT_EQUALS, true)
.withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)));
}
/*******************************************************************************
** Re-check the attachment's actual parent before signing a five-minute GET.
** Inline uses the stored inline header; RC.2 only overrides attachment names.
@@ -618,106 +211,17 @@ public class AttachmentsService
return (url(requireRow(attachmentId), inline));
}
/*******************************************************************************
** Standard download clients preserve the selected delivery authentication mode.
*******************************************************************************/
public AttachmentDownload downloadDelivery(Serializable attachmentId) throws QException
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
return (downloadDelivery(attachmentId, false));
}
/***************************************************************************
** Preserve the explicit transport mode for preview and download consumers.
***************************************************************************/
public AttachmentDownload downloadDelivery(Serializable attachmentId, boolean inline) throws QException
{
return (delivery(requireRow(attachmentId), inline));
}
/*******************************************************************************
** Read a bounded exact original for trusted integrations such as printing.
** Authorize the actual parent, enforce lifecycle policy and verify persisted
** length and SHA-256 before releasing any bytes. No signed URL is involved.
*******************************************************************************/
public AttachmentContent readContent(Serializable attachmentId, Integer maximumBytes) throws QException
{
QRecord row = requireRow(attachmentId);
requireUser();
requireRetainedPolicy(row);
Long size = row.getValueLong("sizeBytes");
String sha256 = row.getValueString("sha256");
if(maximumBytes == null || maximumBytes < 1 || maximumBytes > 20 * 1024 * 1024)
{
throw (new QBadRequestException("Content read limit must be between 1 byte and 20 MiB."));
}
if(size == null || size < 0 || size > maximumBytes || sha256 == null || !sha256.matches("[0-9a-f]{64}"))
{
throw (new QBadRequestException("Attachment exceeds the content read limit or lacks integrity metadata."));
}
lifecycle.beforeContentRead(new QRecord(row));
byte[] bytes;
try(ObjectStream content = storage.open(row.getValueString("bucketName"), row.getValueString("storageKey")))
{
if(content.getContentLength() != null && !size.equals(content.getContentLength()))
{
throw (new AttachmentContentIntegrityException());
}
bytes = content.getInputStream().readNBytes(size.intValue() + 1);
String actualHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
if(bytes.length != size.longValue() || !sha256.equals(actualHash))
{
throw (new AttachmentContentIntegrityException());
}
}
catch(IOException | NoSuchAlgorithmException | RuntimeException e)
{
throw (new QException("Could not read attachment content.", e));
}
lifecycle.afterContentRead(new QRecord(row), size);
return (new AttachmentContent(row.getValueInteger("id"), row.getValueString("fileName"), row.getValueString("contentType"), sha256, bytes));
}
/*******************************************************************************
** Sign an already-authorized row, also used by the preview customizer.
*******************************************************************************/
URL url(QRecord row, boolean inline) throws QException
URL url(QRecord row, boolean inline)
{
return (delivery(row, inline).url());
}
/*******************************************************************************
** Resolve only the configured owner policy, after exact row authorization.
*******************************************************************************/
AttachmentDownload delivery(QRecord row, boolean inline) throws QException
{
requireRetainedPolicy(row);
try
{
lifecycle.beforeAccess(new QRecord(row), inline);
}
catch(RuntimeException e)
{
// Preview generation may ignore storage failures, never policy failures.
throw (new QException("Attachment access policy failed.", e));
}
AttachmentDownload configured = lifecycle.downloadDelivery(new QRecord(row));
if(configured != null)
{
return (configured);
}
PresignGet request = PresignGet.of(row.getValueString("storageKey")).ttl(Duration.ofMinutes(5));
///////////////////////////////////////////////////////////////////////////////////////////
// Objects stored before AttachmentMediaPolicy keep their original headers (possibly an //
// active type served inline). The link therefore forces an attachment disposition for //
// every type that is not a known passive format, whatever the caller asked for; passive //
// previews stay inline. //
///////////////////////////////////////////////////////////////////////////////////////////
if(!inline || !"inline".equals(AttachmentMediaPolicy.disposition(row.getValueString("contentType"))))
if(!inline)
{
request.downloadAs(row.getValueString("fileName"));
}
return (new AttachmentDownload(storage.presign(row.getValueString("bucketName"), request), false));
return (storage.presign(row.getValueString("bucketName"), request));
}
/*******************************************************************************
@@ -729,7 +233,7 @@ public class AttachmentsService
List<QRecord> rows = new ArrayList<>();
for(Serializable id : attachmentIds.stream().distinct().toList())
{
rows.add(requireDeletable(id));
rows.add(requireMutable(id));
}
for(QRecord row : rows)
{
@@ -746,7 +250,6 @@ public class AttachmentsService
{
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
QRecord row = requireMutable(attachmentId);
requireRetainedPolicy(row);
QRecord change = new QRecord().withValue("id", row.getValue("id"));
if(fileName != null)
{
@@ -756,41 +259,10 @@ public class AttachmentsService
{
change.setValue("description", description);
}
lifecycle.beforeMetadataChange(new QRecord(row), new QRecord(change));
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withRecords(List.of(change));
input.setTableMetaData(AttachmentRecords.storageTable());
input.withPermissionBypass("AttachmentsService checked the actual parent and author/admin before metadata update.");
QBackendTransaction callerTransaction = QContext.getQBackendTransaction();
try(QBackendTransaction ownedTransaction = callerTransaction == null ? QBackendTransaction.openFor(input) : null)
{
QBackendTransaction transaction = ownedTransaction == null ? callerTransaction : ownedTransaction;
input.setTransaction(transaction);
try
{
lifecycle.validateMetadataTransaction(new QRecord(row), transaction);
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
lifecycle.afterMetadataChange(new QRecord(row), new QRecord(change), transaction);
if(ownedTransaction != null)
{
ownedTransaction.commit();
}
}
catch(QException | RuntimeException failure)
{
if(ownedTransaction != null)
{
try
{
ownedTransaction.rollback();
}
catch(QException rollbackFailure)
{
failure.addSuppressed(rollbackFailure);
}
}
throw (failure);
}
}
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
return (entry(requireRow(attachmentId)));
}
@@ -802,46 +274,6 @@ public class AttachmentsService
return (rename(attachmentId, null, description));
}
/*******************************************************************************
** Transfer a retained attachment's reference to an authorized archive parent.
** The caller owns the transaction and must roll it back on any failure. Bytes,
** hashes, author, storage coordinates and the retained flag never change.
*******************************************************************************/
public void transferRetainedReference(Serializable attachmentId, String tableName, Serializable recordId, QBackendTransaction transaction) throws QException
{
requireUser();
if(transaction == null || (QContext.getQBackendTransaction() != null && QContext.getQBackendTransaction() != transaction))
{
throw (new QException("Archive transfer requires the caller's explicit transaction."));
}
QRecord row = requireRow(attachmentId);
if(!Boolean.TRUE.equals(row.getValueBoolean("retained")) || tableName == null || recordId == null)
{
throw (new QException("Only a retained attachment can be transferred to an archive."));
}
var candidate = uploadJournal.findByLocation(row.getValueString("bucketName"), row.getValueString("storageKey"));
if(candidate.isPresent() && candidate.get().state() != com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalState.COMPLETE)
{
throw (new QException("Retained upload registration must complete before archival reference transfer."));
}
QRecord target = com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceSecurityHelper.visibleParent(tableName, recordId, transaction);
if(target == null)
{
throw (new QNotFoundException("Not found"));
}
String canonical = target.getValueString(QContext.getQInstance().getTable(tableName).getPrimaryKeyField());
lifecycle.beforeReferenceTransfer(new QRecord(row), tableName, canonical, transaction);
QRecord change = new QRecord().withValue("id", row.getValue("id")).withValue("tableName", tableName).withValue("recordId", canonical);
com.kingsrook.qqq.backend.core.actions.collaboration.RecordReferenceSecurityHelper.copyMirroredLocks(
QContext.getQInstance().getTable(tableName), target, QContext.getQInstance().getTable(CollaborationTables.ATTACHMENT), change);
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(transaction)
.withRecords(List.of(change));
input.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
input.withPermissionBypass("Installed retention owner authorized this exact archival reference transfer and transaction.");
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
lifecycle.afterReferenceTransfer(new QRecord(row), tableName, canonical, transaction);
}
/*******************************************************************************
** Process Review uses persisted identity rather than trusting client rows.
*******************************************************************************/
@@ -850,7 +282,7 @@ public class AttachmentsService
new com.kingsrook.qbits.collaboration.people.PeopleService().touch();
QRecord row = requireRow(attachmentId);
requireUser();
if(!canModify(row))
if(!canDelete(row))
{
throw (new QPermissionDeniedException("Only the author or collaboration.admin can change this attachment."));
}
@@ -859,17 +291,7 @@ public class AttachmentsService
}
/*******************************************************************************
** Deletion reviews must include retention as well as parent and ownership.
*******************************************************************************/
public QRecord requireDeletable(Serializable attachmentId) throws QException
{
QRecord row = requireMutable(attachmentId);
requireNotRetained(row);
return (row);
}
/*******************************************************************************
** Atomically remove metadata and retain a durable storage cleanup request.
** Delete storage first so a provider failure leaves a retryable row.
*******************************************************************************/
void deleteStoredRow(QRecord row) throws QException
{
@@ -881,20 +303,11 @@ public class AttachmentsService
*******************************************************************************/
void deleteStoredRow(QRecord row, boolean cleanup) throws QException
{
if(isRetained(row))
{
if(!cleanup)
{
throw (new QPermissionDeniedException("This attachment is retained and cannot be deleted."));
}
UpdateInput mark = new UpdateInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction())
.withRecords(List.of(new QRecord().withValue("id", row.getValue("id")).withValue("parentDeleted", true)));
mark.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
mark.withPermissionBypass("Trusted parent cleanup retains the original and marks its parent deleted.");
AttachmentRecords.checkErrors(new UpdateAction().execute(mark).getRecords());
return;
}
new AttachmentCleanupQueue(storage).delete(row, cleanup);
storage.delete(row.getValueString("bucketName"), row.getValueString("storageKey"));
DeleteInput input = new DeleteInput(CollaborationTables.ATTACHMENT).withTransaction(QContext.getQBackendTransaction()).withPrimaryKeys(List.of(row.getValue("id")));
input.setTableMetaData(cleanup ? com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT) : AttachmentRecords.storageTable());
input.withPermissionBypass("Attachment service authorized deletion, or trusted parent cleanup selected this row.");
AttachmentRecords.checkErrors(new DeleteAction().execute(input).getRecordsWithErrors());
}
/*******************************************************************************
@@ -923,7 +336,7 @@ public class AttachmentsService
/*******************************************************************************
** Ownership is layered on parent visibility; admins still need that visibility.
*******************************************************************************/
private boolean canModify(QRecord row)
private boolean canDelete(QRecord row)
{
String user = CollaborationSecurity.userId(QContext.getQSession());
return (user != null && (isAdmin() || user.equals(row.getValueString("createdByUserId"))));
@@ -953,53 +366,11 @@ public class AttachmentsService
/*******************************************************************************
** Return display data, without internal storage coordinates.
*******************************************************************************/
private AttachmentEntry entry(QRecord row) throws QException
private AttachmentEntry entry(QRecord row)
{
boolean canEdit = canModify(row) && !(Boolean.TRUE.equals(row.getValueBoolean("retained")) && lifecycle.getClass().equals(AttachmentLifecyclePolicy.class));
if(canEdit)
{
try
{
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
}
catch(QPermissionDeniedException e)
{
canEdit = false;
}
}
return (new AttachmentEntry(row.getValueInteger("id"), row.getValueString("tableName"), row.getValueString("recordId"),
row.getValueString("fileName"), row.getValueString("contentType"), row.getValueLong("sizeBytes"), row.getValueString("sha256"),
row.getValueString("description"), row.getValueString("createdByUserId"), row.getValueInstant("createDate"), canEdit && !isRetained(row), canEdit));
}
/*******************************************************************************
** Preflight every selected row before deleting any storage object.
*******************************************************************************/
private void requireNotRetained(QRecord row) throws QException
{
if(isRetained(row))
{
throw (new QPermissionDeniedException("This attachment is retained and cannot be deleted."));
}
}
/*******************************************************************************
** Persisted retention survives removal or reconfiguration of the provider.
*******************************************************************************/
private boolean isRetained(QRecord row) throws QException
{
return (Boolean.TRUE.equals(row.getValueBoolean("retained")) || lifecycle.isRetained(new QRecord(row)));
}
/*******************************************************************************
** Removing the configured provider must not permit unaudited retained access.
*******************************************************************************/
private void requireRetainedPolicy(QRecord row) throws QException
{
if(Boolean.TRUE.equals(row.getValueBoolean("retained")) && lifecycle.getClass().equals(AttachmentLifecyclePolicy.class))
{
throw (new QException("Retained attachment access requires its lifecycle policy."));
}
row.getValueString("description"), row.getValueString("createdByUserId"), row.getValueInstant("createDate"), canDelete(row)));
}
/*******************************************************************************
@@ -140,7 +140,7 @@ public final class DeleteAttachmentsProcess
{
try
{
QRecord checked = service().requireDeletable(row.getValue("id"));
QRecord checked = service().requireMutable(row.getValue("id"));
output.getRecords().add(checked);
selected.incrementCountAndAddPrimaryKey(checked.getValue("id"));
}
@@ -1,291 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
/*******************************************************************************
** Owner-only adoption of existing bytes. A durable intent precedes verification;
** retention and its audit commit together, then idempotent registration follows.
*******************************************************************************/
final class ExistingAttachmentAdoption
{
private static final long MAX_BYTES = 20L * 1024 * 1024;
private final AttachmentsService service;
private final AttachmentStorage storage;
private final AttachmentLifecyclePolicy lifecycle;
/***************************************************************************
** Dependencies are the actual configured owner boundaries.
***************************************************************************/
ExistingAttachmentAdoption(AttachmentsService service, AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle)
{
this.service = service;
this.storage = storage;
this.lifecycle = lifecycle;
}
/***************************************************************************
** No external transaction, caller locator, bytes, receipt or force option.
***************************************************************************/
void adopt(Integer id) throws QException
{
var instance = QContext.getQInstance();
var session = QContext.getQSession();
String actor = CollaborationSecurity.userId(session);
Check check = () ->
{
if(QContext.getQBackendTransaction() != null || QContext.getQInstance() != instance || QContext.getQSession() != session
|| actor == null || !actor.equals(CollaborationSecurity.userId(session)))
{
throw (new QException("Adoption requires the unchanged initiating context and its own transaction."));
}
};
check.run();
QRecord original = service.requireMutable(id);
if(original.getValueString("intakeKey") != null)
{
throw (new QException("Coordinated intake must recover through its original request."));
}
requireIntegrity(original);
lifecycle.authorizeAdoption(new QRecord(original));
check.run();
lifecycle.beforeAdoption(new QRecord(original));
check.run();
same(original, service.requireMutable(id));
QRecord retained = new QRecord(original).withValue("retained", true);
UpdateInput input = new UpdateInput(CollaborationTables.ATTACHMENT)
.withRecords(List.of(new QRecord().withValue("id", id).withValue("retained", true)));
input.setTableMetaData(AttachmentRecords.storageTable());
input.withPermissionBypass("Attachment owner rechecked current parent, author/admin and audited retention policy.");
try(QBackendTransaction transaction = QBackendTransaction.openFor(input))
{
try
{
lifecycle.validateMetadataTransaction(retained, transaction);
check.run();
lock(transaction, id);
same(original, read(transaction, id));
verify(original);
check.run();
same(original, service.requireMutable(id));
lifecycle.authorizeAdoption(new QRecord(original));
check.run();
if(!Boolean.TRUE.equals(original.getValueBoolean("retained")))
{
input.setTransaction(transaction);
AttachmentRecords.checkErrors(new UpdateAction().execute(input).getRecords());
lifecycle.afterAdoption(retained, transaction);
}
check.run();
service.requireMutable(id);
lifecycle.authorizeAdoption(new QRecord(retained));
check.run();
transaction.commit();
}
catch(QException | RuntimeException | Error failure)
{
try
{
transaction.rollback();
}
catch(QException | RuntimeException cleanup)
{
if(cleanup != failure)
{
failure.addSuppressed(cleanup);
}
}
throw failure;
}
}
check.run();
same(retained, service.requireMutable(id));
lifecycle.authorizeAdoption(new QRecord(retained));
check.run();
lifecycle.afterStored(new QRecord(retained));
check.run();
same(retained, service.requireMutable(id));
lifecycle.authorizeAdoption(new QRecord(retained));
check.run();
}
/***************************************************************************
** Native metadata supplies identifiers; no caller text becomes SQL.
***************************************************************************/
private void lock(QBackendTransaction transaction, Integer id) throws QException
{
if(!(transaction instanceof RDBMSTransaction jdbc))
{
throw (new QException("Existing-original adoption requires a real relational transaction."));
}
var table = AttachmentRecords.storageTable();
String tableName = table.getBackendDetails() instanceof RDBMSTableBackendDetails details ? details.getTableName() : table.getName();
String key = table.getField(table.getPrimaryKeyField()).getBackendName();
key = key == null ? table.getPrimaryKeyField() : key;
if(tableName == null || !tableName.matches("[A-Za-z_][A-Za-z0-9_]*") || !key.matches("[A-Za-z_][A-Za-z0-9_]*"))
{
throw (new QException("Unsupported attachment storage mapping."));
}
try
{
String quote = jdbc.getConnection().getMetaData().getIdentifierQuoteString().trim();
if(!List.of("\"", "`").contains(quote))
{
throw (new QException("Unsupported attachment identifier quoting."));
}
try(var statement = jdbc.getConnection().prepareStatement("SELECT " + quote + key + quote + " FROM " + quote + tableName + quote
+ " WHERE " + quote + key + quote + " = ? FOR UPDATE"))
{
statement.setQueryTimeout(5);
statement.setObject(1, id);
try(var result = statement.executeQuery())
{
if(!result.next())
{
throw (new QNotFoundException("Not found"));
}
}
}
}
catch(java.sql.SQLException failure)
{
throw (new QException("Could not lock the original attachment.", failure));
}
}
/***************************************************************************
** Read the locked original in the same transaction without URL customizers.
***************************************************************************/
private QRecord read(QBackendTransaction transaction, Integer id) throws QException
{
QueryInput input = new QueryInput(CollaborationTables.ATTACHMENT).withTransaction(transaction).withShouldFetchHeavyFields(true)
.withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, id).withLimit(1));
input.setTableMetaData(AttachmentRecords.storageTable());
input.withPermissionBypass("Owner adoption already authorized the exact parent and attachment.");
var rows = new QueryAction().execute(input).getRecords();
if(rows.size() != 1)
{
throw (new QNotFoundException("Not found"));
}
return (rows.getFirst());
}
/***************************************************************************
** Compare persisted metadata, allowing only the native modification timestamp.
** The caller supplies the exact expected retention state at each boundary.
***************************************************************************/
private void same(QRecord expected, QRecord actual) throws QException
{
for(var field : AttachmentRecords.storageTable().getFields().keySet())
{
if(!"modifyDate".equals(field) && !Objects.equals(expected.getValue(field), actual.getValue(field)))
{
throw (new QException("Attachment metadata changed during adoption."));
}
}
}
/***************************************************************************
** Reject ambiguous or oversized originals before opening storage.
***************************************************************************/
private void requireIntegrity(QRecord row) throws QException
{
Long size = row.getValueLong("sizeBytes");
if(size == null || size < 0 || size > MAX_BYTES || row.getValueString("sha256") == null
|| !row.getValueString("sha256").matches("[0-9a-f]{64}"))
{
throw (new QException("Original integrity metadata is missing or outside the adoption bound."));
}
}
/***************************************************************************
** Stream and hash the existing original without releasing or writing bytes.
***************************************************************************/
private void verify(QRecord row) throws QException
{
try(ObjectStream object = storage.open(row.getValueString("bucketName"), row.getValueString("storageKey")))
{
long expected = row.getValueLong("sizeBytes");
if(object.getContentLength() != null && object.getContentLength() != expected)
{
throw (new AttachmentContentIntegrityException());
}
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[8192];
long total = 0;
int count;
while((count = object.getInputStream().read(buffer, 0, (int) Math.min(buffer.length, expected - total + 1))) != -1)
{
total += count;
if(total > expected)
{
throw (new AttachmentContentIntegrityException());
}
digest.update(buffer, 0, count);
}
if(total != expected || !row.getValueString("sha256").equals(HexFormat.of().formatHex(digest.digest())))
{
throw (new AttachmentContentIntegrityException());
}
}
catch(IOException | NoSuchAlgorithmException | RuntimeException failure)
{
throw (new QException("Could not verify the existing original.", failure));
}
}
/***************************************************************************
** Checked context guard without installing a different session or authority.
***************************************************************************/
@FunctionalInterface
private interface Check
{
/***********************************************************************
** Recheck the initiating context after external callbacks.
***********************************************************************/
void run() throws QException;
}
}
@@ -1,305 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.io.IOException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadCandidate;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadEntry;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournal;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalState;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadReservation;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadStorageState;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Retained-only coordination of one immutable original and its durable owner
** manifest. All database ownership stays in Collaboration; lifecycle registration
** may commit in its own module after the attachment checkpoint is durable.
*******************************************************************************/
final class JournaledAttachmentUpload
{
/*******************************************************************************
** The caller can avoid repeating non-mandatory timeline activity on replay.
*******************************************************************************/
record Receipt(QRecord attachment, boolean alreadyComplete) {}
static final long MAX_BYTES = 20L * 1024 * 1024;
private static final Duration LEASE = Duration.ofMinutes(5);
private final AttachmentStorage storage;
private final AttachmentLifecyclePolicy lifecycle;
private final UploadJournal journal;
/*******************************************************************************
** Explicit owner dependencies; the journal never receives file bytes.
*******************************************************************************/
JournaledAttachmentUpload(AttachmentStorage storage, AttachmentLifecyclePolicy lifecycle, UploadJournal journal)
{
this.storage = storage;
this.lifecycle = lifecycle;
this.journal = journal;
}
/*******************************************************************************
** Persist intent before any PUT. Conditional creation never replaces an
** original. A late or expired PUT cannot authorize metadata registration.
*******************************************************************************/
Receipt store(QRecord row, byte[] bytes) throws QException
{
requireIndependent();
UploadCandidate candidate = candidate(row);
authorize(candidate);
if(bytes.length > MAX_BYTES || bytes.length != candidate.sizeBytes() || !hash(bytes).equals(candidate.sha256()))
{
throw (new QException("Retained original exceeds the qualified bound or changed before storage."));
}
UploadEntry entry = journal.prepare(candidate);
if(entry.state() == UploadJournalState.COMPLETE)
{
QRecord existing = stored(entry);
lifecycle.afterStored(new QRecord(existing));
return (new Receipt(existing, true));
}
UploadReservation reservation = journal.reserve(entry.id(), LEASE);
try
{
lifecycle.beforeStored(new QRecord(row));
reservation = journal.renew(reservation, LEASE);
if(entry.storageState() != UploadStorageState.VERIFIED)
{
journal.recordStorage(reservation, UploadStorageState.UNKNOWN);
try
{
storage.put(candidate.bucketName(), PutRequest.of(candidate.storageKey(), bytes).withContentLength(candidate.sizeBytes())
.withContentType(candidate.contentType()).withContentDisposition("attachment").withIfNoneMatch(true)
.withMetadata(Map.of("qqq-upload-manifest", candidate.manifestHash())));
}
catch(RuntimeException uncertain)
{
try
{
verify(candidate);
}
catch(QException verification)
{
verification.addSuppressed(uncertain);
throw (verification);
}
}
}
else
{
verify(candidate);
}
reservation = journal.renew(reservation, LEASE);
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
return (new Receipt(register(reservation), false));
}
finally
{
journal.release(reservation);
}
}
/*******************************************************************************
** Standard recovery reads an already stored original and never writes bytes.
** Coordinated intake must retry through its original coordinator and scope.
*******************************************************************************/
QRecord recover(Integer journalId) throws QException
{
requireIndependent();
UploadEntry entry = journal.find(journalId).orElseThrow(() -> new QNotFoundException("Not found"));
if(!PersonalRecords.currentUserId().equals(entry.candidate().actorId()))
{
throw (new QNotFoundException("Not found"));
}
if(entry.state() == UploadJournalState.COMPLETE)
{
QRecord row = stored(entry);
ParentRecordAccess.requireFeature(row.getValueString("tableName"), row.getValueString("recordId"), "attach");
return (row);
}
authorize(entry.candidate());
if(entry.candidate().intakeKey() != null)
{
throw (new QException("Retry this upload through its original intake request to preserve its registration context."));
}
UploadReservation reservation = journal.reserve(entry.id(), LEASE);
try
{
lifecycle.beforeRecovery(row(entry.candidate()));
verify(entry.candidate());
reservation = journal.renew(reservation, LEASE);
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
return (register(reservation));
}
finally
{
journal.release(reservation);
}
}
/*******************************************************************************
** Attachment and journal checkpoint commit together. Replays use the same
** immutable attachment id and retry the mandatory idempotent lifecycle hook.
*******************************************************************************/
private QRecord register(UploadReservation reservation) throws QException
{
authorize(reservation.candidate());
UploadEntry entry = journal.register(reservation, (transaction, candidate, journalId) ->
{
InsertInput input = new InsertInput(CollaborationTables.ATTACHMENT).withTransaction(transaction).withRecords(List.of(row(candidate)));
input.withPermissionBypass("Retained owner journal reauthorized the original actor and current parent attach capability.");
List<QRecord> records = new InsertAction().execute(input).getRecords();
AttachmentRecords.checkErrors(records);
if(records.size() != 1)
{
throw (new QException("Retained registration did not create exactly one owner row."));
}
return (records.getFirst().getValueInteger("id"));
});
QRecord stored = stored(entry);
lifecycle.afterStored(new QRecord(stored));
reservation = journal.renew(reservation, LEASE);
journal.complete(reservation);
return (stored);
}
/*******************************************************************************
** Exact original bytes must be readable; missing or ambiguous storage never
** becomes a cancellation or permission to delete the source record.
*******************************************************************************/
private void verify(UploadCandidate candidate) throws QException
{
if(candidate.sizeBytes() > MAX_BYTES)
{
throw (new QException("Retained recovery exceeds the qualified content bound."));
}
try(ObjectStream object = storage.open(candidate.bucketName(), candidate.storageKey()))
{
byte[] bytes = object.getInputStream().readNBytes(Math.toIntExact(candidate.sizeBytes()) + 1);
if(bytes.length != candidate.sizeBytes() || !hash(bytes).equals(candidate.sha256()))
{
throw (new QException("Stored original does not match its immutable journal manifest."));
}
}
catch(IOException | RuntimeException failure)
{
throw (new QException("Original storage remains unresolved; no absence or deletion is inferred.", failure));
}
}
/*******************************************************************************
** Resolve the checkpoint without exposing storage coordinates to clients.
*******************************************************************************/
private QRecord stored(UploadEntry entry) throws QException
{
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, entry.attachmentId()).withLimit(1));
if(rows.size() != 1 || !entry.candidate().sha256().equals(rows.getFirst().getValueString("sha256"))
|| !entry.candidate().sizeBytes().equals(rows.getFirst().getValueLong("sizeBytes"))
|| !entry.candidate().actorId().equals(rows.getFirst().getValueString("createdByUserId")))
{
throw (new QException("Retained journal checkpoint does not match its immutable attachment."));
}
return (rows.getFirst());
}
/*******************************************************************************
** Authorization is current and actor-specific; the manifest cannot impersonate.
*******************************************************************************/
private void authorize(UploadCandidate candidate) throws QException
{
if(!PersonalRecords.currentUserId().equals(candidate.actorId()))
{
throw (new QNotFoundException("Not found"));
}
ParentRecordAccess.requireFeature(candidate.tableName(), candidate.recordId(), "attach");
}
/*******************************************************************************
** Retained registration cannot participate in an unrelated caller transaction.
*******************************************************************************/
private void requireIndependent() throws QException
{
if(QContext.getQBackendTransaction() != null)
{
throw (new QException("Retained upload recovery requires its own committed transactions."));
}
}
/*******************************************************************************
** Server-owned values only; no client-supplied locator or actor is accepted.
*******************************************************************************/
private UploadCandidate candidate(QRecord row)
{
return (new UploadCandidate(row.getValueString("tableName"), row.getValueString("recordId"), row.getValueString("createdByUserId"),
row.getValueString("fileName"), row.getValueString("contentType"), row.getValueString("description"), row.getValueLong("sizeBytes"),
row.getValueString("sha256"), row.getValueString("bucketName"), row.getValueString("storageKey"), row.getValueBoolean("retained"),
row.getValueString("intakeKey"), row.getValueString("intakeFingerprint")));
}
/*******************************************************************************
** Reconstruct only owner metadata from the immutable saved manifest.
*******************************************************************************/
private QRecord row(UploadCandidate candidate)
{
return (new QRecord().withValue("tableName", candidate.tableName()).withValue("recordId", candidate.recordId())
.withValue("createdByUserId", candidate.actorId()).withValue("fileName", candidate.fileName()).withValue("contentType", candidate.contentType())
.withValue("description", candidate.description()).withValue("sizeBytes", candidate.sizeBytes()).withValue("sha256", candidate.sha256())
.withValue("bucketName", candidate.bucketName()).withValue("storageKey", candidate.storageKey()).withValue("retained", true)
.withValue("parentDeleted", false).withValue("intakeKey", candidate.intakeKey()).withValue("intakeFingerprint", candidate.intakeFingerprint()));
}
/*******************************************************************************
** SHA-256 of actual bounded bytes, never a storage metadata assertion.
*******************************************************************************/
private String hash(byte[] bytes) throws QException
{
try
{
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)));
}
catch(NoSuchAlgorithmException failure)
{
throw (new QException("SHA-256 is unavailable.", failure));
}
}
}
@@ -1,123 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalTable;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
/*******************************************************************************
** A visible native recovery action for retained original registration. It never
** accepts replacement bytes, a storage path, an actor, or an intake scope.
*******************************************************************************/
public final class RecoverRetainedUploadProcess
{
public static final String NAME = "collaborationRecoverRetainedUpload";
/*******************************************************************************
** Metadata utility.
*******************************************************************************/
private RecoverRetainedUploadProcess()
{
}
/*******************************************************************************
** Standard QQQ confirmation and result screens, attached to the journal table.
*******************************************************************************/
public static QProcessMetaData metadata()
{
return (new QProcessMetaData().withName(NAME).withTableName(UploadJournalTable.NAME).withLabel("Recover retained upload")
.withMinInputRecords(1).withMaxInputRecords(1).withPermissionRules(CollaborationPermissions.permissionRules())
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
.withInputData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData()
.withRecordListMetaData(new com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData().withTableName(UploadJournalTable.NAME))))
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("confirm").withLabel("Recover the original upload")
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.EDIT_FORM))
.withFormField(new QFieldMetaData("confirmRecovery", QFieldType.BOOLEAN).withLabel("Verify the stored original and retry registration").withDefaultValue(false).withIsRequired(true)))
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData().withName("recover").withCode(new QCodeReference(Recover.class)))
.withStep(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData().withName("result").withLabel("Original registration saved")
.withComponent(new com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData()
.withType(com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType.VIEW_FORM))
.withViewField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Retained attachment")
.withPossibleValueSourceName(com.kingsrook.qbits.collaboration.model.CollaborationTables.ATTACHMENT))));
}
/*******************************************************************************
** Resolve one visible journal selection before displaying confirmation.
*******************************************************************************/
public static final class Prepare implements BackendStep
{
/*******************************************************************************
** Read-only preview under current actor and parent visibility.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(input.getRecords() == null || input.getRecords().size() != 1)
{
throw (new QException("Select exactly one retained upload."));
}
var row = new GetAction().execute(new GetInput(UploadJournalTable.NAME).withPrimaryKey(input.getRecords().getFirst().getValue("id"))).getRecord();
if(row == null)
{
throw (new QException("Retained upload is unavailable."));
}
if(row.getValueString("intakeKey") != null)
{
throw (new QException("Retry this upload through its original intake request to preserve its context."));
}
output.addValue("journalId", row.getValue("id"));
output.addValue("confirmRecovery", false);
}
}
/*******************************************************************************
** Execution reauthorizes independently of the earlier preview.
*******************************************************************************/
public static final class Recover implements BackendStep
{
/*******************************************************************************
** The owner returns only after mandatory lifecycle registration completes.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
if(!Boolean.TRUE.equals(input.getValue("confirmRecovery")))
{
throw (new QException("Confirm retained upload recovery to continue."));
}
output.addValue("attachmentId", new AttachmentsService().recoverRetainedUpload(input.getValueInteger("journalId")).id());
}
}
}
@@ -1,227 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ExtractViaQueryStep;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
/*******************************************************************************
** Explicit provider settlement through the standard table action and Review flow.
*******************************************************************************/
public class ResolveAttachmentUploadProcess implements CustomPermissionChecker
{
public static final String NAME = "collaborationResolveAttachmentUpload";
public static final String EVIDENCE = "settlementEvidence";
/*******************************************************************************
** Administrative table action, never a navigation entry or scheduled process.
*******************************************************************************/
public static QProcessMetaData metadata()
{
Map<String, Serializable> values = new HashMap<>();
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, CollaborationTables.ATTACHMENT_CLEANUP);
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, CollaborationTables.ATTACHMENT_CLEANUP);
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE,
"Confirm that the provider has completed or cancelled every selected upload. Object absence or elapsed time alone is not proof. Current bytes will be deleted; the settlement receipt will be retained.");
return (StreamedETLWithFrontendProcess.defineProcessMetaData(ExtractViaQueryStep.class, Review.class, Apply.class, values)
.withName(NAME).withLabel("Resolve settled uploads").withTableName(CollaborationTables.ATTACHMENT_CLEANUP).withIcon(new QIcon("check"))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(new QCodeReference(ResolveAttachmentUploadProcess.class)))
.withStep(0, new QFrontendStepMetaData().withName("evidence").withLabel("Confirm provider settlement")
.withFormField(new QFieldMetaData(EVIDENCE, QFieldType.STRING).withLabel("Provider completion or cancellation evidence")
.withMaxLength(2000).withIsRequired(true))
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))));
}
/*******************************************************************************
** Guard direct process invocation as well as menu visibility.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
AttachmentCleanupQueue.requireSettlementOperator();
}
/*******************************************************************************
** Shared standard summary language for Review and Result.
*******************************************************************************/
private static ProcessSummaryLine line(Status status, String future, String past)
{
ProcessSummaryLine line = new ProcessSummaryLine(status);
line.setSingularFutureMessage(future);
line.setPluralFutureMessage(future);
line.setSingularPastMessage(past);
line.setPluralPastMessage(past);
return (line);
}
/*******************************************************************************
** Review never changes provider state or records a completed settlement.
*******************************************************************************/
public static class Review extends AbstractTransformStep implements ProcessSummaryProviderInterface
{
private final ProcessSummaryLine selected = line(Status.OK, "uploads will be resolved", "uploads selected");
private final ProcessSummaryLine skipped = line(Status.WARNING, "resolved records or committed deletions will be skipped", "records skipped");
/*******************************************************************************
** Require evidence before allowing the administrator to review selected rows.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AttachmentCleanupQueue.requireSettlementOperator();
AttachmentCleanupQueue.validateEvidence(input.getValueString(EVIDENCE));
for(QRecord row : input.getRecords())
{
if(row.getValue("attachmentId") != null || row.getValue("resolvedAt") != null)
{
skipped.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
else
{
output.getRecords().add(row);
selected.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
}
}
/*******************************************************************************
** Keep streamed totals across pages.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
selected.addSelfToListIfAnyCount(lines);
skipped.addSelfToListIfAnyCount(lines);
return (lines);
}
}
/*******************************************************************************
** Apply checks current authorization, row ownership and references again.
*******************************************************************************/
public static class Apply extends AbstractLoadStep implements ProcessSummaryProviderInterface
{
private final AttachmentCleanupQueue queue;
private final ProcessSummaryLine done = line(Status.OK, "uploads will be resolved", "uploads resolved");
private final ProcessSummaryLine failed = line(Status.ERROR, "uploads cannot be resolved", "uploads unresolved; inspect and retry");
/*******************************************************************************
** Construction is also used during context-free metadata validation.
*******************************************************************************/
public Apply()
{
this(new AttachmentCleanupQueue());
}
/*******************************************************************************
** Use the same service with a fixture storage provider in process tests.
*******************************************************************************/
public Apply(AttachmentCleanupQueue queue)
{
this.queue = queue;
}
/*******************************************************************************
** Each locator has its own transaction and immutable settlement receipt.
*******************************************************************************/
@Override
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
AttachmentCleanupQueue.requireSettlementOperator();
AttachmentCleanupQueue.validateEvidence(input.getValueString(EVIDENCE));
for(QRecord row : input.getRecords())
{
try
{
queue.resolveUpload(row.getValue("id"), input.getValueString(EVIDENCE));
done.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
catch(QException | RuntimeException failure)
{
failed.incrementCountAndAddPrimaryKey(row.getValue("id"));
}
}
}
/*******************************************************************************
** Report actual application results and retain review exclusions.
*******************************************************************************/
@Override
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
{
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
done.addSelfToListIfAnyCount(lines);
failed.addSelfToListIfAnyCount(lines);
if(getTransformStep() instanceof Review review)
{
review.skipped.addSelfToListIfAnyCount(lines);
}
return (lines);
}
}
}
@@ -1,99 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QScheduleMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
/*******************************************************************************
** Scheduled durable attachment cleanup. Runs with its caller's session, which must be a
** scheduler system session or collaboration admin; never elevates a user.
*******************************************************************************/
public class RetryAttachmentCleanupProcess implements BackendStep, CustomPermissionChecker
{
public static final String NAME = "collaborationRetryAttachmentCleanup";
/*******************************************************************************
** Always register the backend-only process; scheduling is opt-in.
*******************************************************************************/
public static QProcessMetaData metadata(CollaborationQBitConfig config)
{
QProcessMetaData process = new QProcessMetaData().withName(NAME).withLabel("Retry attachment cleanup").withIsHidden(true).withIcon(new QIcon("history"))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
.withPermissionBaseName("collaboration").withCustomPermissionChecker(new QCodeReference(RetryAttachmentCleanupProcess.class)))
.withStep(new QBackendStepMetaData().withName("run").withCode(new QCodeReference(RetryAttachmentCleanupProcess.class)));
if(config.getSchedulerName() != null)
{
process.withSchedule(new QScheduleMetaData().withSchedulerName(config.getSchedulerName()).withRepeatSeconds(60).withInitialDelaySeconds(60));
}
return (process);
}
/*******************************************************************************
** Hidden is a presentation setting, so explicitly guard execution as well.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
QSession session = QContext.getQSession();
if(!(session instanceof QSystemUserSession) && (session == null || !session.hasPermission(CollaborationPermissions.ADMIN)))
{
throw (new QPermissionDeniedException("This needs the collaboration.admin permission."));
}
}
/*******************************************************************************
** Retry committed cleanup intents after provider or process recovery.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
checkPermissionsThrowing(input, null);
new AttachmentCleanupQueue().retryPending();
}
}
@@ -1,799 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.TimeZone;
import java.util.UUID;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalException.Reason;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.module.rdbms.RDBMSBackendModule;
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** Real JDBC implementation for file-backed H2 with WRITE_DELAY=0 and explicitly
** qualified MySQL 8.4/InnoDB and PostgreSQL 17 connections, with the
** journal and the attachment table on the same backend. This is not a portable
** production database claim; any other backend, a memory database, a no-op
** transaction or an ambient caller transaction is rejected before any write.
**
** Every operation opens and owns its own transaction (commit on success, rollback
** on any failure; no partial success). Mutations lock the row with a bounded
** FOR UPDATE, verify the lease token, fence and expiry, and then also guard the
** UPDATE with the same token and fence and require exactly one row. SQL uses only
** the fixed owner table name and literal text; all values are bound parameters.
** Nothing here ever deletes a row or an object, and it never touches bytes.
**
** Expiry uses this JVM's clock. Hosts must keep lease durations well above their
** clock skew; the fence, not the clock, is what stops a stale holder from
** writing once a newer reservation exists.
**
** Runtime checks cannot qualify a deployment's disks, backups or failover policy.
*******************************************************************************/
public final class JdbcUploadJournal implements UploadJournal
{
private static final String TABLE = CollaborationTables.UPLOAD_JOURNAL;
private static final int QUERY_TIMEOUT_SECONDS = 10;
private static final int MAXIMUM_LIST = 500;
private static final Duration MINIMUM_LEASE = Duration.ofSeconds(1);
private static final Duration MAXIMUM_LEASE = Duration.ofMinutes(30);
private static final String COLUMNS = "id, table_name, record_id, created_by_user_id, file_name, content_type, description, size_bytes, sha256,"
+ " bucket_name, storage_key, retained, intake_key, intake_fingerprint, locator_hash, manifest_hash, state, storage_state, attachment_id,"
+ " lease_token, lease_until, fence";
private static final String SELECT_ALL = "SELECT " + COLUMNS + " FROM " + TABLE;
private static final String BY_ID = SELECT_ALL + " WHERE id = ?";
private static final String BY_ID_LOCKED = BY_ID + " FOR UPDATE";
private static final String BY_LOCATOR = SELECT_ALL + " WHERE locator_hash = ?";
private static final String BY_INTAKE = SELECT_ALL + " WHERE intake_key = ?";
private static final String INCOMPLETE = SELECT_ALL + " WHERE state <> 'COMPLETE' ORDER BY id LIMIT ?";
private static final String INSERT = "INSERT INTO " + TABLE + " (create_date, modify_date, table_name, record_id, created_by_user_id, file_name,"
+ " content_type, description, size_bytes, sha256, bucket_name, storage_key, retained, intake_key, intake_fingerprint, locator_hash,"
+ " manifest_hash, state, storage_state, fence) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PREPARED', 'PENDING', 0)";
private static final String GRANT = "UPDATE " + TABLE + " SET lease_token = ?, lease_until = ?, fence = ?, modify_date = ? WHERE id = ? AND fence = ?";
private static final String SET_STORAGE = "UPDATE " + TABLE + " SET storage_state = ?, modify_date = ? WHERE id = ? AND lease_token = ? AND fence = ?";
private static final String SET_REGISTERED = "UPDATE " + TABLE + " SET state = 'REGISTERED', attachment_id = ?, modify_date = ?"
+ " WHERE id = ? AND lease_token = ? AND fence = ? AND state = 'PREPARED'";
private static final String SET_COMPLETE = "UPDATE " + TABLE + " SET state = 'COMPLETE', lease_token = NULL, lease_until = NULL, modify_date = ?"
+ " WHERE id = ? AND lease_token = ? AND fence = ? AND state = 'REGISTERED'";
private static final String CLEAR_LEASE = "UPDATE " + TABLE + " SET lease_token = NULL, lease_until = NULL, modify_date = ?"
+ " WHERE id = ? AND lease_token = ? AND fence = ?";
private final Clock clock;
/*******************************************************************************
** Journal using the system clock.
*******************************************************************************/
public JdbcUploadJournal()
{
this(Clock.systemUTC());
}
/*******************************************************************************
** Package-local deterministic clock seam for lease-expiry tests.
*******************************************************************************/
JdbcUploadJournal(Clock clock)
{
this.clock = Objects.requireNonNull(clock, "clock");
}
/*******************************************************************************
** One database transaction's worth of work.
*******************************************************************************/
@FunctionalInterface
private interface Work<T>
{
/*******************************************************************************
** Run inside the owned, qualified transaction.
*******************************************************************************/
T run(RDBMSTransaction transaction, Connection connection) throws SQLException, QException;
}
/*******************************************************************************
** A locked or read row with the journal-private secrets.
*******************************************************************************/
private record Row(UploadEntry entry, String leaseToken, String manifestHash)
{
}
/*******************************************************************************
** Static admission of file H2, native mysql, or the native postgres backend
** (vendor and backend type both "postgres"). Actual engine, catalog, table
** storage and durability checks additionally run in every transaction.
*******************************************************************************/
public static boolean isQualifiedBackend(QBackendMetaData backend)
{
if(backend instanceof RDBMSBackendMetaData postgres && PostgresJournalDatabase.is(postgres))
{
return (true);
}
return (backend instanceof RDBMSBackendMetaData metadata && RDBMSBackendModule.NAME.equals(metadata.getBackendType())
&& ("mysql".equals(metadata.getVendor()) || ("h2".equals(metadata.getVendor())
&& metadata.getJdbcUrl() != null && metadata.getJdbcUrl().startsWith("jdbc:h2:file:"))));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public UploadEntry prepare(UploadCandidate candidate) throws QException
{
Objects.requireNonNull(candidate, "candidate");
try
{
return (inTransaction((transaction, connection) -> insertOrReplay(connection, candidate)));
}
catch(UploadJournalException e)
{
if(e.getReason() != Reason.DUPLICATE)
{
throw (e);
}
// Lost a unique-key race: the winner committed, so read its row and compare.
return (inTransaction((transaction, connection) ->
{
UploadEntry existing = existing(connection, candidate);
if(existing == null)
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The journal reported a duplicate but no matching row exists."));
}
return (existing);
}));
}
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public UploadReservation reserve(Integer journalId, Duration lease) throws QException
{
if(lease == null || lease.compareTo(MINIMUM_LEASE) < 0 || lease.compareTo(MAXIMUM_LEASE) > 0)
{
throw (new IllegalArgumentException("The lease must be between 1 second and 30 minutes."));
}
return (inTransaction((transaction, connection) ->
{
Row row = lock(connection, journalId);
Instant now = now();
if(row.entry().state() == UploadJournalState.COMPLETE)
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "This upload is already complete."));
}
if(row.entry().isLeased(now))
{
throw (new UploadJournalException(Reason.LEASE_HELD, "Another holder has a live reservation on this upload."));
}
String token = UUID.randomUUID().toString();
Long fence = row.entry().fence() + 1;
Instant until = now.plus(lease);
if(update(connection, GRANT, token, Timestamp.from(until), fence, Timestamp.from(now), journalId, row.entry().fence()) != 1)
{
throw (new UploadJournalException(Reason.LEASE_HELD, "Another holder took this reservation first."));
}
return (new UploadReservation(row.entry().leased(fence, until), token, fence, until));
}));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public UploadEntry recordStorage(UploadReservation reservation, UploadStorageState storageState) throws QException
{
Objects.requireNonNull(reservation, "reservation");
if(storageState == null || storageState == UploadStorageState.PENDING)
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Storage knowledge can only advance to UNKNOWN or VERIFIED."));
}
return (inTransaction((transaction, connection) ->
{
Row row = requireHolder(connection, reservation);
UploadStorageState current = row.entry().storageState();
if(current == storageState)
{
return (row.entry());
}
if(current == UploadStorageState.VERIFIED)
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Verified storage cannot move back to UNKNOWN."));
}
if(update(connection, SET_STORAGE, storageState.name(), Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
{
throw (stale());
}
return (read(connection, reservation.journalId()));
}));
}
/*******************************************************************************
** Renew at synchronous stage boundaries without reviving an expired holder.
*******************************************************************************/
@Override
public UploadReservation renew(UploadReservation reservation, Duration lease) throws QException
{
if(lease == null || lease.compareTo(MINIMUM_LEASE) < 0 || lease.compareTo(MAXIMUM_LEASE) > 0)
{
throw (new IllegalArgumentException("The lease must be between 1 second and 30 minutes."));
}
return (inTransaction((transaction, connection) ->
{
Row row = requireHolder(connection, reservation);
Instant until = now().plus(lease);
if(update(connection, "UPDATE " + TABLE + " SET lease_until=?, modify_date=? WHERE id=? AND lease_token=? AND fence=?",
Timestamp.from(until), Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
{
throw (stale());
}
return (new UploadReservation(row.entry().leased(reservation.fence(), until), reservation.leaseToken(), reservation.fence(), until));
}));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public UploadEntry register(UploadReservation reservation, AttachmentRegistrar registrar) throws QException
{
Objects.requireNonNull(reservation, "reservation");
Objects.requireNonNull(registrar, "registrar");
return (inTransaction((transaction, connection) ->
{
Row row = requireHolder(connection, reservation);
if(row.entry().state() != UploadJournalState.PREPARED)
{
return (row.entry());
}
if(row.entry().storageState() != UploadStorageState.VERIFIED)
{
throw (new UploadJournalException(Reason.STORAGE_NOT_VERIFIED, "The original object has not been verified, so it cannot be registered."));
}
Integer attachmentId = registrar.insert(transaction, row.entry().candidate(), row.entry().id());
if(attachmentId == null || attachmentId < 1)
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The registrar did not return an attachment id."));
}
if(connection.isClosed() || connection.getAutoCommit())
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The registrar altered the journal's owned transaction."));
}
if(update(connection, SET_REGISTERED, attachmentId, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
{
throw (stale());
}
return (read(connection, reservation.journalId()));
}));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public UploadEntry complete(UploadReservation reservation) throws QException
{
Objects.requireNonNull(reservation, "reservation");
return (inTransaction((transaction, connection) ->
{
Row finished = lock(connection, reservation.journalId());
if(finished.entry().state() == UploadJournalState.COMPLETE && finished.entry().fence().equals(reservation.fence()))
{
return (finished.entry());
}
Row row = requireHolder(connection, reservation);
if(row.entry().state() != UploadJournalState.REGISTERED || row.entry().attachmentId() == null)
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "Only a registered upload can be completed."));
}
if(update(connection, SET_COMPLETE, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) != 1)
{
throw (stale());
}
return (read(connection, reservation.journalId()));
}));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public boolean release(UploadReservation reservation) throws QException
{
Objects.requireNonNull(reservation, "reservation");
return (inTransaction((transaction, connection) ->
{
Row row = lock(connection, reservation.journalId());
if(row.entry().state() == UploadJournalState.COMPLETE || !holds(row, reservation))
{
return (false);
}
return (update(connection, CLEAR_LEASE, Timestamp.from(now()), reservation.journalId(), reservation.leaseToken(), reservation.fence()) == 1);
}));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public Optional<UploadEntry> find(Integer journalId) throws QException
{
return (inTransaction((transaction, connection) ->
{
List<Row> rows = select(connection, BY_ID, journalId);
return (rows.isEmpty() ? Optional.<UploadEntry>empty() : Optional.of(rows.getFirst().entry()));
}));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public Optional<UploadEntry> findByLocation(String bucketName, String storageKey) throws QException
{
String hash = UploadCandidate.locatorHash(bucketName, storageKey);
return (inTransaction((transaction, connection) ->
{
List<Row> rows = select(connection, BY_LOCATOR, hash);
return (rows.isEmpty() ? Optional.<UploadEntry>empty() : Optional.of(rows.getFirst().entry()));
}));
}
/*******************************************************************************
** {@inheritDoc}
*******************************************************************************/
@Override
public List<UploadEntry> listIncomplete(int limit) throws QException
{
if(limit < 1 || limit > MAXIMUM_LIST)
{
throw (new IllegalArgumentException("The limit must be between 1 and " + MAXIMUM_LIST + "."));
}
return (inTransaction((transaction, connection) ->
{
List<UploadEntry> entries = new ArrayList<>();
for(Row row : select(connection, INCOMPLETE, limit))
{
entries.add(row.entry());
}
return (List.copyOf(entries));
}));
}
/*******************************************************************************
** Replay an identical manifest or insert the candidate as PREPARED.
*******************************************************************************/
private UploadEntry insertOrReplay(Connection connection, UploadCandidate candidate) throws SQLException, QException
{
UploadEntry existing = existing(connection, candidate);
if(existing != null)
{
return (existing);
}
Timestamp now = Timestamp.from(now());
update(connection, INSERT, now, now, candidate.tableName(), candidate.recordId(), candidate.actorId(), candidate.fileName(),
candidate.contentType(), candidate.description(), candidate.sizeBytes(), candidate.sha256(), candidate.bucketName(),
candidate.storageKey(), candidate.retained(), candidate.intakeKey(), candidate.intakeFingerprint(), candidate.locatorHash(),
candidate.manifestHash());
List<Row> rows = select(connection, BY_LOCATOR, candidate.locatorHash());
if(rows.isEmpty())
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "The prepared candidate could not be read back."));
}
return (rows.getFirst().entry());
}
/*******************************************************************************
** The row already holding this location, if its manifest is identical. Any other
** row sharing the location or intake key is an immutable conflict. Null if none.
*******************************************************************************/
private UploadEntry existing(Connection connection, UploadCandidate candidate) throws SQLException, QException
{
List<Row> byLocator = select(connection, BY_LOCATOR, candidate.locatorHash());
if(!byLocator.isEmpty())
{
if(!candidate.manifestHash().equals(byLocator.getFirst().manifestHash()))
{
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "This object location is already journaled with a different manifest."));
}
return (byLocator.getFirst().entry());
}
if(candidate.intakeKey() != null && !select(connection, BY_INTAKE, candidate.intakeKey()).isEmpty())
{
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "This intake key is already journaled for a different object."));
}
return (null);
}
/*******************************************************************************
** Lock the row under the bounded database wait, or NOT_FOUND.
*******************************************************************************/
private Row lock(Connection connection, Integer journalId) throws SQLException, QException
{
List<Row> rows = journalId == null ? List.of() : select(connection, BY_ID_LOCKED, journalId);
if(rows.isEmpty())
{
throw (new UploadJournalException(Reason.NOT_FOUND, "No such upload journal row."));
}
return (rows.getFirst());
}
/*******************************************************************************
** Lock the row and require the reservation to still own a live lease on it.
*******************************************************************************/
private Row requireHolder(Connection connection, UploadReservation reservation) throws SQLException, QException
{
Row row = lock(connection, reservation.journalId());
if(row.entry().state() == UploadJournalState.COMPLETE || !holds(row, reservation))
{
throw (stale());
}
return (row);
}
/*******************************************************************************
** Token (compared in constant time), fence and unexpired lease all match.
*******************************************************************************/
private boolean holds(Row row, UploadReservation reservation)
{
return (row.leaseToken() != null && reservation.leaseToken() != null
&& MessageDigest.isEqual(row.leaseToken().getBytes(StandardCharsets.UTF_8), reservation.leaseToken().getBytes(StandardCharsets.UTF_8))
&& row.entry().fence().equals(reservation.fence()) && row.entry().isLeased(now()));
}
/*******************************************************************************
** The standard refusal for a holder that no longer owns the row.
*******************************************************************************/
private static UploadJournalException stale()
{
return (new UploadJournalException(Reason.STALE_RESERVATION, "The reservation no longer owns this upload; reserve it again."));
}
/*******************************************************************************
** Read one row that the transaction just changed.
*******************************************************************************/
private UploadEntry read(Connection connection, Integer journalId) throws SQLException, QException
{
List<Row> rows = select(connection, BY_ID, journalId);
if(rows.isEmpty())
{
throw (new UploadJournalException(Reason.NOT_FOUND, "No such upload journal row."));
}
return (rows.getFirst().entry());
}
/*******************************************************************************
** Bounded query with one bound parameter, verifying each row's integrity.
*******************************************************************************/
private List<Row> select(Connection connection, String sql, Object parameter) throws SQLException, QException
{
List<Row> rows = new ArrayList<>();
try(PreparedStatement statement = connection.prepareStatement(sql))
{
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
statement.setObject(1, parameter);
try(ResultSet result = statement.executeQuery())
{
while(result.next())
{
rows.add(row(result));
}
}
}
return (rows);
}
/*******************************************************************************
** Rebuild and verify a row: the preserved manifest must still hash to the
** stored manifest and locator, or the row was altered outside this journal.
*******************************************************************************/
private Row row(ResultSet result) throws SQLException, QException
{
UploadCandidate candidate;
try
{
candidate = new UploadCandidate(result.getString("table_name"), result.getString("record_id"), result.getString("created_by_user_id"),
result.getString("file_name"), result.getString("content_type"), result.getString("description"), result.getLong("size_bytes"),
result.getString("sha256"), result.getString("bucket_name"), result.getString("storage_key"), result.getBoolean("retained"),
result.getString("intake_key"), result.getString("intake_fingerprint"));
}
catch(IllegalArgumentException e)
{
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "A journal row no longer holds a valid manifest."));
}
String manifestHash = result.getString("manifest_hash");
if(!candidate.manifestHash().equals(manifestHash) || !candidate.locatorHash().equals(result.getString("locator_hash")))
{
throw (new UploadJournalException(Reason.IMMUTABLE_CONFLICT, "A journal row's manifest no longer matches its recorded hashes."));
}
int attachment = result.getInt("attachment_id");
Integer attachmentId = result.wasNull() ? null : attachment;
Timestamp leaseUntil = "MySQL".equals(result.getStatement().getConnection().getMetaData().getDatabaseProductName())
? result.getTimestamp("lease_until", Calendar.getInstance(TimeZone.getTimeZone("UTC"))) : result.getTimestamp("lease_until");
UploadEntry entry;
try
{
entry = new UploadEntry(result.getInt("id"), candidate, UploadJournalState.valueOf(result.getString("state")),
UploadStorageState.valueOf(result.getString("storage_state")), attachmentId, result.getLong("fence"),
leaseUntil == null ? null : leaseUntil.toInstant());
}
catch(IllegalArgumentException e)
{
throw (new UploadJournalException(Reason.ILLEGAL_STATE, "A journal row holds an unknown state."));
}
return (new Row(entry, result.getString("lease_token"), manifestHash));
}
/*******************************************************************************
** Execute one bound update and return its row count.
*******************************************************************************/
private int update(Connection connection, String sql, Object... parameters) throws SQLException
{
try(PreparedStatement statement = connection.prepareStatement(sql))
{
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
for(int i = 0; i < parameters.length; i++)
{
if(parameters[i] instanceof Timestamp timestamp && "MySQL".equals(connection.getMetaData().getDatabaseProductName()))
{
statement.setTimestamp(i + 1, timestamp, Calendar.getInstance(TimeZone.getTimeZone("UTC")));
}
else
{
statement.setObject(i + 1, parameters[i]);
}
}
return (statement.executeUpdate());
}
}
/*******************************************************************************
** Current time at the millisecond precision the database stores.
*******************************************************************************/
private Instant now()
{
return (clock.instant().truncatedTo(ChronoUnit.MILLIS));
}
/*******************************************************************************
** Own one real transaction: qualify it, run the work, then commit; roll back
** every failure so there is never a partial success.
*******************************************************************************/
private <T> T inTransaction(Work<T> work) throws QException
{
if(QContext.getQBackendTransaction() != null)
{
throw (new UploadJournalException(Reason.AMBIENT_TRANSACTION, "The upload journal owns its transactions and cannot join an ambient one."));
}
RDBMSBackendMetaData backend = requireQualifiedBackends();
try(QBackendTransaction opened = new RDBMSBackendModule().openTransaction(new InsertInput(TABLE)))
{
if(!(opened instanceof RDBMSTransaction transaction) || !transaction.getClass().equals(RDBMSTransaction.class))
{
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal requires the database's actual transaction."));
}
try
{
requireQualifiedConnection(transaction.getConnection(), backend);
T result = work.run(transaction, transaction.getConnection());
transaction.commit();
return (result);
}
catch(SQLException e)
{
rollback(transaction, e);
throw (map(e));
}
catch(QException | RuntimeException failure)
{
rollback(transaction, failure);
throw (failure);
}
}
}
/*******************************************************************************
** Roll back, keeping any rollback failure as suppressed on the real failure.
*******************************************************************************/
private static void rollback(RDBMSTransaction transaction, Throwable failure)
{
try
{
transaction.rollback();
}
catch(QException | RuntimeException rollbackFailure)
{
failure.addSuppressed(rollbackFailure);
}
}
/*******************************************************************************
** Classify a database failure by SQLSTATE: unique violation, bounded-wait
** lock timeout or deadlock, or anything else.
*******************************************************************************/
private static UploadJournalException map(SQLException e)
{
Throwable cursor = e;
for(int depth = 0; cursor != null && depth < 8; depth++, cursor = cursor.getCause())
{
if(cursor instanceof SQLException sql)
{
if("23505".equals(sql.getSQLState()) || ("23000".equals(sql.getSQLState()) && sql.getErrorCode() == 1062))
{
return (new UploadJournalException(Reason.DUPLICATE, "The journal row already exists.", e));
}
if("50200".equals(sql.getSQLState()) || "40001".equals(sql.getSQLState()) || "HYT00".equals(sql.getSQLState())
|| "55P03".equals(sql.getSQLState()) || "40P01".equals(sql.getSQLState())
|| sql.getErrorCode() == 1205 || sql.getErrorCode() == 1213)
{
return (new UploadJournalException(Reason.BUSY, "The upload journal row stayed busy past the bounded wait.", e));
}
}
}
return (new UploadJournalException(Reason.DATABASE, "The upload journal database operation failed.", e));
}
/*******************************************************************************
** The journal and the attachment table must share one qualified backend, so the
** metadata insert and the checkpoint can be one real transaction.
*******************************************************************************/
private static RDBMSBackendMetaData requireQualifiedBackends() throws QException
{
QInstance instance = QContext.getQInstance();
QBackendMetaData journal = instance == null ? null : instance.getBackendForTable(TABLE);
QBackendMetaData attachments = instance == null ? null : instance.getBackendForTable(CollaborationTables.ATTACHMENT);
if(!isQualifiedBackend(journal) || attachments == null || !journal.getName().equals(attachments.getName()))
{
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND,
"The upload journal requires qualified H2, MySQL 8.4 or PostgreSQL 17 shared by the journal and attachment tables."));
}
return ((RDBMSBackendMetaData) journal);
}
/*******************************************************************************
** Qualify the actual connection, not merely its class: open, writable,
** transactional, the configured file database, and an actual SETTINGS query
** reporting WRITE_DELAY=0 so a commit is on disk before it returns.
*******************************************************************************/
private static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
{
if(PostgresJournalDatabase.is(backend))
{
PostgresJournalDatabase.requireQualifiedConnection(connection, backend);
return;
}
if("mysql".equals(backend.getVendor()))
{
MySqlJournalDatabase.requireQualifiedConnection(connection, backend);
return;
}
String expected = backend.getJdbcUrl().split(";")[0];
if(connection.isClosed() || connection.isReadOnly() || connection.getAutoCommit()
|| !expected.equals(String.valueOf(connection.getMetaData().getURL()).split(";")[0]))
{
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal needs a writable, non-autocommit connection to its configured database."));
}
boolean writeDelayIsZero = false;
try(Statement statement = connection.createStatement())
{
statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS);
try(ResultSet settings = statement.executeQuery("SELECT * FROM INFORMATION_SCHEMA.SETTINGS"))
{
while(settings.next())
{
if("WRITE_DELAY".equalsIgnoreCase(settings.getString(1)))
{
writeDelayIsZero = "0".equals(settings.getString(2));
}
}
}
}
if(!writeDelayIsZero)
{
throw (new UploadJournalException(Reason.UNSUPPORTED_BACKEND, "The upload journal requires H2 WRITE_DELAY=0 as reported by the database's SETTINGS."));
}
}
}
@@ -1,184 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.net.URI;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.List;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
/*******************************************************************************
** Narrow qualification of this owner's fixed MySQL journal and attachment pair.
** No schema migration or server-global setting is performed at runtime.
*******************************************************************************/
final class MySqlJournalDatabase
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private MySqlJournalDatabase()
{
}
/*******************************************************************************
** Check the real connection, current durability settings and physical tables.
** A host-configured session lock timeout bounds contention without mutation.
*******************************************************************************/
static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
{
String expected = baseUrl(ConnectionManager.getJdbcUrl(backend));
String actual = baseUrl(connection.getMetaData().getURL());
String catalog;
try
{
URI location = URI.create(expected.substring("jdbc:".length()));
catalog = location.getPath().substring(1);
if(location.getHost() == null || location.getUserInfo() != null || !catalog.matches("[A-Za-z0-9_]+"))
{
throw (unsupported());
}
}
catch(IllegalArgumentException | IndexOutOfBoundsException e)
{
throw (unsupported());
}
if(!expected.equals(actual) || !catalog.equals(connection.getCatalog()) || connection.isClosed() || connection.isReadOnly()
|| connection.getAutoCommit() || !"MySQL".equals(connection.getMetaData().getDatabaseProductName())
|| !connection.getMetaData().getDatabaseProductVersion().startsWith("8.4."))
{
throw (unsupported());
}
try(Statement statement = connection.createStatement())
{
statement.setQueryTimeout(10);
try(ResultSet settings = statement.executeQuery("SELECT @@GLOBAL.innodb_flush_log_at_trx_commit, @@GLOBAL.log_bin, @@GLOBAL.sync_binlog, @@GLOBAL.read_only, @@SESSION.time_zone, @@GLOBAL.system_time_zone, @@SESSION.innodb_lock_wait_timeout, @@GLOBAL.innodb_doublewrite, @@SESSION.transaction_read_only"))
{
if(!settings.next() || !List.of("ON", "DETECT_AND_RECOVER").contains(settings.getString(8)) || settings.getBoolean(9)
|| settings.getInt(7) < 1 || settings.getInt(7) > 5 || settings.getInt(1) != 1 || (settings.getBoolean(2) && settings.getInt(3) != 1) || settings.getBoolean(4)
|| !(List.of("+00:00", "UTC").contains(settings.getString(5))
|| ("SYSTEM".equals(settings.getString(5)) && "UTC".equals(settings.getString(6)))))
{
throw (unsupported());
}
}
}
for(String tableName : List.of(CollaborationTables.UPLOAD_JOURNAL, CollaborationTables.ATTACHMENT))
{
QTableMetaData table = QContext.getQInstance().getTable(tableName);
if(table.getBackendDetails() != null && (!(table.getBackendDetails() instanceof RDBMSTableBackendDetails details)
|| (details.getTableName() != null && !tableName.equals(details.getTableName()))))
{
throw (unsupported());
}
try(PreparedStatement statement = connection.prepareStatement("SELECT TABLE_NAME, ENGINE, TABLE_COLLATION FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"))
{
statement.setQueryTimeout(10);
statement.setString(1, catalog);
statement.setString(2, tableName);
try(ResultSet tableInfo = statement.executeQuery())
{
if(!tableInfo.next() || !tableName.equals(tableInfo.getString(1)) || !"InnoDB".equals(tableInfo.getString(2))
|| (CollaborationTables.UPLOAD_JOURNAL.equals(tableName) && !"utf8mb4_0900_bin".equals(tableInfo.getString(3))) || tableInfo.next())
{
throw (unsupported());
}
}
}
}
try(PreparedStatement statement = connection.prepareStatement("SELECT COLLATION_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLLATION_NAME IS NOT NULL"))
{
statement.setQueryTimeout(10);
statement.setString(1, catalog);
statement.setString(2, CollaborationTables.UPLOAD_JOURNAL);
try(ResultSet columns = statement.executeQuery())
{
int count = 0;
while(columns.next())
{
count++;
if(!"utf8mb4_0900_bin".equals(columns.getString(1)))
{
throw (unsupported());
}
}
if(count < 1)
{
throw (unsupported());
}
}
}
try(PreparedStatement statement = connection.prepareStatement("SELECT INDEX_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND NON_UNIQUE = 0 ORDER BY INDEX_NAME, SEQ_IN_INDEX"))
{
statement.setQueryTimeout(10);
statement.setString(1, catalog);
statement.setString(2, CollaborationTables.UPLOAD_JOURNAL);
try(ResultSet indexes = statement.executeQuery())
{
java.util.Map<String, List<String>> columns = new java.util.HashMap<>();
while(indexes.next())
{
columns.computeIfAbsent(indexes.getString(1), key -> new java.util.ArrayList<>()).add(indexes.getString(2));
}
if(!columns.containsValue(List.of("locator_hash")) || !columns.containsValue(List.of("intake_key")))
{
throw (unsupported());
}
}
}
}
/*******************************************************************************
** Compare the configured target without logging credentials or driver options.
*******************************************************************************/
private static String baseUrl(String url) throws UploadJournalException
{
if(url == null || !url.startsWith("jdbc:mysql://"))
{
throw (unsupported());
}
return (url.split("\\?", 2)[0]);
}
/*******************************************************************************
** Deliberately omit connection URLs and settings from externally visible errors.
*******************************************************************************/
private static UploadJournalException unsupported()
{
return (new UploadJournalException(UploadJournalException.Reason.UNSUPPORTED_BACKEND,
"Retained upload journaling requires its configured MySQL 8.4 catalog, InnoDB owner tables, binary journal identifiers, a UTC session, a 1-5 second lock timeout and synchronous durable commits."));
}
}
@@ -1,239 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.net.URI;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
/*******************************************************************************
** Narrow qualification of this owner's fixed PostgreSQL 17 journal and attachment
** pair, on the native QQQ postgres backend (vendor and backend type "postgres").
** Observation only: no schema migration and no session or server setting is
** changed at runtime.
*******************************************************************************/
final class PostgresJournalDatabase
{
static final String VENDOR = "postgres";
private static final String SETTINGS = "SELECT current_setting('server_version_num')::int, current_setting('transaction_read_only'), pg_is_in_recovery(),"
+ " current_setting('synchronous_commit'), current_setting('fsync'), current_setting('full_page_writes'), current_setting('TimeZone'),"
+ " (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout'), current_setting('transaction_isolation'), current_database()";
private static final String TABLES = "SELECT c.relname, c.relkind, c.relpersistence FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace"
+ " WHERE n.nspname=current_schema() AND c.relname = ANY(?)";
private static final String COLLATIONS = "SELECT c.relname, a.attname FROM pg_attribute a JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace"
+ " JOIN pg_collation co ON co.oid=a.attcollation WHERE n.nspname=current_schema() AND c.relname = ANY(?) AND a.attnum>0 AND NOT a.attisdropped AND NOT co.collisdeterministic";
private static final String UNIQUES = "SELECT i.relname, a.attname FROM pg_index ix JOIN pg_class i ON i.oid=ix.indexrelid"
+ " JOIN pg_class t ON t.oid=ix.indrelid JOIN pg_namespace n ON n.oid=t.relnamespace"
+ " CROSS JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)"
+ " JOIN pg_attribute a ON a.attrelid=t.oid AND a.attnum=k.attnum"
+ " WHERE n.nspname=current_schema() AND t.relname = ? AND ix.indisunique AND ix.indisvalid AND ix.indpred IS NULL AND ix.indexprs IS NULL AND k.ord<=ix.indnkeyatts"
+ " ORDER BY i.relname, k.ord";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private PostgresJournalDatabase()
{
}
/*******************************************************************************
** The native postgres module identity, not merely a vendor string: a "postgres"
** vendor label on the generic rdbms backend type is refused.
*******************************************************************************/
static boolean is(RDBMSBackendMetaData backend)
{
return (VENDOR.equals(backend.getVendor()) && VENDOR.equals(backend.getBackendType()));
}
/*******************************************************************************
** Check the real connection, current durability settings and physical tables.
*******************************************************************************/
static void requireQualifiedConnection(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
{
String expected = baseUrl(ConnectionManager.getJdbcUrl(backend));
String actual = baseUrl(connection.getMetaData().getURL());
String catalog;
try
{
URI location = URI.create(expected.substring("jdbc:".length()));
catalog = location.getPath() == null || !location.getPath().startsWith("/") ? "" : location.getPath().substring(1);
if(location.getHost() == null || location.getUserInfo() != null || !catalog.matches("[A-Za-z0-9_]+"))
{
throw (unsupported());
}
}
catch(IllegalArgumentException | IndexOutOfBoundsException e)
{
throw (unsupported());
}
if(!is(backend) || !expected.equals(actual) || !catalog.equals(connection.getCatalog()) || connection.isClosed() || connection.isReadOnly()
|| connection.getAutoCommit() || !"PostgreSQL".equals(connection.getMetaData().getDatabaseProductName())
|| connection.getMetaData().getDatabaseMajorVersion() != 17)
{
throw (unsupported());
}
try(Statement statement = connection.createStatement())
{
statement.setQueryTimeout(10);
try(ResultSet settings = statement.executeQuery(SETTINGS))
{
if(!settings.next() || settings.getInt(1) / 10000 != 17 || !"off".equals(settings.getString(2)) || settings.getBoolean(3)
|| !Set.of("on", "remote_apply").contains(settings.getString(4)) || !"on".equals(settings.getString(5)) || !"on".equals(settings.getString(6))
|| !Set.of("UTC", "Etc/UTC").contains(settings.getString(7)) || settings.getLong(8) < 1 || settings.getLong(8) > 5000
|| !"read committed".equals(settings.getString(9)) || !catalog.equals(settings.getString(10)))
{
throw (unsupported());
}
}
}
List<String> names = List.of(CollaborationTables.UPLOAD_JOURNAL, CollaborationTables.ATTACHMENT);
for(String tableName : names)
{
QTableMetaData table = QContext.getQInstance().getTable(tableName);
if(table.getBackendDetails() != null && (!(table.getBackendDetails() instanceof RDBMSTableBackendDetails details)
|| (details.getTableName() != null && !tableName.equals(details.getTableName()))))
{
throw (unsupported());
}
}
requireTables(connection, names);
}
/*******************************************************************************
** Permanent (logged) ordinary tables only, deterministic text comparison, and
** complete valid non-partial unique indexes for the journal's two identities.
*******************************************************************************/
private static void requireTables(Connection connection, List<String> names) throws SQLException, QException
{
java.sql.Array array = connection.createArrayOf("text", names.toArray());
try
{
try(PreparedStatement statement = connection.prepareStatement(TABLES))
{
statement.setQueryTimeout(10);
statement.setArray(1, array);
try(ResultSet rows = statement.executeQuery())
{
Set<String> observed = new java.util.HashSet<>();
while(rows.next())
{
if(!names.contains(rows.getString(1)) || !observed.add(rows.getString(1)) || !"r".equals(rows.getString(2)) || !"p".equals(rows.getString(3)))
{
throw (unsupported());
}
}
if(observed.size() != names.size())
{
throw (unsupported());
}
}
}
try(PreparedStatement statement = connection.prepareStatement(COLLATIONS))
{
statement.setQueryTimeout(10);
statement.setArray(1, array);
try(ResultSet rows = statement.executeQuery())
{
if(rows.next())
{
throw (unsupported());
}
}
}
}
finally
{
array.free();
}
try(PreparedStatement statement = connection.prepareStatement(UNIQUES))
{
statement.setQueryTimeout(10);
statement.setString(1, CollaborationTables.UPLOAD_JOURNAL);
try(ResultSet rows = statement.executeQuery())
{
Map<String, List<String>> columns = new HashMap<>();
while(rows.next())
{
columns.computeIfAbsent(rows.getString(1), key -> new ArrayList<>()).add(rows.getString(2));
}
if(!columns.containsValue(List.of("locator_hash")) || !columns.containsValue(List.of("intake_key")))
{
throw (unsupported());
}
}
}
}
/*******************************************************************************
** Compare the configured target without logging credentials or driver options.
*******************************************************************************/
private static String baseUrl(String url) throws UploadJournalException
{
if(url == null || !url.startsWith("jdbc:postgresql://"))
{
throw (unsupported());
}
return (url.split("\\?", 2)[0]);
}
/*******************************************************************************
** Deliberately omit connection URLs and settings from externally visible errors.
*******************************************************************************/
private static UploadJournalException unsupported()
{
return (new UploadJournalException(UploadJournalException.Reason.UNSUPPORTED_BACKEND,
"Retained upload journaling requires its configured PostgreSQL 17 database, permanent owner tables, deterministic comparison, READ COMMITTED isolation, a UTC session, a bounded lock timeout and synchronous durable commits."));
}
}
@@ -1,198 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
/*******************************************************************************
** Immutable, bounded manifest of a retained upload, assembled by the server
** (AttachmentsService) from the authorized source, the actual session actor,
** the measured size/hash and the chosen storage coordinates. Nothing here is a
** client-supplied path or journal field. All members are immutable values.
**
** Only retained uploads are journaled; a non-retained candidate is rejected.
** The bucket and key are needed for recovery but are deliberately absent from
** toString and from the QQQ table metadata; the journal exposes only their hash.
*******************************************************************************/
public record UploadCandidate(String tableName, String recordId, String actorId, String fileName, String contentType, String description,
Long sizeBytes, String sha256, String bucketName, String storageKey, Boolean retained,
String intakeKey, String intakeFingerprint)
{
public static final long MAX_SIZE_BYTES = 1L << 40;
private static final String HEX_64 = "[0-9a-f]{64}";
/*******************************************************************************
** Validate every member. Failures are programming errors in the trusted
** caller, so they are unchecked and never echo the offending value.
*******************************************************************************/
public UploadCandidate
{
tableName = text("tableName", tableName, 128, true, false);
recordId = text("recordId", recordId, 64, true, false);
actorId = text("actorId", actorId, 255, true, false);
fileName = text("fileName", fileName, 255, true, false);
contentType = text("contentType", contentType, 255, true, false);
description = text("description", description, 4096, false, true);
bucketName = text("bucketName", bucketName, 255, true, false);
storageKey = text("storageKey", storageKey, 1024, true, false);
if(sizeBytes == null || sizeBytes < 0 || sizeBytes > MAX_SIZE_BYTES)
{
throw (new IllegalArgumentException("sizeBytes is out of range."));
}
if(sha256 == null || !sha256.matches(HEX_64))
{
throw (new IllegalArgumentException("sha256 must be a lowercase SHA-256 hex digest."));
}
if(!Boolean.TRUE.equals(retained))
{
throw (new IllegalArgumentException("Only retained uploads are journaled."));
}
if((intakeKey == null) != (intakeFingerprint == null)
|| (intakeKey != null && (!intakeKey.matches(HEX_64) || !intakeFingerprint.matches(HEX_64))))
{
throw (new IllegalArgumentException("intakeKey and intakeFingerprint must be supplied together as SHA-256 identifiers."));
}
}
/*******************************************************************************
** SHA-256 of the length-prefixed bucket and key; the journal's unique identity
** for one object location.
*******************************************************************************/
public String locatorHash()
{
return (locatorHash(bucketName, storageKey));
}
/*******************************************************************************
** SHA-256 of the length-prefixed bucket and key.
*******************************************************************************/
public static String locatorHash(String bucketName, String storageKey)
{
return (tupleHash("upload-journal-locator-v1", bucketName, storageKey));
}
/*******************************************************************************
** SHA-256 over the complete manifest. A row that shares a locator or intake
** key but not this hash is an immutable conflict.
*******************************************************************************/
public String manifestHash()
{
return (tupleHash("upload-journal-manifest-v1", tableName, recordId, actorId, fileName, contentType, description,
String.valueOf(sizeBytes), sha256, bucketName, storageKey, String.valueOf(retained), intakeKey, intakeFingerprint));
}
/*******************************************************************************
** Never prints the bucket, key, description or actor.
*******************************************************************************/
@Override
public String toString()
{
return ("UploadCandidate[source=" + tableName + "/" + recordId + ", sizeBytes=" + sizeBytes + ", locatorHash=" + locatorHash() + "]");
}
/*******************************************************************************
** Bounded text without control characters (multi-line allows CR, LF, tab).
*******************************************************************************/
private static String text(String name, String value, int maximumLength, boolean required, boolean multiLine)
{
if(value == null)
{
if(required)
{
throw (new IllegalArgumentException(name + " is required."));
}
return (null);
}
if((required && value.isBlank()) || value.length() > maximumLength)
{
throw (new IllegalArgumentException(name + " is blank or exceeds " + maximumLength + " characters."));
}
for(int i = 0; i < value.length(); i++)
{
char c = value.charAt(i);
if(Character.isHighSurrogate(c))
{
if(i + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(++i)))
{
throw (new IllegalArgumentException(name + " contains malformed Unicode."));
}
continue;
}
if(Character.isLowSurrogate(c))
{
throw (new IllegalArgumentException(name + " contains malformed Unicode."));
}
if(Character.isISOControl(c) && !(multiLine && (c == '\n' || c == '\r' || c == '\t')))
{
throw (new IllegalArgumentException(name + " contains a control character."));
}
}
return (value);
}
/*******************************************************************************
** Length-prefix every member, including null, so delimiters cannot collide.
*******************************************************************************/
private static String tupleHash(String... values)
{
try
{
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try(DataOutputStream output = new DataOutputStream(buffer))
{
for(String value : values)
{
byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
output.writeInt(value == null ? -1 : bytes.length);
output.write(bytes);
}
}
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(buffer.toByteArray())));
}
catch(IOException | NoSuchAlgorithmException e)
{
throw (new IllegalStateException("Could not hash the upload manifest.", e));
}
}
}
@@ -1,158 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.time.Instant;
/*******************************************************************************
** Immutable snapshot of one journal row at read time. It never carries the
** lease token, so it cannot be used to mutate the journal.
*******************************************************************************/
public final class UploadEntry
{
private final Integer id;
private final UploadCandidate candidate;
private final UploadJournalState state;
private final UploadStorageState storageState;
private final Integer attachmentId;
private final Long fence;
private final Instant leaseUntil;
/*******************************************************************************
** Package-local; only the journal creates snapshots.
*******************************************************************************/
UploadEntry(Integer id, UploadCandidate candidate, UploadJournalState state, UploadStorageState storageState, Integer attachmentId, Long fence, Instant leaseUntil)
{
this.id = id;
this.candidate = candidate;
this.state = state;
this.storageState = storageState;
this.attachmentId = attachmentId;
this.fence = fence;
this.leaseUntil = leaseUntil;
}
/*******************************************************************************
** Journal row id.
*******************************************************************************/
public Integer id()
{
return (id);
}
/*******************************************************************************
** The preserved manifest, including the original actor and source.
*******************************************************************************/
public UploadCandidate candidate()
{
return (candidate);
}
/*******************************************************************************
** Registration progress.
*******************************************************************************/
public UploadJournalState state()
{
return (state);
}
/*******************************************************************************
** What is known about the original object.
*******************************************************************************/
public UploadStorageState storageState()
{
return (storageState);
}
/*******************************************************************************
** The registered attachment id, or null before the registering commit.
*******************************************************************************/
public Integer attachmentId()
{
return (attachmentId);
}
/*******************************************************************************
** Number of reservations ever granted; the fencing counter.
*******************************************************************************/
public Long fence()
{
return (fence);
}
/*******************************************************************************
** When the current lease lapses, or null when no lease is held.
*******************************************************************************/
public Instant leaseUntil()
{
return (leaseUntil);
}
/*******************************************************************************
** True while a lease is held as of the given instant.
*******************************************************************************/
public boolean isLeased(Instant now)
{
return (leaseUntil != null && leaseUntil.isAfter(now));
}
/*******************************************************************************
** Copy reflecting a newly granted lease.
*******************************************************************************/
UploadEntry leased(Long newFence, Instant newLeaseUntil)
{
return (new UploadEntry(id, candidate, state, storageState, attachmentId, newFence, newLeaseUntil));
}
/*******************************************************************************
** Never prints the manifest's bucket or key.
*******************************************************************************/
@Override
public String toString()
{
return ("UploadEntry[id=" + id + ", state=" + state + ", storage=" + storageState + ", attachmentId=" + attachmentId + ", fence=" + fence + ", " + candidate + "]");
}
}
@@ -1,141 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Durable journal of retained upload candidates. The intended order, owned by
** AttachmentsService (this interface never reads, writes or deletes bytes):
**
** 1. prepare(candidate) independently committed, before the first PUT
** 2. reserve(id, lease) exclusive fenced lease (original or recovery)
** 3. recordStorage(UNKNOWN) before the PUT; VERIFIED once bytes are proven
** 4. register(reservation, cb) attachment insert + checkpoint, one transaction
** 5. (caller) lifecycle.afterStored, after the commit, idempotent and retried
** 6. complete(reservation) only after step 5 returned
**
** A crash, timeout or failure at any step leaves the row visible; recovery
** reserves it again once the lease lapses, after the caller reauthorizes the
** original actor and the current source. Nothing here deletes an original.
**
** AttachmentsService owns authorization and lifecycle policy around this port.
*******************************************************************************/
public interface UploadJournal
{
/*******************************************************************************
** Inserts the registering callback's attachment row. Runs inside the journal's
** owned transaction, which the callback must use for every write and must not
** commit, roll back or close. Returns the new attachment id. Throwing rolls
** back the insert and the checkpoint together.
*******************************************************************************/
@FunctionalInterface
interface AttachmentRegistrar
{
/*******************************************************************************
** Insert the attachment metadata row for the journaled candidate.
*******************************************************************************/
Integer insert(QBackendTransaction transaction, UploadCandidate candidate, Integer journalId) throws QException;
}
/*******************************************************************************
** Durably record the candidate in its own committed transaction. Idempotent
** for an identical manifest; an immutable conflict for any different one.
*******************************************************************************/
UploadEntry prepare(UploadCandidate candidate) throws QException;
/*******************************************************************************
** Take the exclusive lease, failing with LEASE_HELD while another holder's
** lease is live. Each grant increments the fence. Lease bounded 1s to 30m.
*******************************************************************************/
UploadReservation reserve(Integer journalId, Duration lease) throws QException;
/*******************************************************************************
** Extend only a still-live exact reservation. Expired holders cannot renew.
*******************************************************************************/
UploadReservation renew(UploadReservation reservation, Duration lease) throws QException;
/*******************************************************************************
** Report original-object knowledge. Forward only: PENDING to UNKNOWN to
** VERIFIED. Requires the live reservation.
*******************************************************************************/
UploadEntry recordStorage(UploadReservation reservation, UploadStorageState storageState) throws QException;
/*******************************************************************************
** Requires VERIFIED storage and the live reservation. Inserts the attachment
** through the callback and checkpoints its id and REGISTERED in one same-database
** transaction. If already REGISTERED the callback is not called again and the
** existing entry is returned, so the attachment is never registered twice.
*******************************************************************************/
UploadEntry register(UploadReservation reservation, AttachmentRegistrar registrar) throws QException;
/*******************************************************************************
** Mark COMPLETE after the post-commit lifecycle registration returned. Requires
** REGISTERED and the live reservation; repeating it for the same fence is a no-op.
*******************************************************************************/
UploadEntry complete(UploadReservation reservation) throws QException;
/*******************************************************************************
** Give up the lease early so recovery need not wait. Changes no other data.
** Returns false, harmlessly, if the reservation was no longer the holder.
*******************************************************************************/
boolean release(UploadReservation reservation) throws QException;
/*******************************************************************************
** Current snapshot of one row.
*******************************************************************************/
Optional<UploadEntry> find(Integer journalId) throws QException;
/*******************************************************************************
** Snapshot found by the object location, hashed internally.
*******************************************************************************/
Optional<UploadEntry> findByLocation(String bucketName, String storageKey) throws QException;
/*******************************************************************************
** Oldest-first rows that are not COMPLETE, for recovery sweeps. Limit 1 to 500.
*******************************************************************************/
List<UploadEntry> listIncomplete(int limit) throws QException;
}
@@ -1,118 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Journal failure with a machine-readable reason. Messages never contain the
** bucket, storage key or lease token.
*******************************************************************************/
public class UploadJournalException extends QException
{
/*******************************************************************************
** Why the journal refused or failed an operation.
*******************************************************************************/
public enum Reason
{
/***************************************************************************
** Another transaction held the row past the bounded wait.
***************************************************************************/
BUSY,
/***************************************************************************
** A live lease belongs to another holder.
***************************************************************************/
LEASE_HELD,
/***************************************************************************
** The reservation's token, fence or lease no longer owns the row.
***************************************************************************/
STALE_RESERVATION,
/***************************************************************************
** Same locator or intake key, different immutable manifest (or tampered row).
***************************************************************************/
IMMUTABLE_CONFLICT,
/***************************************************************************
** The operation does not apply to the row's current state.
***************************************************************************/
ILLEGAL_STATE,
/***************************************************************************
** Registration requires VERIFIED original storage.
***************************************************************************/
STORAGE_NOT_VERIFIED,
/***************************************************************************
** No such journal row.
***************************************************************************/
NOT_FOUND,
/***************************************************************************
** Backend is not the qualified file-backed H2 configuration.
***************************************************************************/
UNSUPPORTED_BACKEND,
/***************************************************************************
** The journal owns its transactions and refuses an ambient one.
***************************************************************************/
AMBIENT_TRANSACTION,
/***************************************************************************
** Unique constraint hit; handled internally, not normally surfaced.
***************************************************************************/
DUPLICATE,
/***************************************************************************
** Other database failure.
***************************************************************************/
DATABASE
}
private final Reason reason;
/*******************************************************************************
** Constructor.
*******************************************************************************/
public UploadJournalException(Reason reason, String message)
{
super(message);
this.reason = reason;
}
/*******************************************************************************
** Constructor with cause.
*******************************************************************************/
public UploadJournalException(Reason reason, String message, Throwable cause)
{
super(message, cause);
this.reason = reason;
}
/*******************************************************************************
** The refusal reason.
*******************************************************************************/
public Reason getReason()
{
return (reason);
}
}
@@ -1,159 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Private manifest fields cannot become filter or sorting oracles. This
** table-local restriction composes with the host's existing field access policy.
*******************************************************************************/
public final class UploadJournalReadPolicy
{
private static final Set<String> PRIVATE = Set.of("bucketName", "storageKey", "manifestHash", "leaseToken");
/*******************************************************************************
** Utility namespace.
*******************************************************************************/
private UploadJournalReadPolicy()
{
}
/*******************************************************************************
** Native read criteria are checked before SQL, including unique-key Get.
** Joins are not an journal browsing contract and are rejected conservatively.
*******************************************************************************/
public static void check(AbstractActionInput input, QTableMetaData table) throws QPermissionDeniedException
{
if(!UploadJournalTable.NAME.equals(table.getName()))
{
return;
}
if(input instanceof QueryInput query)
{
if(query.getQueryJoins() != null && !query.getQueryJoins().isEmpty())
{
deny();
}
filter(query.getFilter(), table, 0, new int[]{0});
if(query.getFieldNamesToInclude() != null)
{
for(String name : query.getFieldNamesToInclude())
{
field(name, table);
}
}
}
else if(input instanceof GetInput get)
{
if(get.getQueryJoins() != null && !get.getQueryJoins().isEmpty())
{
deny();
}
if(get.getUniqueKey() != null)
{
for(String name : get.getUniqueKey().keySet())
{
field(name, table);
}
}
}
}
/*******************************************************************************
** Bound traversal and accept only literal criteria on public direct fields.
*******************************************************************************/
private static void filter(QQueryFilter filter, QTableMetaData table, int depth, int[] visited) throws QPermissionDeniedException
{
if(filter == null)
{
return;
}
if(depth > 64 || ++visited[0] > 4096)
{
deny();
}
for(var criterion : filter.getCriteria() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria>of() : filter.getCriteria())
{
field(criterion.getFieldName(), table);
if(criterion.getOtherFieldName() != null)
{
field(criterion.getOtherFieldName(), table);
}
if(criterion.getFieldFunction() != null)
{
deny();
}
for(Object value : criterion.getValues() == null ? List.of() : criterion.getValues())
{
if(value instanceof com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.CurrentUser
&& "createdByUserId".equals(criterion.getFieldName())
&& criterion.getOperator() == com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS
&& criterion.getValues().size() == 1)
{
continue;
}
if(value != null && !(value instanceof String) && !(value instanceof Number) && !(value instanceof Boolean)
&& !(value instanceof java.time.temporal.TemporalAccessor) && !(value instanceof java.util.Date))
{
deny();
}
}
}
for(var order : filter.getOrderBys() == null ? List.<com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy>of() : filter.getOrderBys())
{
field(order.getFieldName(), table);
}
for(QQueryFilter child : filter.getSubFilters() == null ? List.<QQueryFilter>of() : filter.getSubFilters())
{
filter(child, table, depth + 1, visited);
}
}
/*******************************************************************************
** A public direct field cannot refer to a private projection through an alias.
*******************************************************************************/
private static void field(String name, QTableMetaData table) throws QPermissionDeniedException
{
if(name == null || PRIVATE.contains(name) || table.getFields().get(name) == null || Boolean.TRUE.equals(table.getFields().get(name).getIsHidden()))
{
deny();
}
}
/*******************************************************************************
** Uniform denial never echoes security values or a caller's probing predicate.
*******************************************************************************/
private static void deny() throws QPermissionDeniedException
{
throw (new QPermissionDeniedException("Upload journal queries support public direct fields only."));
}
}
@@ -1,118 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalRecords;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissionChecker;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
/*******************************************************************************
** Owner journal visibility follows its original actor and its exact attachment's
** current parent after a transfer. Immutable manifest source values never change.
*******************************************************************************/
public class UploadJournalSecurity implements CustomPermissionChecker, TableCustomizerInterface
{
/*******************************************************************************
** No direct writes or counts bypass per-row dynamic parent authorization.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table)
{
UploadJournalReadPolicy.check(input, table);
}
if(input instanceof InsertInput || input instanceof UpdateInput || input instanceof DeleteInput || input instanceof CountInput || input instanceof AggregateInput)
{
throw (new QPermissionDeniedException("The upload journal is maintained by the attachment service; unscoped totals are unavailable."));
}
new CollaborationPermissionChecker().checkPermissionsThrowing(input, metadata);
}
/*******************************************************************************
** Read authoritative owner identity even if the client selected a projection.
** Internal manifest columns are not QQQ fields and cannot be returned here.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records) throws QException
{
List<QRecord> visible = new ArrayList<>();
for(QRecord row : records)
{
if(row.getValueInteger("id") == null)
{
continue;
}
try
{
List<QRecord> persisted = CollaborationRecords.cleanupQuery(UploadJournalTable.NAME,
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, row.getValueInteger("id")).withLimit(1));
if(persisted.size() != 1 || !PersonalRecords.currentUserId().equals(persisted.getFirst().getValueString("createdByUserId")))
{
continue;
}
QRecord source = persisted.getFirst();
if(source.getValueInteger("attachmentId") != null)
{
List<QRecord> attachment = CollaborationRecords.cleanupQuery(CollaborationTables.ATTACHMENT,
new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, source.getValueInteger("attachmentId")).withLimit(1));
if(attachment.size() != 1 || !source.getValueString("sha256").equals(attachment.getFirst().getValueString("sha256"))
|| !source.getValueLong("sizeBytes").equals(attachment.getFirst().getValueLong("sizeBytes"))
|| !source.getValueString("createdByUserId").equals(attachment.getFirst().getValueString("createdByUserId")))
{
continue;
}
source = attachment.getFirst();
}
ParentRecordAccess.requireVisibleParent(source.getValueString("tableName"), source.getValueString("recordId"));
visible.add(row);
}
catch(QNotFoundException denied)
{
// Invisible and absent owners have identical native query behavior.
}
}
return (visible);
}
}
@@ -1,46 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
/*******************************************************************************
** Registration progress of a journaled retained upload. Only moves forward.
** NOT YET WIRED: no producer, table registration or service uses this yet.
*******************************************************************************/
public enum UploadJournalState
{
/*******************************************************************************
** Candidate is durable; no attachment row has been committed for it.
*******************************************************************************/
PREPARED,
/*******************************************************************************
** The attachment row and its checkpoint committed together; the post-commit
** lifecycle registration has not yet been confirmed.
*******************************************************************************/
REGISTERED,
/*******************************************************************************
** Lifecycle registration returned after the registering commit.
*******************************************************************************/
COMPLETE
}
@@ -1,222 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** The one fixed journal table: native QQQ metadata for visible, read-only
** provenance and status, and explicit H2/MySQL DDL. The bucket, storage key,
** manifest hash and lease token are physical columns only; they are NOT QQQ
** fields, so no UI or API surface can return a raw object locator or lease.
** Only the hash of the locator is exposed.
**
** The producer registers this table; hosts apply the appropriate owner DDL
** through migration tooling before enabling retained upload intake.
*******************************************************************************/
public final class UploadJournalTable
{
public static final String NAME = CollaborationTables.UPLOAD_JOURNAL;
/*******************************************************************************
** Utility class.
*******************************************************************************/
private UploadJournalTable()
{
}
/*******************************************************************************
** Table metadata with no editable field; direct writes are denied by
** UploadJournalSecurity. Field names line up with the parent-reference
** metadata used by attachments (tableName, recordId, createdByUserId).
*******************************************************************************/
public static QTableMetaData metaData(CollaborationQBitConfig config)
{
return (new QTableMetaData().withName(NAME).withLabel("Retained Upload Journal").withIcon(new QIcon("history_edu"))
.withBackendName(config.getBackendName()).withPrimaryKeyField("id")
.withPermissionRules(CollaborationPermissions.permissionRules().withCustomPermissionChecker(new QCodeReference(UploadJournalSecurity.class)))
.withCustomizer(com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers.POST_QUERY_RECORD, new QCodeReference(UploadJournalSecurity.class))
.withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_COUNT, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.QUERY_STATS)
.withRecordLabelFormatAndFields("%s", "fileName")
.withUniqueKey(new UniqueKey("locatorHash"))
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("ID").withIsEditable(false))
.withField(new QFieldMetaData("createDate", QFieldType.DATE_TIME).withLabel("Created").withIsEditable(false))
.withField(new QFieldMetaData("modifyDate", QFieldType.DATE_TIME).withLabel("Modified").withIsEditable(false))
.withField(string("tableName", "Table", 128))
.withField(string("recordId", "Record ID", 64))
.withField(string("createdByUserId", "Original Actor", 255).withPossibleValueSourceName(CollaborationTables.USER))
.withField(string("fileName", "File Name", 255))
.withField(string("contentType", "Content Type", 255))
.withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description").withIsEditable(false))
.withField(new QFieldMetaData("sizeBytes", QFieldType.LONG).withLabel("Size (bytes)").withIsEditable(false))
.withField(string("sha256", "SHA-256", 64))
.withField(new QFieldMetaData("retained", QFieldType.BOOLEAN).withLabel("Retained").withIsEditable(false))
.withField(string("intakeKey", "Intake Key", 64))
.withField(string("intakeFingerprint", "Intake Fingerprint", 64))
.withField(string("locatorHash", "Object Location Hash", 64))
.withField(string("state", "Registration State", 16))
.withField(string("storageState", "Original Object", 16))
.withField(new QFieldMetaData("attachmentId", QFieldType.INTEGER).withLabel("Attachment").withIsEditable(false)
.withPossibleValueSourceName(CollaborationTables.ATTACHMENT))
.withField(new QFieldMetaData("leaseUntil", QFieldType.DATE_TIME).withLabel("Lease Until").withIsEditable(false))
.withField(new QFieldMetaData("fence", QFieldType.LONG).withLabel("Reservation Count").withIsEditable(false))
.withSection(new QFieldSection("identity", new QIcon("badge"), Tier.T1, List.of("id", "fileName", "state", "storageState", "attachmentId")).withLabel("Status"))
.withSection(new QFieldSection("source", new QIcon("link"), Tier.T2, List.of("tableName", "recordId", "createdByUserId", "contentType", "sizeBytes", "sha256", "description")).withLabel("Source"))
.withSection(new QFieldSection("recovery", new QIcon("storage"), Tier.T3, List.of("locatorHash", "intakeKey", "intakeFingerprint", "leaseUntil", "fence", "retained")).withLabel("Recovery")));
}
/*******************************************************************************
** H2 DDL for the qualified adapter only. Unquoted lowercase identifiers match
** QQQ's inferred snake_case backend names. The constraints are database
** guards: retained rows only, forward-only states, intake pairing, and no
** REGISTERED/COMPLETE row without its attachment id. PostgreSQL has its own
** script, postgresScript().
*******************************************************************************/
public static String h2Script()
{
return ("CREATE TABLE IF NOT EXISTS collaboration_upload_journal (\n"
+ " id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,\n"
+ " create_date TIMESTAMP NOT NULL,\n"
+ " modify_date TIMESTAMP NOT NULL,\n"
+ " table_name VARCHAR(128) NOT NULL,\n"
+ " record_id VARCHAR(64) NOT NULL,\n"
+ " created_by_user_id VARCHAR(255) NOT NULL,\n"
+ " file_name VARCHAR(255) NOT NULL,\n"
+ " content_type VARCHAR(255) NOT NULL,\n"
+ " description VARCHAR(4096),\n"
+ " size_bytes BIGINT NOT NULL,\n"
+ " sha256 VARCHAR(64) NOT NULL,\n"
+ " bucket_name VARCHAR(255) NOT NULL,\n"
+ " storage_key VARCHAR(1024) NOT NULL,\n"
+ " retained BOOLEAN NOT NULL,\n"
+ " intake_key VARCHAR(64),\n"
+ " intake_fingerprint VARCHAR(64),\n"
+ " locator_hash VARCHAR(64) NOT NULL,\n"
+ " manifest_hash VARCHAR(64) NOT NULL,\n"
+ " state VARCHAR(16) NOT NULL,\n"
+ " storage_state VARCHAR(16) NOT NULL,\n"
+ " attachment_id INTEGER,\n"
+ " lease_token VARCHAR(64),\n"
+ " lease_until TIMESTAMP,\n"
+ " fence BIGINT NOT NULL,\n"
+ " CONSTRAINT ck_upload_journal_retained CHECK (retained = TRUE),\n"
+ " CONSTRAINT ck_upload_journal_state CHECK (state IN ('PREPARED', 'REGISTERED', 'COMPLETE')),\n"
+ " CONSTRAINT ck_upload_journal_storage CHECK (storage_state IN ('PENDING', 'UNKNOWN', 'VERIFIED')),\n"
+ " CONSTRAINT ck_upload_journal_intake CHECK ((intake_key IS NULL) = (intake_fingerprint IS NULL)),\n"
+ " CONSTRAINT ck_upload_journal_registered CHECK (state = 'PREPARED' OR attachment_id IS NOT NULL),\n"
+ " CONSTRAINT ck_upload_journal_verified CHECK (state = 'PREPARED' OR storage_state = 'VERIFIED')\n"
+ ");\n"
+ "CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_upload_journal_locator ON collaboration_upload_journal (locator_hash);\n"
+ "CREATE UNIQUE INDEX IF NOT EXISTS uq_collaboration_upload_journal_intake ON collaboration_upload_journal (intake_key);\n"
+ "CREATE INDEX IF NOT EXISTS idx_collaboration_upload_journal_open ON collaboration_upload_journal (state, id);\n"
+ "CREATE INDEX IF NOT EXISTS idx_collaboration_upload_journal_parent ON collaboration_upload_journal (table_name, record_id);\n");
}
/*******************************************************************************
** PostgreSQL 17 schema for the native postgres backend. Same columns, CHECK
** guards and unique/ordinary indexes as the H2 script; instants are
** TIMESTAMPTZ so a lease fence round-trips independent of the JVM zone.
** Default (deterministic) collation keeps identifiers exact. Statements are
** idempotent, so the script is safe to re-run.
*******************************************************************************/
public static String postgresScript()
{
return (h2Script().replace("TIMESTAMP NOT NULL,", "TIMESTAMPTZ NOT NULL,").replace("lease_until TIMESTAMP,", "lease_until TIMESTAMPTZ,"));
}
/*******************************************************************************
** MySQL 8.4 schema. Binary NO PAD comparison preserves exact identifiers;
** millisecond timestamps preserve lease fences across connection round trips.
*******************************************************************************/
public static String mysqlScript()
{
return ("CREATE TABLE IF NOT EXISTS collaboration_upload_journal (\n"
+ " id INTEGER AUTO_INCREMENT PRIMARY KEY,\n"
+ " create_date TIMESTAMP(3) NOT NULL,\n"
+ " modify_date TIMESTAMP(3) NOT NULL,\n"
+ " table_name VARCHAR(128) NOT NULL,\n"
+ " record_id VARCHAR(64) NOT NULL,\n"
+ " created_by_user_id VARCHAR(255) NOT NULL,\n"
+ " file_name VARCHAR(255) NOT NULL,\n"
+ " content_type VARCHAR(255) NOT NULL,\n"
+ " description VARCHAR(4096),\n"
+ " size_bytes BIGINT NOT NULL,\n"
+ " sha256 VARCHAR(64) NOT NULL,\n"
+ " bucket_name VARCHAR(255) NOT NULL,\n"
+ " storage_key VARCHAR(1024) NOT NULL,\n"
+ " retained BOOLEAN NOT NULL,\n"
+ " intake_key VARCHAR(64),\n"
+ " intake_fingerprint VARCHAR(64),\n"
+ " locator_hash VARCHAR(64) NOT NULL,\n"
+ " manifest_hash VARCHAR(64) NOT NULL,\n"
+ " state VARCHAR(16) NOT NULL,\n"
+ " storage_state VARCHAR(16) NOT NULL,\n"
+ " attachment_id INTEGER,\n"
+ " lease_token VARCHAR(64),\n"
+ " lease_until TIMESTAMP(3),\n"
+ " fence BIGINT NOT NULL,\n"
+ " CONSTRAINT ck_upload_journal_retained CHECK (retained = TRUE),\n"
+ " CONSTRAINT ck_upload_journal_state CHECK (state IN ('PREPARED', 'REGISTERED', 'COMPLETE')),\n"
+ " CONSTRAINT ck_upload_journal_storage CHECK (storage_state IN ('PENDING', 'UNKNOWN', 'VERIFIED')),\n"
+ " CONSTRAINT ck_upload_journal_intake CHECK ((intake_key IS NULL) = (intake_fingerprint IS NULL)),\n"
+ " CONSTRAINT ck_upload_journal_registered CHECK (state = 'PREPARED' OR attachment_id IS NOT NULL),\n"
+ " CONSTRAINT ck_upload_journal_verified CHECK (state = 'PREPARED' OR storage_state = 'VERIFIED'),\n"
+ " UNIQUE KEY uq_collaboration_upload_journal_locator (locator_hash),\n"
+ " UNIQUE KEY uq_collaboration_upload_journal_intake (intake_key),\n"
+ " KEY idx_collaboration_upload_journal_open (state, id),\n"
+ " KEY idx_collaboration_upload_journal_parent (table_name, record_id)\n"
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_bin;\n");
}
/*******************************************************************************
** A bounded read-only string field.
*******************************************************************************/
private static QFieldMetaData string(String name, String label, Integer length)
{
return (new QFieldMetaData(name, QFieldType.STRING).withLabel(label).withMaxLength(length).withIsEditable(false));
}
}
@@ -1,63 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.CurrentUser;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Small metadata contract for native recovery navigation from a source's Files
** panel. The client adds canonical source table/id pins to this server-owned view.
** Filters aid discovery only; the journal still authorizes every row and action.
*******************************************************************************/
public final class UploadRecoveryNavigation
{
/*******************************************************************************
** Metadata utility.
*******************************************************************************/
private UploadRecoveryNavigation()
{
}
/*******************************************************************************
** Retention owners opt in their managed source tables after producing them.
** No client knows or guesses the journal table or recovery process name.
*******************************************************************************/
public static void configure(QTableMetaData source)
{
Map<String, Serializable> view = new LinkedHashMap<>();
view.put("tableName", UploadJournalTable.NAME);
view.put("label", "Review interrupted uploads");
view.put("filter", new QQueryFilter().withCriteria("createdByUserId", QCriteriaOperator.EQUALS, new CurrentUser())
.withCriteria("state", QCriteriaOperator.IN, "PREPARED", "REGISTERED"));
RecordFeaturesMetaData.ofOrWithNew(source).withSetting(RecordFeature.ATTACHMENTS, "recoveryView", (Serializable) view);
}
}
@@ -1,125 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.time.Instant;
/*******************************************************************************
** Immutable write reservation: an exclusive, fenced lease on one journal row.
** Every mutating journal call re-checks the token, fence and expiry under a row
** lock, so a holder that lost its lease cannot act. The token is generated by
** the journal, is never persisted outside the journal row, never printed, and
** is not accepted from clients. The snapshot is as of the grant.
*******************************************************************************/
public final class UploadReservation
{
private final UploadEntry entry;
private final String leaseToken;
private final Long fence;
private final Instant leaseUntil;
/*******************************************************************************
** Package-local; only the journal grants reservations.
*******************************************************************************/
UploadReservation(UploadEntry entry, String leaseToken, Long fence, Instant leaseUntil)
{
this.entry = entry;
this.leaseToken = leaseToken;
this.fence = fence;
this.leaseUntil = leaseUntil;
}
/*******************************************************************************
** Journal row id.
*******************************************************************************/
public Integer journalId()
{
return (entry.id());
}
/*******************************************************************************
** The preserved manifest.
*******************************************************************************/
public UploadCandidate candidate()
{
return (entry.candidate());
}
/*******************************************************************************
** Journal state as of the grant; read the journal for current state.
*******************************************************************************/
public UploadEntry entryAtGrant()
{
return (entry);
}
/*******************************************************************************
** Fencing counter of this grant.
*******************************************************************************/
public Long fence()
{
return (fence);
}
/*******************************************************************************
** Lease expiry of this grant.
*******************************************************************************/
public Instant leaseUntil()
{
return (leaseUntil);
}
/*******************************************************************************
** Secret proof of ownership, visible only to the journal implementation.
*******************************************************************************/
String leaseToken()
{
return (leaseToken);
}
/*******************************************************************************
** Never prints the token.
*******************************************************************************/
@Override
public String toString()
{
return ("UploadReservation[journalId=" + entry.id() + ", fence=" + fence + ", leaseUntil=" + leaseUntil + "]");
}
}
@@ -1,46 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
/*******************************************************************************
** What the journal knows about the original object. The journal never reads or
** writes bytes; the storage owner reports these facts. Never moves backwards.
*******************************************************************************/
public enum UploadStorageState
{
/*******************************************************************************
** No write has been attempted that the journal knows of.
*******************************************************************************/
PENDING,
/*******************************************************************************
** A write was attempted and its outcome is not known (timeout, crash). The
** object may exist; it stays visible and reconcilable and is never deleted.
*******************************************************************************/
UNKNOWN,
/*******************************************************************************
** The storage owner verified the exact bytes and hash of the original.
*******************************************************************************/
VERIFIED
}
@@ -26,8 +26,8 @@ import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationSecurity;
import com.kingsrook.qbits.collaboration.attachments.AttachmentAggregateGuard;
import com.kingsrook.qbits.collaboration.attachments.AttachmentPreviewCustomizer;
import com.kingsrook.qbits.collaboration.attachments.AttachmentSecurity;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.values.FeatureScopeNamePossibleValueProvider;
import com.kingsrook.qbits.collaboration.values.RecordFieldNamePossibleValueProvider;
@@ -80,8 +80,6 @@ public final class CollaborationTables
public static final String ACTIVITY = "collaboration_activity";
public static final String FOLLOW_DIGEST = "collaboration_follow_digest";
public static final String ATTACHMENT = "collaboration_attachment";
public static final String ATTACHMENT_CLEANUP = "collaboration_attachment_cleanup";
public static final String UPLOAD_JOURNAL = "collaboration_upload_journal";
public static final String FEATURE_SETTING = "collaboration_feature_setting";
public static final String FAVORITE = "collaboration_favorite";
public static final String RECENT = "collaboration_recent";
@@ -200,26 +198,10 @@ public final class CollaborationTables
.withField(new QFieldMetaData("viewCount", QFieldType.INTEGER).withLabel("View Count").withIsRequired(true).withDefaultValue(1))
.withSection(new QFieldSection("details", new QIcon("history"), Tier.T2, List.of("label", "lastViewedAt", "viewCount")).withLabel("Details"));
List<QTableMetaData> tables = new ArrayList<>(List.of(settings, people, favorites, recents, attachment(config)));
tables.add(com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalTable.metaData(config));
tables.addAll(conversationTables(config));
tables.addAll(tagTables(config));
tables.addAll(workTables(config));
tables.addAll(customFieldTables(config));
tables.add(base(config, ATTACHMENT_CLEANUP, "Attachment Cleanup", "delete")
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED)
.withCustomPermissionChecker(new QCodeReference(com.kingsrook.qbits.collaboration.attachments.AttachmentCleanupQueue.class)))
.withRecordLabelFormatAndFields("Cleanup %s", "id")
.withField(new QFieldMetaData("attachmentId", QFieldType.INTEGER))
.withField(string("bucketName", "Bucket", 255).withIsRequired(true))
.withField(string("storageKey", "Storage Key", 1024).withIsRequired(true))
.withField(new QFieldMetaData("nextAttemptAt", QFieldType.DATE_TIME).withLabel("Next Attempt"))
.withField(new QFieldMetaData("resolvedAt", QFieldType.DATE_TIME).withLabel("Resolved At"))
.withField(string("resolvedBy", "Resolved By", 255))
.withField(string("settlementEvidence", "Provider Settlement Evidence", 2000))
.withSection(new QFieldSection("cleanup", new QIcon("delete"), Tier.T1,
List.of("id", "attachmentId", "bucketName", "storageKey", "nextAttemptAt")).withLabel("Cleanup"))
.withSection(new QFieldSection("settlement", new QIcon("check"), Tier.T2,
List.of("resolvedAt", "resolvedBy", "settlementEvidence")).withLabel("Operator Settlement")));
for(QTableMetaData table : tables)
{
table.withSection(new QFieldSection("dates", new QIcon("calendar_today"), Tier.T3, List.of("createDate", "modifyDate")).withLabel("Dates"));
@@ -291,9 +273,9 @@ public final class CollaborationTables
*******************************************************************************/
private static QTableMetaData attachment(CollaborationQBitConfig config)
{
return (AttachmentAggregateGuard.configure(base(config, ATTACHMENT, "Attachments", "attach_file")
return (base(config, ATTACHMENT, "Attachments", "attach_file")
.withPermissionRules(CollaborationPermissions.permissionRules()
.withCustomPermissionChecker(new QCodeReference(AttachmentAggregateGuard.class)))
.withCustomPermissionChecker(new QCodeReference(AttachmentSecurity.class)))
.withRecordLabelFormatAndFields("%s", "fileName")
.withSearchFields(List.of("fileName", "description"))
.withAuditRules(new QAuditRules().withAuditLevel(AuditLevel.FIELD))
@@ -303,39 +285,30 @@ public final class CollaborationTables
.withField(string("contentType", "Content Type", 255).withIsRequired(true))
.withField(new QFieldMetaData("sizeBytes", QFieldType.LONG).withLabel("Size (bytes)").withIsRequired(true))
.withField(string("sha256", "SHA-256", 64).withIsRequired(true))
.withField(string("intakeKey", "Intake Key", 64).withIsEditable(false))
.withField(string("intakeFingerprint", "Intake Fingerprint", 64).withIsEditable(false))
.withField(string("bucketName", "Bucket", 255).withIsRequired(true))
.withField(string("storageKey", "Storage Key", 1024).withIsRequired(true))
.withField(string("thumbnailKey", "Thumbnail Key", 1024))
.withField(new QFieldMetaData("description", QFieldType.TEXT).withLabel("Description"))
.withField(user("createdByUserId", "Created By").withIsRequired(true))
.withField(new QFieldMetaData("parentDeleted", QFieldType.BOOLEAN).withLabel("Parent Deleted").withIsRequired(true).withDefaultValue(false))
.withField(new QFieldMetaData("retained", QFieldType.BOOLEAN).withLabel("Retained").withIsRequired(true).withDefaultValue(false).withIsEditable(false))
.withVirtualField(new QVirtualFieldMetaData("previewUrl", QFieldType.STRING).withLabel("Preview").withIsEditable(false).withIsQuerySelectable(true)
.withFieldAdornment(AdornmentType.FilePreviewValues.newFieldAdornment()
.withValue(AdornmentType.FilePreviewValues.FILE_NAME_FIELD, "fileName")
.withValue(AdornmentType.FilePreviewValues.MIME_TYPE_FIELD, "contentType")
.withValue(AdornmentType.FilePreviewValues.SIZE_BYTES_FIELD, "sizeBytes")
.withValue(AdornmentType.FilePreviewValues.THUMBNAIL_MAX_SIZE, 48))
.withFieldAdornment(new FieldAdornment(AdornmentType.FILE_DOWNLOAD)
.withValue(AdornmentType.FileDownloadValues.FILE_NAME_FIELD, "fileName")
.withValue("deliveryModeField", "previewDelivery")))
.withValue(AdornmentType.FilePreviewValues.THUMBNAIL_MAX_SIZE, 48)))
.withVirtualField(new QVirtualFieldMetaData("downloadUrl", QFieldType.STRING).withLabel("Download").withIsEditable(false).withIsQuerySelectable(true)
.withFieldAdornment(new FieldAdornment(AdornmentType.FILE_DOWNLOAD)
.withValue(AdornmentType.FileDownloadValues.FILE_NAME_FIELD, "fileName")
.withValue(AdornmentType.FileDownloadValues.DOWNLOAD_URL_DYNAMIC, true)
.withValue("deliveryModeField", "downloadDelivery")))
.withVirtualField(new QVirtualFieldMetaData("previewDelivery", QFieldType.STRING).withLabel("Preview Delivery").withIsEditable(false).withIsQuerySelectable(true))
.withVirtualField(new QVirtualFieldMetaData("downloadDelivery", QFieldType.STRING).withLabel("Download Delivery").withIsEditable(false).withIsQuerySelectable(true))
.withValue(AdornmentType.FileDownloadValues.DOWNLOAD_URL_DYNAMIC, true)))
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(AttachmentPreviewCustomizer.class))
.withSection(new QFieldSection("file", new QIcon("attach_file"), Tier.T1,
List.of("fileName", "contentType", "sizeBytes", "description")).withLabel("File")
.withAlternative(QFieldSectionAlternativeType.RECORD_VIEW, section -> section.setFieldNames(List.of("previewUrl", "downloadUrl", "fileName", "contentType", "sizeBytes", "description"))))
.withSection(new QFieldSection("parent", new QIcon("link"), Tier.T2,
List.of("tableName", "recordId", "createdByUserId", "parentDeleted", "retained")).withLabel("Parent"))
List.of("tableName", "recordId", "createdByUserId", "parentDeleted")).withLabel("Parent"))
.withSection(new QFieldSection("storage", new QIcon("storage"), Tier.T3,
List.of("id", "bucketName", "storageKey", "thumbnailKey", "sha256", "intakeKey", "intakeFingerprint")).withLabel("Storage")), config));
List.of("id", "bucketName", "storageKey", "thumbnailKey", "sha256")).withLabel("Storage")));
}
@@ -21,33 +21,18 @@
package com.kingsrook.qbits.collaboration.presence;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.WeakHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.realtime.RealtimeBus;
@@ -64,7 +49,6 @@ public final class PresenceRegistry implements AutoCloseable
public static final long TTL_MILLIS = 45000;
public static final int MAX_ENTRIES = 50000;
private static final QLogger LOG = QLogger.getLogger(PresenceRegistry.class);
private static final byte[] SESSION_SECRET = sessionSecret();
private static final Map<QInstance, PresenceRegistry> REGISTRIES = new WeakHashMap<>();
private final Map<Key, Entry> entries = new HashMap<>();
private final RealtimeBusInterface bus;
@@ -133,52 +117,27 @@ public final class PresenceRegistry implements AutoCloseable
}
/*******************************************************************************
** Each tab has its own lease; user views merge the live leases. The lease
** keeps an opaque id of the server-side session (never the session uuid,
** which is a bearer credential) and the time it started (renewals keep it).
** Each tab has its own lease; user views merge the live leases.
*******************************************************************************/
private record Entry(String displayName, String mode, String fieldName, long at, String sessionId, long since)
private record Entry(String displayName, String mode, String fieldName, long at)
{
}
/*******************************************************************************
** Transport-independent snapshot entry, never a persisted directory row.
** Merged per user, connections counts the live leases behind it; per
** connection (connections = 1), connectionId is an opaque per-lease id,
** sessionId an opaque id of the signed-in session (HMAC, never reversible to
** the session uuid) and since the lease start (ISO-8601). None of the three
** is set on merged viewers.
*******************************************************************************/
public record Viewer(String userId, String displayName, String mode, String fieldName, Integer connections, @JsonInclude(JsonInclude.Include.NON_NULL) String connectionId,
@JsonInclude(JsonInclude.Include.NON_NULL) String sessionId, @JsonInclude(JsonInclude.Include.NON_NULL) String since) implements Serializable
public record Viewer(String userId, String displayName, String mode, String fieldName) implements Serializable
{
/*******************************************************************************
** A merged viewer behind a number of live leases.
*******************************************************************************/
public Viewer(String userId, String displayName, String mode, String fieldName, Integer connections)
{
this(userId, displayName, mode, fieldName, connections, null, null, null);
}
}
/*******************************************************************************
** Refresh locally even if transport delivery fails; replicate every heartbeat.
*******************************************************************************/
public boolean heartbeat(String user, String displayName, String connection, String table, String pk, String mode, String fieldName)
{
return (heartbeat(user, displayName, connection, null, table, pk, mode, fieldName));
}
/*******************************************************************************
** Renew a lease tied to the caller's server-side session. Only an opaque HMAC
** of the session uuid is kept and replicated; the uuid itself is dropped here.
*******************************************************************************/
public boolean heartbeat(String user, String displayName, String connection, String sessionUuid, String table, String pk, String mode, String fieldName)
{
expire();
var key = new Key(user, connection, table, pk);
long now = clock.millis();
var entry = new Entry(displayName, mode, fieldName, now, opaqueSessionId(sessionUuid), now);
var entry = new Entry(displayName, mode, fieldName, clock.millis());
if(!apply(key, entry, false))
{
return (false);
@@ -193,8 +152,7 @@ public final class PresenceRegistry implements AutoCloseable
public void leave(String user, String connection, String table, String pk)
{
var key = new Key(user, connection, table, pk);
long now = clock.millis();
var entry = new Entry(null, null, null, now, null, now);
var entry = new Entry(null, null, null, clock.millis());
apply(key, entry, true);
publish(key, entry, true);
}
@@ -213,7 +171,6 @@ public final class PresenceRegistry implements AutoCloseable
data.put("mode", entry.mode());
data.put("fieldName", entry.fieldName());
data.put("at", entry.at());
data.put("sessionId", entry.sessionId());
data.put("leave", leave);
bus.publish(new RealtimeMessage().withChannel(INTERNAL_CHANNEL).withEvent("presence.heartbeat").withData(data));
}
@@ -240,7 +197,7 @@ public final class PresenceRegistry implements AutoCloseable
return;
}
apply(new Key(user, connection, table, (String) data.get("pk")),
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt, (String) data.get("sessionId"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
new Entry((String) data.get("displayName"), (String) data.get("mode"), (String) data.get("fieldName"), receivedAt), Boolean.TRUE.equals(data.get("leave")));
}
/*******************************************************************************
@@ -261,25 +218,16 @@ public final class PresenceRegistry implements AutoCloseable
LOG.warn("Presence capacity reached; refusing a new target");
return (false);
}
List<Viewer> before = projection(key.table(), key.pk());
List<Viewer> before = snapshotInternal(key.table(), key.pk());
if(leave)
{
entries.remove(key);
}
else
{
/////////////////////////////////////////////////////////////////
// a renewal keeps the lease start (pods may differ by a little: //
// the earliest start wins) and the first known session id //
/////////////////////////////////////////////////////////////////
if(old != null)
{
entry = new Entry(entry.displayName(), entry.mode(), entry.fieldName(), entry.at(),
old.sessionId() != null ? old.sessionId() : entry.sessionId(), Math.min(old.since(), entry.since()));
}
entries.put(key, entry);
}
changed = !before.equals(projection(key.table(), key.pk()));
changed = !before.equals(snapshotInternal(key.table(), key.pk()));
}
if(changed)
{
@@ -326,137 +274,16 @@ public final class PresenceRegistry implements AutoCloseable
*******************************************************************************/
private List<Viewer> snapshotInternal(String table, String pk)
{
Map<String, Entry> users = new TreeMap<>();
Map<String, Integer> connections = new TreeMap<>();
for(var row : live(table, pk))
{
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
connections.merge(row.getKey().user(), 1, Integer::sum);
}
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(),
pk == null ? null : row.getValue().fieldName(), connections.get(row.getKey()))).toList());
}
/*******************************************************************************
** One viewer per live lease, ordered by user, lease start and opaque id. A
** table target counts each connection once per record it holds a lease on.
*******************************************************************************/
private List<Viewer> connectionsInternal(String table, String pk)
{
return (live(table, pk).stream()
.map(row -> new Viewer(row.getKey().user(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName(), 1,
opaqueConnectionId(row.getKey().user(), row.getKey().connection()), row.getValue().sessionId(), Instant.ofEpochMilli(row.getValue().since()).toString()))
.sorted(java.util.Comparator.comparing(Viewer::userId).thenComparing(Viewer::since).thenComparing(Viewer::connectionId))
.toList());
}
/*******************************************************************************
** Live leases on a table (any record when pk is null) or on one record.
*******************************************************************************/
private List<Map.Entry<Key, Entry>> live(String table, String pk)
{
List<Map.Entry<Key, Entry>> result = new ArrayList<>();
Map<String, Entry> users = new java.util.TreeMap<>();
for(var row : entries.entrySet())
{
if(row.getKey().table().equals(table) && (pk == null || Objects.equals(row.getKey().pk(), pk)) && row.getValue().at() + TTL_MILLIS > clock.millis())
{
result.add(row);
users.merge(row.getKey().user(), row.getValue(), (old, next) ->
(!Objects.equals(old.mode(), next.mode()) ? "EDITING".equals(next.mode()) : next.at() >= old.at()) ? next : old);
}
}
return (result);
}
/*******************************************************************************
** What clients can observe: merged viewers and per-connection viewers. Lease
** renewals that change neither generate no event.
*******************************************************************************/
private List<Viewer> projection(String table, String pk)
{
List<Viewer> result = new ArrayList<>(snapshotInternal(table, pk));
result.addAll(connectionsInternal(table, pk));
return (result);
}
/*******************************************************************************
** Opaque, stable per-lease id: "c" and the first 16 hex digits of
** SHA-256(userId + "\n" + connectionId). A client can recognise its own
** connection; nobody learns another client's connection id.
*******************************************************************************/
public static String opaqueConnectionId(String userId, String connectionId)
{
try
{
byte[] digest = MessageDigest.getInstance("SHA-256").digest((userId + "\n" + connectionId).getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder("c");
for(int i = 0; i < 8; i++)
{
result.append(String.format("%02x", digest[i]));
}
return (result.toString());
}
catch(NoSuchAlgorithmException e)
{
throw (new IllegalStateException("SHA-256 is unavailable", e));
}
}
/*******************************************************************************
** Opaque session id: "s" and the first 16 hex digits of
** HMAC-SHA256(secret, sessionUuid), or null without a session. The secret is
** QQQ_PRESENCE_SESSION_SECRET (system property or environment; share it across
** pods for ids that agree between pods) or else random per process. Session
** uuids are bearer credentials and must never be stored or published.
*******************************************************************************/
public static String opaqueSessionId(String sessionUuid)
{
if(sessionUuid == null || sessionUuid.isBlank())
{
return (null);
}
try
{
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(SESSION_SECRET, "HmacSHA256"));
byte[] digest = mac.doFinal(sessionUuid.getBytes(StandardCharsets.UTF_8));
StringBuilder result = new StringBuilder("s");
for(int i = 0; i < 8; i++)
{
result.append(String.format("%02x", digest[i]));
}
return (result.toString());
}
catch(NoSuchAlgorithmException | InvalidKeyException e)
{
throw (new IllegalStateException("HmacSHA256 is unavailable", e));
}
}
/*******************************************************************************
** Configured shared secret, or 32 random bytes for this process.
*******************************************************************************/
private static byte[] sessionSecret()
{
String configured = System.getProperty("QQQ_PRESENCE_SESSION_SECRET", System.getenv("QQQ_PRESENCE_SESSION_SECRET"));
if(configured != null && !configured.isBlank())
{
return (configured.getBytes(StandardCharsets.UTF_8));
}
byte[] random = new byte[32];
new SecureRandom().nextBytes(random);
return (random);
}
/*******************************************************************************
** One viewer per live lease (opt-in per-connection presence, #54).
*******************************************************************************/
public List<Viewer> connections(String table, String pk)
{
expire();
synchronized(this)
{
return (connectionsInternal(table, pk));
}
return (users.entrySet().stream().map(row -> new Viewer(row.getKey(), row.getValue().displayName(), row.getValue().mode(), pk == null ? null : row.getValue().fieldName())).toList());
}
/*******************************************************************************
@@ -467,27 +294,12 @@ public final class PresenceRegistry implements AutoCloseable
expire();
synchronized(this)
{
Map<String, Integer> result = new TreeMap<>();
Map<String, Integer> result = new java.util.TreeMap<>();
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, snapshotInternal(table, null).size()));
return (result);
}
}
/*******************************************************************************
** Live lease (connection) counts per table, next to the user counts above.
*******************************************************************************/
public Map<String, Integer> connectionCounts()
{
expire();
synchronized(this)
{
Map<String, Integer> result = new TreeMap<>();
entries.keySet().stream().map(Key::table).distinct().forEach(table -> result.put(table, live(table, null).size()));
result.values().removeIf(count -> count == 0);
return (result);
}
}
/*******************************************************************************
** Record focus stays on its record channel; table events contain table snapshots.
*******************************************************************************/
@@ -511,97 +323,23 @@ public final class PresenceRegistry implements AutoCloseable
{
data.put("pk", pk);
}
Viewers viewers = new Viewers();
Viewers leases = new Viewers();
ArrayList<LinkedHashMap<String, Serializable>> viewers = new ArrayList<>();
synchronized(this)
{
snapshotInternal(table, pk).forEach(viewer -> viewers.add(toMap(viewer)));
connectionsInternal(table, pk).forEach(viewer -> leases.add(toMap(viewer)));
for(Viewer viewer : snapshotInternal(table, pk))
{
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
value.put("userId", viewer.userId());
value.put("displayName", viewer.displayName());
value.put("mode", viewer.mode());
value.put("fieldName", viewer.fieldName());
viewers.add(value);
}
}
data.put("viewers", viewers);
data.put("leaseCount", leases.size());
data.put("leases", leases);
bus.publish(new RealtimeMessage().withChannel(channel).withEvent("presence.changed").withData(data));
}
/*******************************************************************************
** Event shape of a viewer; per-connection fields only when set.
*******************************************************************************/
private static LinkedHashMap<String, Serializable> toMap(Viewer viewer)
{
LinkedHashMap<String, Serializable> value = new LinkedHashMap<>();
value.put("userId", viewer.userId());
value.put("displayName", viewer.displayName());
value.put("mode", viewer.mode());
value.put("fieldName", viewer.fieldName());
value.put("connections", viewer.connections());
if(viewer.connectionId() != null)
{
value.put("connectionId", viewer.connectionId());
if(viewer.sessionId() != null)
{
value.put("sessionId", viewer.sessionId());
}
value.put("since", viewer.since());
}
return (value);
}
/*******************************************************************************
** Event viewers list that is never omitted: the SSE writer serializes event data
** with NON_EMPTY inclusion, which would drop an empty list, but the schema
** declares viewers a required array, so the last leave must send [] (#52).
*******************************************************************************/
@JsonSerialize(using = ViewersSerializer.class)
public static final class Viewers extends ArrayList<LinkedHashMap<String, Serializable>>
{
}
/*******************************************************************************
** Writes each viewer with the caller's mapper settings and reports never empty.
*******************************************************************************/
public static final class ViewersSerializer extends StdSerializer<Viewers>
{
/*******************************************************************************
** Jackson instantiates the serializer reflectively.
*******************************************************************************/
public ViewersSerializer()
{
super(Viewers.class);
}
/*******************************************************************************
** Always an array, including the empty array after the last viewer leaves.
*******************************************************************************/
@Override
public void serialize(Viewers viewers, JsonGenerator generator, SerializerProvider provider) throws IOException
{
generator.writeStartArray(viewers, viewers.size());
for(LinkedHashMap<String, Serializable> viewer : viewers)
{
provider.defaultSerializeValue(viewer, generator);
}
generator.writeEndArray();
}
/*******************************************************************************
** NON_EMPTY inclusion consults this; an empty viewer list is still a value.
*******************************************************************************/
@Override
public boolean isEmpty(SerializerProvider provider, Viewers viewers)
{
return (false);
}
}
/*******************************************************************************
** Release listeners and timer on host shutdown or in tests.
*******************************************************************************/
@@ -62,7 +62,7 @@ public class PresenceService
throw (new QBadRequestException("Unknown focused field"));
}
String name = QContext.getQSession().getUser().getFullName();
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, QContext.getQSession().getUuid(), table, pk, mode, field))
if(!registry().heartbeat(user, name == null || name.isBlank() ? user : name, connection, table, pk, mode, field))
{
throw (new QBadRequestException("Presence capacity reached; retry later"));
}
@@ -89,15 +89,6 @@ public class PresenceService
return (registry().snapshot(table, authorize(table, pk)));
}
/*******************************************************************************
** One viewer per live lease, behind the same access boundary as snapshot.
*******************************************************************************/
public List<PresenceRegistry.Viewer> connections(String table, String pk) throws QException
{
PersonalRecords.currentUserId();
return (registry().connections(table, authorize(table, pk)));
}
/*******************************************************************************
** A refusal always has the same not-found result, including disabled features.
*******************************************************************************/
@@ -134,12 +134,6 @@ entries:
summary: "The person who created this entry."
- key: table:collaboration_attachment;field:parentDeleted
summary: "Whether the related record has been deleted."
- key: table:collaboration_attachment;field:retained
summary: "Whether the original file is protected from deletion, including after its related record is deleted."
- key: table:collaboration_attachment;field:intakeKey
summary: "The server-scoped identity used to recover a repeated file intake request."
- key: table:collaboration_attachment;field:intakeFingerprint
summary: "The fingerprint that prevents a repeated intake request from changing its original file or metadata."
- key: table:collaboration_comment;slot:help
summary: "Read record discussions, including replies, authors and resolution details."
content: |
@@ -752,83 +746,3 @@ entries:
summary: "Choose the record feature you want to configure."
- key: possibleValueSource:collaboration_permission_mode;slot:help
summary: "Choose whether a feature accepts editing permission or requires an explicit feature permission."
- key: table:collaboration_attachment_cleanup;slot:help
summary: "Review storage cleanup waiting to finish after an attachment was deleted."
content: |
**How rows get created:** Authorized attachment operations create these records automatically. Deleted files are removed after the database commits. Uncertain uploads remain pending until the provider has finished writing. Ordinary successful cleanup removes its entry; administrator-resolved uploads retain a receipt. Administrators can inspect the records but cannot edit them directly.
- key: table:collaboration_attachment_cleanup;field:attachmentId
summary: "The deleted attachment, when known; failed uploads may have no committed attachment ID."
- key: table:collaboration_attachment_cleanup;field:bucketName
summary: "The storage bucket containing the file awaiting cleanup."
- key: table:collaboration_attachment_cleanup;field:storageKey
summary: "The immutable file location retained until cleanup succeeds."
- key: table:collaboration_attachment_cleanup;field:nextAttemptAt
summary: "Failed cleanup waits at least one minute before another scheduled attempt."
- key: table:collaboration_attachment_cleanup;field:resolvedAt
summary: "When an administrator resolved this upload. Resolved receipts are excluded from retries."
- key: table:collaboration_attachment_cleanup;field:resolvedBy
summary: "The signed-in administrator who resolved this upload. The original identity is retained on repeat requests."
- key: table:collaboration_attachment_cleanup;field:settlementEvidence
summary: "Evidence that the provider completed or cancelled the upload. Never include credentials or private file contents."
- key: table:collaboration_attachment_cleanup;section:settlement
summary: "Retained evidence and identity for an administrator-resolved upload."
- key: process:collaborationResolveAttachmentUpload;slot:help
summary: "Resolve uncertain uploads only after confirming that the provider has finished writing."
content: |
Select upload intents, record evidence of provider completion or cancellation for every selected location, and confirm the Review screen. Object absence or elapsed time alone is not proof. If completion cannot be established, leave the intent pending. The action requires an identified collaboration administrator, refuses active or referenced uploads, deletes current bytes, and retains a settlement receipt. Read Result to see which uploads remain unresolved. Existing attachment deletions use normal cleanup retry instead.
- key: process:collaborationRetryAttachmentCleanup;slot:help
summary: "Retry pending attachment file cleanup after storage or database recovery."
content: |
Processes up to 100 committed cleanup requests. Completed entries are removed; failures remain available for retry. Runs every minute when the host configures a collaboration scheduler. Manual execution requires collaboration administrator access.
- key: table:collaboration_upload_journal;slot:help
summary: "Review retained upload progress and recover interrupted registration."
content: |
**How rows get created**
Retained uploads automatically create an immutable journal entry before original storage. PREPARED and REGISTERED entries may need recovery after an interruption; COMPLETE means mandatory registration finished, not that quarantined content was released.
Open a pending row and choose Recover retained upload while signed in as its original uploader with current access to attach files to the source. The action verifies the existing original and retries registration; it never replaces or deletes bytes. If an intake key is present, retry through the original upload workflow instead.
Missing or mismatched storage remains unresolved. Contact your operator if storage is unavailable or the original uploader is no longer active. Waiting for a reservation to expire is not permission to discard evidence.
- key: table:collaboration_upload_journal;field:tableName
summary: "Original source table. A completed archive keeps this provenance unchanged."
- key: table:collaboration_upload_journal;field:recordId
summary: "Original source record identifier. Access follows the exact retained attachment after an archive transfer."
- key: table:collaboration_upload_journal;field:createdByUserId
summary: "Person who initiated the upload. Recovery currently requires this same signed-in person and current source access."
- key: table:collaboration_upload_journal;field:fileName
summary: "Original uploaded file name captured before storing the bytes."
- key: table:collaboration_upload_journal;field:contentType
summary: "Media type captured for the original upload."
- key: table:collaboration_upload_journal;field:description
summary: "Description supplied with the original upload."
- key: table:collaboration_upload_journal;field:sizeBytes
summary: "Measured number of original bytes. Recovery requires the exact same size."
- key: table:collaboration_upload_journal;field:sha256
summary: "SHA-256 calculated from the actual original bytes and checked again during recovery."
- key: table:collaboration_upload_journal;field:retained
summary: "This journal contains retained originals only; recovery never deletes their bytes."
- key: table:collaboration_upload_journal;field:intakeKey
summary: "A value identifies a coordinated intake. Retry through the original intake workflow rather than this recovery action."
- key: table:collaboration_upload_journal;field:intakeFingerprint
summary: "Immutable coordinated request fingerprint. Conflicting retries are rejected."
- key: table:collaboration_upload_journal;field:locatorHash
summary: "Hash identifying the stored object without revealing its storage address."
- key: table:collaboration_upload_journal;field:state
summary: "PREPARED awaits an attachment; REGISTERED awaits lifecycle completion; COMPLETE has saved registration."
- key: table:collaboration_upload_journal;field:storageState
summary: "UNKNOWN may have stored bytes; VERIFIED confirms the exact original. Missing data never proves safe cancellation."
- key: table:collaboration_upload_journal;field:attachmentId
summary: "Exact attachment checkpoint. Recovery reuses this identity rather than creating a replacement."
- key: table:collaboration_upload_journal;field:leaseUntil
summary: "Time until the current recovery reservation expires. Expiry does not delete or cancel the original."
- key: table:collaboration_upload_journal;field:fence
summary: "Monotonically increasing reservation number that prevents an expired attempt from registering metadata."
- key: process:collaborationAdoptRetainedAttachment;slot:help
summary: "Verify an existing attachment's original and register it as retained without copying bytes."
content: |
Select exactly one attachment, confirm the Review screen and read the Result. You must currently be allowed to modify the attachment and see its source record. The action verifies the existing original and completes lifecycle registration under the configured retention policy. It never accepts replacement bytes, a storage path or another person's identity, and it never copies or deletes the original.
Uploads that came from a coordinated intake request cannot be adopted here; retry them through their original workflow. If the original is missing or does not match, the attachment remains unchanged.
- key: process:collaborationRecoverRetainedUpload;slot:help
summary: "Verify an existing retained original and finish interrupted registration."
content: |
Select one journal row and confirm recovery. You must be its original uploader and currently be allowed to attach files to its source. Recovery preserves the original bytes and any committed attachment identity, and may leave the file quarantined for review.
Coordinated intake entries must be retried through their original workflow. Missing or mismatched bytes remain unresolved; the action never creates replacement content or clears a possible original by timeout.
@@ -202,12 +202,7 @@ components:
\ are exposed."
properties:
canDelete:
description: "Whether the caller may delete this attachment; retained files\
\ cannot be deleted."
nullable: true
type: "boolean"
canEdit:
description: "Whether the caller may change the attachment filename or description."
description: "Whether the caller may change or delete this attachment."
nullable: true
type: "boolean"
contentType:
@@ -409,13 +404,9 @@ components:
CollaborationDownloadV1:
description: "Temporary access to file bytes. Clients follow url."
properties:
delivery:
description: "SIGNED_URL or AUTHENTICATED_FETCH. Authenticated fetch requires\
\ the trusted native API transport and a durable receipt check."
nullable: true
type: "string"
url:
description: "Temporary delivery URL; never persist it."
description: "Presigned URL, valid for five minutes. Clients follow url;\
\ never persist it."
nullable: true
type: "string"
type: "object"
@@ -758,19 +749,6 @@ components:
CollaborationPresenceSnapshotV1:
description: "Live viewers, merged across tabs; presence.changed uses this payload."
properties:
leaseCount:
description: "Live connections (leases) behind viewers; a user with two\
\ terminals counts twice. Since 1.2.0."
nullable: true
type: "integer"
leases:
description: "One viewer per live connection, with connectionId and since.\
\ On GET only with perConnection=true; always on presence.changed. Since\
\ 1.2.0."
items:
$ref: "#/components/schemas/CollaborationPresenceViewerV1"
nullable: true
type: "array"
pk:
description: "Record key; absent for table events."
nullable: true
@@ -780,26 +758,13 @@ components:
nullable: false
type: "string"
viewers:
description: "Live viewers; always present, empty once the last viewer leaves."
items:
$ref: "#/components/schemas/CollaborationPresenceViewerV1"
nullable: false
type: "array"
type: "object"
CollaborationPresenceViewerV1:
description: "One user merged across live tabs (EDITING takes precedence), or\
\ one live connection in leases."
description: "One user merged across live tabs; EDITING takes precedence."
properties:
connectionId:
description: "Opaque per-connection id, only in leases: c + the first 16\
\ hex digits of SHA-256(userId + newline + connectionId). Since 1.2.0."
nullable: true
type: "string"
connections:
description: "Live connections (tabs, terminals) merged into this viewer;\
\ 1 in leases. Since 1.2.0."
nullable: true
type: "integer"
displayName:
description: "Session display name."
nullable: false
@@ -815,18 +780,6 @@ components:
- "EDITING"
nullable: false
type: "string"
sessionId:
description: "Opaque id of the signed-in session behind this connection,\
\ only in leases (HMAC of the server session; never the session itself).\
\ Since 1.2.0."
nullable: true
type: "string"
since:
description: "When this connection's lease started (ISO-8601), only in leases.\
\ Since 1.2.0."
format: "date-time"
nullable: true
type: "string"
userId:
description: "Stable user identity."
nullable: false
@@ -1297,7 +1250,7 @@ info:
description: "Record collaboration: favorites, recents, attachments, tags, conversations,\
\ following, timeline, people and presence"
title: "QQQ Middleware API extension: collaboration"
version: "1.2.0"
version: "1.1.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/collaboration/presence:
@@ -1384,13 +1337,6 @@ paths:
required: false
schema:
type: "string"
- description: "Also return one viewer per live connection (lease) in leases;\
\ default false. Since collaboration 1.2.0."
in: "query"
name: "perConnection"
required: false
schema:
type: "boolean"
responses:
400:
content:
@@ -223,21 +223,6 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
assertThat(ok("POST", "/presence", body).get("viewers")).hasSize(1);
assertThat(ok("GET", "/presence?table=document&pk=1", null).at("/viewers/0/mode").asText()).isEqualTo("EDITING");
assertThat(ok("GET", "/presence?table=document", null).at("/viewers/0/fieldName").isNull()).isTrue();
ok("POST", "/presence", body.replace("tab-one", "tab-two").replace("EDITING", "VIEWING"));
JsonNode merged = ok("GET", "/presence?table=document&pk=1", null);
assertThat(merged.get("viewers")).hasSize(1);
assertThat(merged.at("/viewers/0/connections").asInt()).isEqualTo(2);
assertThat(merged.get("leaseCount").asInt()).isEqualTo(2);
assertThat(merged.has("leases")).isFalse();
JsonNode perConnection = ok("GET", "/presence?table=document&pk=1&perConnection=true", null);
assertThat(perConnection.get("viewers")).hasSize(1);
assertThat(perConnection.get("leases")).hasSize(2);
assertThat(perConnection.at("/leases/0/connectionId").asText()).startsWith("c").doesNotContain("tab-");
assertThat(perConnection.at("/leases/0/since").asText()).isNotBlank();
assertThat(perConnection.at("/leases/0/sessionId").asText()).startsWith("s");
assertThat(perConnection.toString()).doesNotContain("alice" + sessionSuffix).doesNotContain(sessionSuffix);
assertError(400, "GET", "/presence?table=document&perConnection=maybe", null, "alice");
ok("DELETE", "/presence", "{\"connectionId\":\"tab-two\",\"table\":\"document\",\"pk\":\"1\"}");
assertError(404, "GET", "/presence?table=document&pk=3", null, "alice");
assertError(404, "POST", "/presence", body, "outsider");
assertError(400, "POST", "/presence", "{\"connectionId\":\"x\",\"table\":\"document\",\"mode\":\"BAD\"}", "alice");
@@ -524,7 +509,7 @@ class CollaborationApiHttpTest extends PersonalServiceFixture
JsonNode version = json(client.send(HttpRequest.newBuilder(URI.create(serverUrl + "/qqq/v1/version")).GET().build(),
HttpResponse.BodyHandlers.ofString()), 200);
assertThat(version.at("/extensions/0/name").asText()).isEqualTo("collaboration");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.2.0");
assertThat(version.at("/extensions/0/version").asText()).isEqualTo("1.1.0");
}
/*******************************************************************************
@@ -65,8 +65,6 @@ class CollaborationTablesGuardrailTest extends PersonalServiceFixture
Map.entry("collaboration_favorite", "user content: a person stars something"),
Map.entry("collaboration_recent", "runtime: a person opens a record"),
Map.entry("collaboration_attachment", "user content: a person uploads a file"),
Map.entry("collaboration_attachment_cleanup", "runtime: authorized deletion queues durable storage cleanup"),
Map.entry("collaboration_upload_journal", "runtime: retained upload intent is committed before original storage and retained for recovery"),
Map.entry("collaboration_comment", "user content: a person comments"),
Map.entry("collaboration_comment_revision", "runtime: a comment is edited"),
Map.entry("collaboration_mention", "runtime: a comment mentions someone"),
@@ -185,7 +183,7 @@ class CollaborationTablesGuardrailTest extends PersonalServiceFixture
private List<String> collaborationTables()
{
List<String> tables = instance.getTables().values().stream().map(QTableMetaData::getName).filter(n -> n.startsWith("collaboration_")).sorted().toList();
assertThat(tables).hasSize(21);
assertThat(tables).hasSize(19);
return (tables);
}
@@ -114,7 +114,7 @@ class CollaborationTest
CollaborationTables.SCOPE_TYPE, CollaborationTables.FEATURE, CollaborationTables.PERMISSION_MODE);
assertThat(instance.getPossibleValueSource(CollaborationTables.FEATURE).getEnumValues()).hasSize(12);
assertThat(instance.getApp("collaboration").getChildren()).extracting(child -> child.getName())
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.ATTACHMENT_CLEANUP, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
.containsExactlyInAnyOrder(CollaborationTables.COMMENT, CollaborationTables.ATTACHMENT, CollaborationTables.TAG, CollaborationTables.FIELD_DEFINITION, CollaborationTables.TASK, CollaborationTables.SHARE, CollaborationTables.ACCESS_REQUEST, CollaborationTables.RECORD_ASSIGNMENT, CollaborationTables.ACTIVITY, CollaborationTables.FEATURE_SETTING, CollaborationTables.USER);
assertThat(instance.getApp("collaboration").getWidgets()).containsExactlyElementsOf(CollaborationHome.HOME_WIDGETS);
for(String widget : CollaborationHome.HOME_WIDGETS)
{
@@ -221,7 +221,7 @@ class CollaborationTest
QInstance headless = new QInstance();
headless.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
new CollaborationQBitProducer().withQBitConfig(config.withAdminApp(false)).produce(headless);
assertThat(headless.getTables()).hasSize(21);
assertThat(headless.getTables()).hasSize(19);
assertThat(headless.getApps()).isEmpty();
assertThat(headless.getWidgets()).hasSize(5);
}
@@ -24,10 +24,8 @@ package com.kingsrook.qbits.collaboration;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import com.kingsrook.qbits.collaboration.api.CollaborationPresenceSchemas;
import com.kingsrook.qbits.collaboration.presence.PresenceRegistry;
import com.kingsrook.qbits.collaboration.presence.PresenceService;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
@@ -38,7 +36,6 @@ import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import com.kingsrook.qqq.backend.core.realtime.InMemoryRealtimeBus;
import com.kingsrook.qqq.backend.core.realtime.RealtimeMessage;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -64,7 +61,7 @@ class PresenceTest extends PersonalServiceFixture
assertThat(second.snapshot("document", "1")).hasSize(1);
clock.advance(1000);
second.heartbeat("alice", "Alice", "two", "document", "1", "EDITING", "title");
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title", 2));
assertThat(first.snapshot("document", "1")).containsExactly(new PresenceRegistry.Viewer("alice", "Alice", "EDITING", "title"));
assertThat(first.snapshot("document", null).getFirst().fieldName()).isNull();
int changes = (int) events.stream().filter(event -> event.getEvent().equals("presence.changed")).count();
clock.advance(20000);
@@ -90,177 +87,6 @@ class PresenceTest extends PersonalServiceFixture
}
}
/*******************************************************************************
** The last leave still sends viewers: the SSE writer and the HTTP snapshot must
** both carry an empty array, never omit the field (#52).
*******************************************************************************/
@Test
void lastLeaveSendsEmptyViewers() throws Exception
{
var clock = new MutableClock();
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
bus.subscribe(events::add);
registry.heartbeat("bob", "Bob", "one", "person", null, "VIEWING", null);
registry.heartbeat("bob", "Bob", "one", "person", "7", "EDITING", "name");
registry.leave("bob", "one", "person", null);
registry.leave("bob", "one", "person", "7");
assertThat(registry.snapshot("person", null)).isEmpty();
for(String channel : List.of("table:person", "record:person:7"))
{
RealtimeMessage last = events.stream().filter(event -> event.getChannel().equals(channel) && event.getEvent().equals("presence.changed")).reduce((first, second) -> second).orElseThrow();
assertThat(last.getData()).containsKey("viewers");
assertThat((List<?>) last.getData().get("viewers")).isEmpty();
///////////////////////////////////////////////////////////////////////
// the realtime hub copies the data map and writes it with toJson, //
// whose NON_EMPTY inclusion used to drop the empty viewers list //
///////////////////////////////////////////////////////////////////////
String json = JsonUtils.toJson(new LinkedHashMap<>(last.getData()));
assertThat(JsonUtils.toJSONObject(json).getJSONArray("viewers").length()).isZero();
}
String occupied = JsonUtils.toJson(events.stream().filter(event -> event.getChannel().equals("record:person:7")).findFirst().orElseThrow().getData());
assertThat(JsonUtils.toJSONObject(occupied).getJSONArray("viewers").getJSONObject(0).getString("fieldName")).isEqualTo("name");
var snapshot = new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("person", null, registry.snapshot("person", null));
assertThat(JsonUtils.toJSONObject(JsonUtils.toJson(snapshot)).getJSONArray("viewers").length()).isZero();
}
}
/*******************************************************************************
** Two leases of one user merge into one viewer with connections = 2; per
** connection they are two viewers with opaque ids, modes, fields and starts.
** Counts keep counting users; connectionCounts counts leases (#54).
*******************************************************************************/
@Test
void perConnectionViewersAndCounts() throws Exception
{
var clock = new MutableClock();
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
first.heartbeat("admin", "Admin", "terminal-1", "session-a", "person", "7", "VIEWING", null);
clock.advance(1000);
second.heartbeat("admin", "Admin", "terminal-2", "session-b", "person", "7", "EDITING", "name");
first.heartbeat("bob", "Bob", "tab", "session-c", "person", "7", "VIEWING", null);
assertThat(first.snapshot("person", "7")).containsExactly(
new PresenceRegistry.Viewer("admin", "Admin", "EDITING", "name", 2),
new PresenceRegistry.Viewer("bob", "Bob", "VIEWING", null, 1));
List<PresenceRegistry.Viewer> connections = second.connections("person", "7");
assertThat(connections).hasSize(3);
assertThat(connections.subList(0, 2)).extracting(PresenceRegistry.Viewer::userId, PresenceRegistry.Viewer::mode, PresenceRegistry.Viewer::fieldName, PresenceRegistry.Viewer::connections)
.containsExactly(org.assertj.core.groups.Tuple.tuple("admin", "VIEWING", null, 1), org.assertj.core.groups.Tuple.tuple("admin", "EDITING", "name", 1));
assertThat(connections.getFirst().connectionId()).isEqualTo(PresenceRegistry.opaqueConnectionId("admin", "terminal-1")).startsWith("c").hasSize(17);
assertThat(connections.getFirst().since()).isEqualTo("2026-10-13T12:00:00Z");
assertThat(connections.get(1).since()).isEqualTo("2026-10-13T12:00:01Z");
////////////////////////////////////////////////////////////////////
// renewals keep the lease start; the session uuid replicates //
////////////////////////////////////////////////////////////////////
clock.advance(20000);
first.heartbeat("admin", "Admin", "terminal-1", "session-a", "person", "7", "VIEWING", null);
assertThat(second.connections("person", "7").getFirst().since()).isEqualTo("2026-10-13T12:00:00Z");
assertThat(second.connections("person", "7")).extracting(PresenceRegistry.Viewer::sessionId)
.containsExactly(PresenceRegistry.opaqueSessionId("session-a"), PresenceRegistry.opaqueSessionId("session-b"), PresenceRegistry.opaqueSessionId("session-c"));
assertThat(PresenceRegistry.opaqueSessionId("session-a")).startsWith("s").hasSize(17).isNotEqualTo(PresenceRegistry.opaqueSessionId("session-b"));
////////////////////////////////////////////////////////////////////
// table snapshots and counts: users vs leases //
////////////////////////////////////////////////////////////////////
assertThat(first.snapshot("person", null)).extracting(PresenceRegistry.Viewer::connections).containsExactly(2, 1);
assertThat(first.connections("person", null)).extracting(PresenceRegistry.Viewer::fieldName).containsOnlyNulls();
assertThat(first.counts()).containsExactly(java.util.Map.entry("person", 2));
assertThat(first.connectionCounts()).containsExactly(java.util.Map.entry("person", 3));
second.leave("admin", "terminal-2", "person", "7");
assertThat(first.snapshot("person", "7").getFirst()).isEqualTo(new PresenceRegistry.Viewer("admin", "Admin", "VIEWING", null, 1));
}
}
/*******************************************************************************
** Session uuids are bearer credentials: no bus message (internal replication
** or client event) and no snapshot carries one, only the opaque session id.
*******************************************************************************/
@Test
void rawSessionUuidIsNeverPublished() throws Exception
{
var clock = new MutableClock();
String sessionUuid = "7d1c2a9e-secret-session-uuid";
try(var bus = new InMemoryRealtimeBus(); var first = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES);
var second = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
bus.subscribe(events::add);
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", "7", "VIEWING", null);
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", "7", "EDITING", "name");
first.leave("admin", "terminal-1", "person", "7");
first.heartbeat("admin", "Admin", "terminal-1", sessionUuid, "person", null, "VIEWING", null);
assertThat(events).isNotEmpty().anySatisfy(event -> assertThat(event.getChannel()).isEqualTo(PresenceRegistry.INTERNAL_CHANNEL));
for(RealtimeMessage event : events)
{
assertThat(JsonUtils.toJson(new LinkedHashMap<>(event.getData()))).doesNotContain(sessionUuid);
}
assertThat(events).filteredOn(event -> event.getChannel().equals(PresenceRegistry.INTERNAL_CHANNEL) && !Boolean.TRUE.equals(event.getData().get("leave")))
.allSatisfy(event -> assertThat(event.getData().get("sessionId")).isEqualTo(PresenceRegistry.opaqueSessionId(sessionUuid)));
var snapshot = new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("person", null, second.snapshot("person", null), 1, second.connections("person", null));
String json = JsonUtils.toJson(snapshot);
assertThat(json).doesNotContain(sessionUuid).contains(PresenceRegistry.opaqueSessionId(sessionUuid));
}
}
/*******************************************************************************
** presence.changed carries connections per merged viewer, a lease count and
** one lease entry per connection; a second terminal of one user is a change.
*******************************************************************************/
@Test
void changedEventCarriesConnections() throws Exception
{
var clock = new MutableClock();
try(var bus = new InMemoryRealtimeBus(); var registry = new PresenceRegistry(bus, clock, PresenceRegistry.MAX_ENTRIES))
{
List<RealtimeMessage> events = new CopyOnWriteArrayList<>();
bus.subscribe(events::add);
registry.heartbeat("admin", "Admin", "terminal-1", "person", "7", "VIEWING", null);
int before = recordEvents(events).size();
registry.heartbeat("admin", "Admin", "terminal-2", "person", "7", "VIEWING", null);
List<RealtimeMessage> after = recordEvents(events);
assertThat(after).hasSize(before + 1);
var json = JsonUtils.toJSONObject(JsonUtils.toJson(new LinkedHashMap<>(after.getLast().getData())));
assertThat(json.getJSONArray("viewers").length()).isEqualTo(1);
assertThat(json.getJSONArray("viewers").getJSONObject(0).getInt("connections")).isEqualTo(2);
assertThat(json.getJSONArray("viewers").getJSONObject(0).has("connectionId")).isFalse();
assertThat(json.getInt("leaseCount")).isEqualTo(2);
assertThat(json.getJSONArray("leases").length()).isEqualTo(2);
var lease = json.getJSONArray("leases").getJSONObject(1);
assertThat(lease.getString("userId")).isEqualTo("admin");
assertThat(lease.getString("mode")).isEqualTo("VIEWING");
assertThat(lease.getInt("connections")).isEqualTo(1);
assertThat(lease.getString("connectionId")).startsWith("c");
assertThat(lease.getString("since")).isEqualTo("2026-10-13T12:00:00Z");
assertThat(json.toString()).doesNotContain("terminal-");
registry.leave("admin", "terminal-1", "person", "7");
registry.leave("admin", "terminal-2", "person", "7");
var last = JsonUtils.toJSONObject(JsonUtils.toJson(new LinkedHashMap<>(recordEvents(events).getLast().getData())));
assertThat(last.getInt("leaseCount")).isZero();
assertThat(last.getJSONArray("leases").length()).isZero();
}
}
/*******************************************************************************
** Record-channel presence.changed events so far.
*******************************************************************************/
private static List<RealtimeMessage> recordEvents(List<RealtimeMessage> events)
{
return (events.stream().filter(event -> event.getChannel().equals("record:person:7") && event.getEvent().equals("presence.changed")).toList());
}
/*******************************************************************************
** At capacity, existing leases renew while new targets are refused on both pods.
*******************************************************************************/
@@ -291,15 +117,7 @@ class PresenceTest extends PersonalServiceFixture
void accessFeatureAndSnapshot() throws Exception
{
var service = new PresenceService();
QContext.getQSession().setUuid("session-uuid-alice");
assertThat(service.heartbeat("one", "document", "01", "EDITING", "title")).hasSize(1);
assertThat(service.connections("document", "1")).singleElement().satisfies(viewer ->
{
assertThat(viewer.connectionId()).isEqualTo(PresenceRegistry.opaqueConnectionId("alice", "one"));
assertThat(viewer.sessionId()).isEqualTo(PresenceRegistry.opaqueSessionId("session-uuid-alice"));
assertThat(JsonUtils.toJson(new CollaborationPresenceSchemas.CollaborationPresenceSnapshotV1("document", "1", service.snapshot("document", "1"), 1, service.connections("document", "1"))))
.doesNotContain("session-uuid-alice");
});
assertThat(service.snapshot("document", "1")).extracting(PresenceRegistry.Viewer::userId).containsExactly("alice");
assertThat(service.snapshot("document", null)).hasSize(1);
assertThatThrownBy(() -> service.heartbeat("one", "document", "3", "VIEWING", null)).isInstanceOf(QNotFoundException.class);
@@ -319,7 +137,6 @@ class PresenceTest extends PersonalServiceFixture
admin();
assertThat(CollaborationHome.presence().getRows()).extracting(row -> row.get("table")).containsExactly("Documents");
assertThat(CollaborationHome.presence().getRows().getFirst().get("viewers")).isEqualTo(1);
assertThat(CollaborationHome.presence().getRows().getFirst().get("connections")).isEqualTo(1);
login("alice");
assertThat(CollaborationHome.presence().getRows()).isEmpty();
assertThat(CollaborationHome.presence().getNoRowsFoundHTML()).isEqualTo(CollaborationHome.PRESENCE_ADMIN_ONLY);
@@ -1,174 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.metadata.MetaDataAction;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryJoin;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Actual native Count permission enforcement and metadata, plus aggregate input
** guard semantics. Record Files separately tests archived native parent rows.
*******************************************************************************/
public class AttachmentAggregateGuardTest extends PersonalServiceFixture
{
/*******************************************************************************
** Ordinary Collaboration keeps its established count and metadata contract.
*******************************************************************************/
@Test
void defaultPolicyPreservesOrdinaryCount() throws Exception
{
login("alice", "collaboration.view");
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT)).getCount()).isZero();
assertThat(instance.getTable(CollaborationTables.ATTACHMENT).getDisabledCapabilities()).doesNotContain(Capability.TABLE_COUNT);
assertThat(new AttachmentLifecyclePolicy().requiresPinnedAggregateParent()).isFalse();
}
/*******************************************************************************
** A readable literal pin is accepted; broad or invisible parents are rejected.
*******************************************************************************/
@Test
void nativeCountRequiresCurrentlyVisiblePinnedParent() throws Exception
{
enable();
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT))).isInstanceOf(QPermissionDeniedException.class);
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1"))).getCount()).isZero();
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("3")))).isInstanceOf(QPermissionDeniedException.class);
var guard = new AttachmentAggregateGuard();
assertThatThrownBy(() -> guard.checkPermissionsThrowing(new AggregateInput(CollaborationTables.ATTACHMENT), instance.getTable(CollaborationTables.ATTACHMENT)))
.isInstanceOf(QPermissionDeniedException.class);
guard.checkPermissionsThrowing(new AggregateInput(CollaborationTables.ATTACHMENT).withFilter(pin("1")), instance.getTable(CollaborationTables.ATTACHMENT));
}
/*******************************************************************************
** OR, set branches, conflicting pins and joins cannot escape the exact parent.
*******************************************************************************/
@Test
void rejectsFilterShapesWhichDoNotGuaranteeOneParent() throws Exception
{
enable();
for(QQueryFilter filter : java.util.List.of(pin("1").withBooleanOperator(QQueryFilter.BooleanOperator.OR),
pin("1").withSubFilterSetOperator(QQueryFilter.SubFilterSetOperator.UNION),
pin("1").withSubFilter(new QQueryFilter().withSubFilterSetOperator(QQueryFilter.SubFilterSetOperator.UNION_ALL)),
pin("1").withCriteria("recordId", QCriteriaOperator.EQUALS, "2"),
new QQueryFilter().withSubFilter(pin("1")), new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, "document")))
{
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(filter))).isInstanceOf(QPermissionDeniedException.class);
}
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1")).withQueryJoin(new QueryJoin())))
.isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Generic list clients see no unscoped total capability in instance metadata.
*******************************************************************************/
@Test
void metadataHidesGenericTotalsWithoutDisablingPinnedNativeCount() throws Exception
{
enable();
var table = new MetaDataAction().execute(new MetaDataInput()).getTables().get(CollaborationTables.ATTACHMENT);
assertThat(table.getCapabilities()).doesNotContain("TABLE_COUNT", "QUERY_STATS").contains("TABLE_QUERY");
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1"))).getCount()).isZero();
}
/*******************************************************************************
** Fixture policy is installed before count actions and reflected in metadata.
*******************************************************************************/
private void enable()
{
login("alice", "collaboration.view");
config.withAttachmentLifecycle(new QCodeReference(PinnedPolicy.class));
AttachmentAggregateGuard.configure(instance.getTable(CollaborationTables.ATTACHMENT), config);
}
/*******************************************************************************
** Removing the runtime lifecycle cannot broaden a previously installed table.
*******************************************************************************/
@Test
void installedRestrictionSurvivesPolicyRemovalAndMetadataClone() throws Exception
{
enable();
var clone = instance.getTable(CollaborationTables.ATTACHMENT).clone();
config.withAttachmentLifecycle(new QCodeReference(AttachmentLifecyclePolicy.class));
instance.getTables().put(CollaborationTables.ATTACHMENT, AttachmentAggregateGuard.configure(clone, config));
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT))).isInstanceOf(QPermissionDeniedException.class);
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1"))).getCount()).isZero();
assertThat(instance.getTable(CollaborationTables.ATTACHMENT).getDisabledCapabilities()).contains(Capability.TABLE_COUNT, Capability.QUERY_STATS);
}
/*******************************************************************************
** Fresh default metadata still detects retained rows of invisible parents.
*******************************************************************************/
@Test
void persistedRetentionRequiresPinsWithoutAnInstalledPolicyMarker() throws Exception
{
login("alice", "collaboration.view");
var input = new com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput(CollaborationTables.ATTACHMENT)
.withRecords(java.util.List.of(new com.kingsrook.qqq.backend.core.model.data.QRecord().withValue("tableName", "document")
.withValue("recordId", "3").withValue("retained", true).withValue("fileName", "retained.txt")
.withValue("contentType", "text/plain").withValue("sizeBytes", 1L).withValue("sha256", "a".repeat(64))
.withValue("bucketName", "fixture").withValue("storageKey", "fixture-retained").withValue("createdByUserId", "alice")));
input.setTableMetaData(com.kingsrook.qbits.collaboration.CollaborationRecords.cleanupMetadata(CollaborationTables.ATTACHMENT));
input.withPermissionBypass("Test fixture seeds retained owner metadata without an installed lifecycle.");
assertThat(new com.kingsrook.qqq.backend.core.actions.tables.InsertAction().execute(input).getRecords()).allSatisfy(row -> assertThat(row.getErrors()).isNullOrEmpty());
assertThatThrownBy(() -> new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT))).isInstanceOf(QPermissionDeniedException.class);
assertThat(new CountAction().execute(new CountInput(CollaborationTables.ATTACHMENT).withFilter(pin("1"))).getCount()).isZero();
}
/*******************************************************************************
** Exact equality on both canonical reference fields is required.
*******************************************************************************/
private QQueryFilter pin(String id)
{
return (new QQueryFilter().withCriteria("tableName", QCriteriaOperator.EQUALS, "document").withCriteria("recordId", QCriteriaOperator.EQUALS, id));
}
/*******************************************************************************
** An ordinary generic owner can opt in without depending on Record Files.
*******************************************************************************/
public static class PinnedPolicy extends AttachmentLifecyclePolicy
{
/*******************************************************************************
** Constant metadata capability policy, no request context needed.
*******************************************************************************/
@Override
public boolean requiresPinnedAggregateParent()
{
return (true);
}
}
}
@@ -1,60 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/*******************************************************************************
** Declared media types are client-controlled; only passive formats may be
** served inline, everything else is an opaque attachment.
*******************************************************************************/
class AttachmentMediaPolicyTest
{
/***************************************************************************
** Active, malformed and missing declarations are opaque attachments.
***************************************************************************/
@Test
void activeMalformedAndMissingTypesAreOpaqueAttachments()
{
for(String declared : new String[] { "text/html", "TEXT/HTML; charset=utf-8", "image/svg+xml", "application/xhtml+xml", "text/xml", "application/xml",
"text/javascript", "application/javascript", "weird", "", " ", "a/b/c", "text/html\r\nX-Injected: 1", null })
{
assertThat(AttachmentMediaPolicy.storedType(declared)).as(String.valueOf(declared)).isEqualTo("application/octet-stream");
assertThat(AttachmentMediaPolicy.disposition(declared)).as(String.valueOf(declared)).isEqualTo("attachment");
}
}
/***************************************************************************
** Passive raster, PDF, text and media types keep their normalized type inline.
***************************************************************************/
@Test
void passiveTypesAreNormalizedAndInline()
{
assertThat(AttachmentMediaPolicy.storedType("IMAGE/PNG; q=1")).isEqualTo("image/png");
assertThat(AttachmentMediaPolicy.disposition("IMAGE/PNG; q=1")).isEqualTo("inline");
for(String declared : new String[] { "image/jpeg", "image/gif", "image/webp", "application/pdf", "text/plain", "audio/mpeg", "video/mp4" })
{
assertThat(AttachmentMediaPolicy.storedType(declared)).isEqualTo(declared);
assertThat(AttachmentMediaPolicy.disposition(declared)).isEqualTo("inline");
}
}
}
@@ -1,174 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.sql.DriverManager;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.MemoryUploadJournal;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.interfaces.QueryInterface;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.modules.backend.QBackendModuleDispatcher;
import com.kingsrook.qqq.backend.module.rdbms.RDBMSBackendModule;
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSQueryAction;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Real native H2 attachment queries; host permissions remain the fixture's
** actual memory-backed parent policy. No content is stored or signed here.
*******************************************************************************/
class AttachmentPaginationJdbcTest extends PersonalServiceFixture
{
private AttachmentsService service;
/*******************************************************************************
** Retain the complete owner table shape, including required fields. Observe
** the bound at the native backend and execute the real RDBMS query unchanged.
*******************************************************************************/
@BeforeEach
void database() throws Exception
{
ConnectionManager.closeConnectionProviders();
var backend = new RDBMSBackendMetaData().withName("page_database").withVendor("h2").withUsername("sa")
.withJdbcUrl("jdbc:h2:mem:attachment_pages_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE");
QBackendModuleDispatcher.registerBackendModule(new RecordingBackend());
backend.setBackendType(RecordingBackend.class);
instance.addBackend(backend);
var table = instance.getTable(CollaborationTables.ATTACHMENT);
table.setBackendName(backend.getName());
try(var connection = DriverManager.getConnection(backend.getJdbcUrl(), "sa", ""); var statement = connection.createStatement())
{
List<String> columns = new ArrayList<>();
for(var field : table.getFields().values())
{
String type = switch(field.getType())
{
case INTEGER -> "INTEGER";
case LONG -> "BIGINT";
case BOOLEAN -> "BOOLEAN";
case DATE_TIME -> "TIMESTAMP";
case STRING, TEXT -> "VARCHAR(4096)";
default -> throw new IllegalStateException("Unhandled fixture field " + field.getName());
};
columns.add(field.getBackendName() + " " + type + (field.getName().equals("id") ? " PRIMARY KEY" : "")
+ (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
}
statement.execute("CREATE TABLE collaboration_attachment (" + String.join(",", columns) + ")");
for(int id = 1; id <= 8; id++)
{
statement.execute("INSERT INTO collaboration_attachment (id,table_name,record_id,file_name,content_type,size_bytes,sha256,bucket_name,storage_key,created_by_user_id,parent_deleted,retained,create_date) VALUES ("
+ id + ",'document','" + (id == 8 ? "2" : "1") + "','file-" + id + "','text/plain',1,'" + "a".repeat(64)
+ "','test','key-" + id + "','alice'," + (id == 7) + ",TRUE,TIMESTAMP '2026-10-31 00:00:00')");
}
}
service = new AttachmentsService(config, new AttachmentStorage(), new AttachmentLifecyclePolicy(), new MemoryUploadJournal());
RecordingBackend.bounds.clear();
}
/*******************************************************************************
** Close only fixture native providers after the query assertions.
*******************************************************************************/
@AfterEach
void closeDatabase()
{
ConnectionManager.closeConnectionProviders();
}
/*******************************************************************************
** SQL paging preserves deterministic ties, exact source scope and retained
** metadata while returning only the requested page plus its lookahead.
*******************************************************************************/
@Test
void boundedSqlPagesKeepTiesSourceIdentityAndRetainedMetadata() throws Exception
{
var first = service.listPage("document", "01", 0, 2);
assertThat(first.items()).extracting(AttachmentEntry::id).containsExactly(6, 5);
assertThat(first.hasMore()).isTrue();
assertThat(first.items()).allSatisfy(item -> assertThat(item.canDelete()).isFalse());
var second = service.listPage("document", 1, 2, 2);
assertThat(second.items()).extracting(AttachmentEntry::id).containsExactly(4, 3);
var last = service.listPage("document", 1, 4, 2);
assertThat(last.items()).extracting(AttachmentEntry::id).containsExactly(2, 1);
assertThat(last.hasMore()).isFalse();
assertThat(service.listPage("document", 1, 6, 2).items()).isEmpty();
assertThat(RecordingBackend.bounds).containsExactly("0:3:3", "2:3:3", "4:3:2", "6:3:0");
assertThat(service.listPage("document", 2, 0, 100).items()).extracting(AttachmentEntry::id).containsExactly(8);
assertThat(RecordingBackend.bounds.getLast()).isEqualTo("0:101:1");
}
/*******************************************************************************
** Current parent revocation and invalid bounds fail before attachment SQL.
*******************************************************************************/
@Test
void revocationAndInvalidBoundsNeverReachAttachmentQuery() throws Exception
{
service.listPage("document", 1, 0, 1);
RecordingBackend.bounds.clear();
QContext.getQSession().getSecurityKeyValues().put("tenant", List.of("b"));
assertThatThrownBy(() -> service.listPage("document", 1, 1, 1)).hasMessage("Not found");
assertThatThrownBy(() -> service.listPage("document", 1, -1, 1)).hasMessageContaining("Offset");
assertThatThrownBy(() -> service.listPage("document", 1, 0, 101)).hasMessageContaining("limit");
assertThat(RecordingBackend.bounds).isEmpty();
}
/*******************************************************************************
** Instrument only attachment query inputs/results; delegate actual SQL to QQQ.
*******************************************************************************/
public static class RecordingBackend extends RDBMSBackendModule
{
static final List<String> bounds = new ArrayList<>();
/*******************************************************************************
** Isolated dispatcher name; never replace the production RDBMS module.
*******************************************************************************/
@Override
public String getBackendType()
{
return ("attachment_pagination_test");
}
/*******************************************************************************
** Real RDBMS execution must return no more than the requested lookahead.
*******************************************************************************/
@Override
public QueryInterface getQueryInterface()
{
return (input ->
{
var output = new RDBMSQueryAction().execute(input);
bounds.add(input.getFilter().getSkip() + ":" + input.getFilter().getLimit() + ":" + output.getRecords().size());
return (output);
});
}
}
}
@@ -32,23 +32,16 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.CollaborationSchema;
import com.kingsrook.qbits.collaboration.HomeWidgetData;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.app.CollaborationHome;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.MemoryUploadJournal;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.collaboration.permissions.CollaborationPermissions;
import com.kingsrook.qbits.collaboration.security.ParentRecordAccess;
import com.kingsrook.qbits.objectstorage.api.PresignGet;
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
import com.kingsrook.qbits.objectstorage.api.ObjectHeaders;
import com.kingsrook.qbits.objectstorage.api.StoredObject;
import com.kingsrook.qbits.objectstorage.api.StorageException;
import com.kingsrook.qbits.objectstorage.api.PutRequest;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
@@ -94,9 +87,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
{
private static AttachmentsService processService;
private FakeStorage storage;
private MemoryUploadJournal journal;
private AttachmentsService service;
private TestLifecycle lifecycle;
/*******************************************************************************
** No Docker, network, object-storage grants or collaboration.view needed.
@@ -104,566 +95,8 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
@BeforeEach
void attachments()
{
lifecycle = null;
storage = new FakeStorage();
journal = new MemoryUploadJournal();
service = memoryService();
}
/*******************************************************************************
** Standard downloads retain lifecycle checks and the authenticated descriptor.
*******************************************************************************/
@Test
void authenticatedDeliveryPreservesPolicyAndExactUrl() throws Exception
{
AttachmentEntry file = upload("download.txt", "original");
java.util.concurrent.atomic.AtomicBoolean deny = new java.util.concurrent.atomic.AtomicBoolean();
URL destination = URI.create("https://app.example/qqq/v1/qbit/files/exact").toURL();
AttachmentLifecyclePolicy policy = new AttachmentLifecyclePolicy()
{
/***********************************************************************
** Policy refusal must happen before a delivery is issued.
***********************************************************************/
@Override
public void beforeAccess(QRecord row, boolean inline) throws QException
{
if(deny.get())
{
throw new QException("Denied by lifecycle");
}
}
/***********************************************************************
** No storage signing or alternate byte owner is needed.
***********************************************************************/
@Override
public AttachmentDownload downloadDelivery(QRecord row)
{
assertThat(row.getValueInteger("id")).isEqualTo(file.id());
return (new AttachmentDownload(destination, true));
}
};
service = new AttachmentsService(config, storage, policy, journal);
assertThat(service.downloadDelivery(file.id())).isEqualTo(new AttachmentDownload(destination, true));
assertThat(service.downloadUrl(file.id(), false)).isEqualTo(destination);
assertThat(service.downloadDelivery(file.id(), true)).isEqualTo(new AttachmentDownload(destination, true));
assertThat(service.downloadUrl(file.id(), true)).isEqualTo(destination);
AttachmentDownload signed = new AttachmentDownload(URI.create("https://storage.example/file?signature=secret").toURL(), false);
assertThat(signed.toString()).doesNotContain("secret", "signature", "storage.example");
for(String invalid : java.util.List.of("https://user:password@storage.example/file", "https://storage.example/file#fragment", "http:/missing-host"))
{
assertThatThrownBy(() -> new AttachmentDownload(URI.create(invalid).toURL(), false)).isInstanceOf(IllegalArgumentException.class);
}
deny.set(true);
assertThatThrownBy(() -> service.downloadDelivery(file.id())).hasMessageContaining("Denied by lifecycle");
assertThatThrownBy(() -> new AttachmentDownload(URI.create("file:///tmp/bytes").toURL(), true)).isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
** Retention protects mixed bulk deletion without preventing metadata edits.
*******************************************************************************/
@Test
void retainedFilesAreNotDeletable() throws Exception
{
withLifecycle();
AttachmentEntry ordinary = upload("ordinary.txt", "first");
lifecycle.retained = true;
AttachmentEntry retained = upload("retained.txt", "second");
lifecycle.retainedId = retained.id();
assertThat(retained.canDelete()).isFalse();
assertThat(retained.canEdit()).isTrue();
assertThatThrownBy(() -> service.delete(List.of(ordinary.id(), retained.id()))).hasMessageContaining("retained");
assertThat(storage.objects).hasSize(2);
assertThatThrownBy(() -> service.requireDeletable(retained.id())).hasMessageContaining("retained");
assertThatThrownBy(() -> service.deleteStoredRow(raw(retained.id()))).hasMessageContaining("retained");
assertThat(service.rename(retained.id(), "renamed.txt", null).fileName()).isEqualTo("renamed.txt");
assertThat(lifecycle.metadataCalls).isEqualTo(1);
lifecycle.metadataFails = true;
assertThatThrownBy(() -> service.rename(retained.id(), "denied.txt", null)).hasMessageContaining("Metadata audit unavailable");
assertThat(raw(retained.id()).getValueString("fileName")).isEqualTo("renamed.txt");
lifecycle.retentionFails = true;
assertThatThrownBy(() -> service.delete(List.of(ordinary.id()))).hasMessageContaining("Retention unavailable");
assertThat(storage.objects).hasSize(2);
}
/*******************************************************************************
** Repeated cleanup advances past retained rows and never removes their bytes.
*******************************************************************************/
@Test
void retainedParentCleanupCrossesPageBoundary() throws Exception
{
withLifecycle();
lifecycle.retained = true;
for(int i = 0; i < 205; i++)
{
upload("photo-" + i + ".jpg", "original");
}
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
AttachmentCleanup cleanup = new AttachmentCleanup(service);
cleanup.onParentsDeleted("document", List.of(1));
cleanup.onParentsDeleted("document", List.of(1));
List<QRecord> rows = AttachmentRecords.query(new QQueryFilter());
assertThat(rows).hasSize(205).allSatisfy(row -> assertThat(row.getValueBoolean("parentDeleted")).isTrue());
assertThat(storage.objects).hasSize(205);
assertThatThrownBy(() -> service.downloadUrl(rows.getFirst().getValue("id"), false)).hasMessage("Not found");
assertThat(lifecycle.accesses).isEmpty();
}
/*******************************************************************************
** Both standard preview fields and explicit downloads pass the same policy.
*******************************************************************************/
@Test
void accessPolicyIsStrictAndRunsAfterAuthorization() throws Exception
{
withLifecycle();
AttachmentEntry uploaded = upload("audit.txt", "original");
assertThat(lifecycle.storedCalls).isEqualTo(1);
service.downloadUrl(uploaded.id(), false);
new AttachmentPreviewCustomizer().filter(List.of(raw(uploaded.id())), false, true, service);
assertThat(lifecycle.accesses).containsExactly(false, true, false);
assertThat(storage.signatures).hasSize(3);
lifecycle.accessFails = true;
assertThatThrownBy(() -> service.downloadUrl(uploaded.id(), false)).hasMessageContaining("Access audit unavailable");
assertThatThrownBy(() -> new AttachmentPreviewCustomizer().filter(List.of(raw(uploaded.id())), false, true, service))
.hasMessageContaining("Access audit unavailable");
assertThat(storage.signatures).hasSize(3);
lifecycle.accessRuntimeFails = true;
assertThatThrownBy(() -> new AttachmentPreviewCustomizer().filter(List.of(raw(uploaded.id())), false, true, service))
.hasMessageContaining("Attachment access policy failed");
assertThat(storage.signatures).hasSize(3);
lifecycle.accesses.clear();
QContext.getQSession().getSecurityKeyValues().remove("tenant");
assertThatThrownBy(() -> service.downloadUrl(uploaded.id(), false)).hasMessage("Not found");
assertThat(new AttachmentPreviewCustomizer().filter(List.of(raw(uploaded.id())), false, true, service)).isEmpty();
assertThat(lifecycle.accesses).isEmpty();
}
/*******************************************************************************
** A policy registration failure preserves the original for reconciliation.
*******************************************************************************/
@Test
void storedPolicyFailureDoesNotDeleteOriginal() throws Exception
{
withLifecycle();
lifecycle.storedFails = true;
assertThatThrownBy(() -> upload("preserved.txt", "original")).hasMessageContaining("Registration unavailable");
assertThat(storage.objects).hasSize(1);
assertThat(AttachmentRecords.query(new QQueryFilter())).hasSize(1);
}
/*******************************************************************************
** Configuration removal cannot turn a retained original into a deletable file.
*******************************************************************************/
@Test
void persistedRetentionSurvivesProviderRemoval() throws Exception
{
withLifecycle();
lifecycle.retained = true;
AttachmentEntry file = upload("retained.txt", "original");
assertThat(raw(file.id()).getValueBoolean("retained")).isTrue();
config.withAttachmentLifecycle(new QCodeReference(AttachmentLifecyclePolicy.class));
service = memoryService();
assertThat(service.list("document", 1).getFirst().canDelete()).isFalse();
assertThatThrownBy(() -> service.delete(List.of(file.id()))).hasMessageContaining("retained");
assertThatThrownBy(() -> service.downloadUrl(file.id(), false)).hasMessageContaining("requires its lifecycle policy");
assertThat(storage.signatures).isEmpty();
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
new AttachmentCleanup(service).onParentsDeleted("document", List.of(1));
assertThat(raw(file.id()).getValueBoolean("parentDeleted")).isTrue();
assertThat(storage.objects).hasSize(1);
}
/*******************************************************************************
** Failed intake classification never writes object bytes or attachment rows.
*******************************************************************************/
@Test
void unavailableRetentionPolicyDeniesIntake() throws Exception
{
withLifecycle();
lifecycle.retentionFails = true;
assertThatThrownBy(() -> upload("blocked.txt", "bytes")).hasMessageContaining("Retention unavailable");
assertThat(storage.objects).isEmpty();
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
}
/*******************************************************************************
** Core validation reads authorize rows without generating presentation URLs.
*******************************************************************************/
@Test
void internalReadsDoNotIssueUrlsButDisplayReadsDo() throws Exception
{
CountingLifecycle.accessCalls = 0;
PreviewStorage.signatureCalls = 0;
config.withAttachmentLifecycle(new QCodeReference(CountingLifecycle.class))
.withAttachmentStorage(new QCodeReference(PreviewStorage.class));
service = memoryService();
AttachmentEntry file = upload("presentation.txt", "original");
login("alice", CollaborationPermissions.VIEW);
QRecord internal = new GetAction().execute(new GetInput(CollaborationTables.ATTACHMENT).withPrimaryKey(file.id())).getRecord();
assertThat(internal.getValue("previewUrl")).isNull();
assertThat(internal.getValue("downloadUrl")).isNull();
assertThat(internal.getValue("previewDelivery")).isNull();
assertThat(internal.getValue("downloadDelivery")).isNull();
QRecord stale = new QRecord(internal).withValue("previewDelivery", "AUTHENTICATED_FETCH").withValue("downloadDelivery", "SIGNED_URL");
QRecord cleared = new AttachmentPreviewCustomizer().filter(List.of(stale), true, false, service).getFirst();
assertThat(cleared.getValue("previewDelivery")).isNull();
assertThat(cleared.getValue("downloadDelivery")).isNull();
service.rename(file.id(), "renamed.txt", null);
assertThat(CountingLifecycle.accessCalls).isZero();
assertThat(PreviewStorage.signatureCalls).isZero();
QRecord displayed = new GetAction().execute(new GetInput(CollaborationTables.ATTACHMENT).withPrimaryKey(file.id())
.withShouldGenerateDisplayValues(true)).getRecord();
assertThat(displayed.getValueString("previewUrl")).startsWith("https://signed.example/");
assertThat(displayed.getValueString("downloadUrl")).startsWith("https://signed.example/");
assertThat(displayed.getValueString("previewDelivery")).isEqualTo("SIGNED_URL");
assertThat(displayed.getValueString("downloadDelivery")).isEqualTo("SIGNED_URL");
new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT).withShouldGenerateDisplayValues(true));
assertThat(CountingLifecycle.accessCalls).isEqualTo(4);
assertThat(PreviewStorage.signatureCalls).isEqualTo(4);
QContext.getQSession().getSecurityKeyValues().remove("tenant");
assertThat(new GetAction().execute(new GetInput(CollaborationTables.ATTACHMENT).withPrimaryKey(file.id())).getRecord()).isNull();
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT)).getRecords()).isEmpty();
assertThat(new QueryAction().execute(new QueryInput(CollaborationTables.ATTACHMENT).withShouldGenerateDisplayValues(true)).getRecords()).isEmpty();
assertThat(CountingLifecycle.accessCalls).isEqualTo(4);
assertThat(PreviewStorage.signatureCalls).isEqualTo(4);
}
/*******************************************************************************
** Count policy calls across independently constructed request services.
*******************************************************************************/
public static class CountingLifecycle extends AttachmentLifecyclePolicy
{
private static int accessCalls;
/*******************************************************************************
** Observe URL intent without depending on a service instance lifetime.
*******************************************************************************/
@Override
public void beforeAccess(QRecord attachment, boolean inline)
{
accessCalls++;
}
}
/*******************************************************************************
** Configured preview-only storage adapter; no network or object-store access.
*******************************************************************************/
public static class PreviewStorage extends AttachmentStorage
{
private static int signatureCalls;
/*******************************************************************************
** Return a synthetic capability and count actual signing calls.
*******************************************************************************/
@Override
public URL presign(String name, PresignGet request)
{
signatureCalls++;
try
{
return (URI.create("https://signed.example/" + request.getKey()).toURL());
}
catch(Exception e)
{
throw (new IllegalStateException(e));
}
}
}
/*******************************************************************************
** Inject a per-test policy while configuring internal metadata reads too.
*******************************************************************************/
private AttachmentsService memoryService()
{
AttachmentLifecyclePolicy selected = com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader.getAdHoc(AttachmentLifecyclePolicy.class, config.getAttachmentLifecycle());
return (new AttachmentsService(config, storage, selected, journal));
}
/*******************************************************************************
** Inject a per-test policy while configuring internal metadata reads too.
*******************************************************************************/
private void withLifecycle()
{
config.withAttachmentLifecycle(new QCodeReference(TestLifecycle.class));
lifecycle = new TestLifecycle();
service = new AttachmentsService(config, storage, lifecycle, journal);
}
/*******************************************************************************
** Reference transfers need explicit retention authority and caller ownership.
*******************************************************************************/
@Test
void archiveTransferRequiresPolicyAndTransaction() throws Exception
{
withLifecycle();
lifecycle.retained = true;
AttachmentEntry file = upload("retained.txt", "original");
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, null))
.hasMessageContaining("explicit transaction");
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, new QBackendTransaction()))
.hasMessageContaining("archive policy");
QContext.setTransaction(new QBackendTransaction());
try
{
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, new QBackendTransaction()))
.hasMessageContaining("explicit transaction");
}
finally
{
QContext.clearTransaction();
}
assertThat(raw(file.id()).getValueString("recordId")).isEqualTo("1");
assertThat(storage.objects).hasSize(1);
}
/*******************************************************************************
** A trusted policy cannot replace native target visibility with an approval.
*******************************************************************************/
@Test
void archiveTransferChecksBothParentsBeforePolicy() throws Exception
{
withLifecycle();
lifecycle.retained = true;
AttachmentEntry file = upload("retained.txt", "original");
TransferPolicy policy = new TransferPolicy();
service = new AttachmentsService(config, storage, policy, journal);
QBackendTransaction transaction = new QBackendTransaction();
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 3, transaction)).hasMessage("Not found");
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 999, transaction)).hasMessage("Not found");
QContext.getQSession().getSecurityKeyValues().clear();
QContext.getQSession().withSecurityKeyValue("tenant", "b");
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 3, transaction)).hasMessage("Not found");
assertThat(policy.beforeCalls).isZero();
assertThat(policy.afterCalls).isZero();
assertThat(storage.objects).hasSize(1);
}
/*******************************************************************************
** The owner changes only the canonical reference, never the original content.
*******************************************************************************/
@Test
void archiveTransferPreservesOriginalAndPassesExactTransaction() throws Exception
{
withLifecycle();
lifecycle.retained = true;
AttachmentEntry file = upload("retained.txt", "original");
QRecord prior = raw(file.id());
Map<String, byte[]> originalObjects = new HashMap<>(storage.objects);
TransferPolicy policy = new TransferPolicy();
service = new AttachmentsService(config, storage, policy, journal);
QBackendTransaction transaction = new QBackendTransaction();
login("bob");
service.transferRetainedReference(file.id(), "document", 2, transaction);
QRecord updated = raw(file.id());
assertThat(updated.getValueString("recordId")).isEqualTo("2");
assertThat(updated.getValueString("tableName")).isEqualTo("document");
for(String field : prior.getValues().keySet())
{
if(!List.of("tableName", "recordId", "modifyDate").contains(field))
{
assertThat(updated.getValue(field)).as(field).isEqualTo(prior.getValue(field));
}
}
assertThat(storage.objects).containsExactlyInAnyOrderEntriesOf(originalObjects);
assertThat(storage.readCount).isZero();
assertThat(policy.beforeCalls).isEqualTo(1);
assertThat(policy.afterCalls).isEqualTo(1);
assertThat(policy.transaction).isSameAs(transaction);
assertThat(policy.targetId).isEqualTo("2");
assertThat(policy.prior.getValueString("recordId")).isEqualTo("1");
assertThat(service.list("document", 2)).hasSize(1);
assertThat(service.list("document", 1)).isEmpty();
}
/*******************************************************************************
** Outcome failure reaches the transaction owner; memory does not prove rollback.
*******************************************************************************/
@Test
void archiveTransferPropagatesOutcomeFailure() throws Exception
{
withLifecycle();
lifecycle.retained = true;
AttachmentEntry file = upload("retained.txt", "original");
TransferPolicy policy = new TransferPolicy();
policy.failAfter = true;
service = new AttachmentsService(config, storage, policy, journal);
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, new QBackendTransaction()))
.hasMessage("Archive audit unavailable");
assertThat(policy.beforeCalls).isEqualTo(1);
assertThat(policy.afterCalls).isEqualTo(1);
assertThat(storage.objects).hasSize(1);
}
/*******************************************************************************
** An ordinary attachment cannot acquire the archive-only transfer capability.
*******************************************************************************/
@Test
void archiveTransferRejectsOrdinaryAttachments() throws Exception
{
AttachmentEntry file = upload("ordinary.txt", "original");
TransferPolicy policy = new TransferPolicy();
service = new AttachmentsService(config, storage, policy, journal);
assertThatThrownBy(() -> service.transferRetainedReference(file.id(), "document", 2, new QBackendTransaction()))
.hasMessageContaining("Only a retained attachment");
assertThat(policy.beforeCalls).isZero();
assertThat(raw(file.id()).getValueString("recordId")).isEqualTo("1");
}
/*******************************************************************************
** Test-only authority observes calls; real JDBC/archive policy lives in its QBit.
*******************************************************************************/
private static class TransferPolicy extends AttachmentLifecyclePolicy
{
private int beforeCalls;
private int afterCalls;
private boolean failAfter;
private QBackendTransaction transaction;
private String targetId;
private QRecord prior;
/***************************************************************************
** Observe the canonical target and the caller's exact transaction.
***************************************************************************/
@Override
public void beforeReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction)
{
beforeCalls++;
this.transaction = transaction;
targetId = recordId;
prior = attachment;
}
/***************************************************************************
** A failed outcome must escape so the caller can roll back its transaction.
***************************************************************************/
@Override
public void afterReferenceTransfer(QRecord attachment, String tableName, String recordId, QBackendTransaction transaction) throws QException
{
afterCalls++;
if(failAfter)
{
throw (new QException("Archive audit unavailable"));
}
}
}
/*******************************************************************************
** Per-test policy instance, exposed only to the enclosing deterministic tests.
*******************************************************************************/
public static class TestLifecycle extends AttachmentLifecyclePolicy
{
private boolean retained;
private Integer retainedId;
private boolean retentionFails;
private boolean accessFails;
private boolean accessRuntimeFails;
private boolean storedFails;
private boolean beforeStoredFails;
private int beforeStoredCalls;
private boolean metadataFails;
private int storedCalls;
private int metadataCalls;
private final List<Boolean> accesses = new ArrayList<>();
/*******************************************************************************
** Public no-argument constructor for host-configured policy loading.
*******************************************************************************/
public TestLifecycle()
{
}
/*******************************************************************************
** Record successful registration callbacks or simulate a failed registry.
*******************************************************************************/
@Override
public void beforeStored(QRecord attachment) throws QException
{
beforeStoredCalls++;
if(beforeStoredFails)
{
throw (new QException("Storage intent unavailable"));
}
}
/*******************************************************************************
** Explicit fixture-only recovery policy; real durability is tested with JDBC.
*******************************************************************************/
@Override
public void beforeRecovery(QRecord candidate)
{
}
/*******************************************************************************
** Simulate registration after storage; this memory fixture is not durability proof.
*******************************************************************************/
@Override
public void afterStored(QRecord attachment) throws QException
{
storedCalls++;
if(storedFails)
{
throw (new QException("Registration unavailable"));
}
}
/*******************************************************************************
** Simulate a durable access event writer failure before signing.
*******************************************************************************/
@Override
public void beforeAccess(QRecord attachment, boolean inline) throws QException
{
accesses.add(inline);
if(accessRuntimeFails)
{
throw (new IllegalStateException("Unexpected policy failure"));
}
if(accessFails)
{
throw (new QException("Access audit unavailable"));
}
}
/*******************************************************************************
** Protect all rows or one specific retained row, independently of parent.
*******************************************************************************/
@Override
public boolean isRetained(QRecord attachment) throws QException
{
if(retentionFails)
{
throw (new QException("Retention unavailable"));
}
return (retained && (retainedId == null || retainedId.equals(attachment.getValueInteger("id"))));
}
/*******************************************************************************
** Track proposed edits separately from successful committed writes.
*******************************************************************************/
@Override
public void beforeMetadataChange(QRecord attachment, QRecord changes) throws QException
{
metadataCalls++;
if(metadataFails)
{
throw (new QException("Metadata audit unavailable"));
}
}
/*******************************************************************************
** This memory fixture tests callback ordering, not durable transactions.
*******************************************************************************/
@Override
public void validateMetadataTransaction(QRecord attachment, QBackendTransaction transaction)
{
}
/*******************************************************************************
** Production providers must append their event using the owner transaction.
*******************************************************************************/
@Override
public void afterMetadataChange(QRecord attachment, QRecord changes, QBackendTransaction transaction)
{
}
service = new AttachmentsService(config, storage);
}
/*******************************************************************************
@@ -709,184 +142,6 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
assertThat(service.list("document", 1)).extracting(AttachmentEntry::id).containsExactly(second.id());
}
/*******************************************************************************
** Content is authorized and verified before release, and buffers are immutable.
*******************************************************************************/
@Test
void verifiedContentRead() throws Exception
{
AttachmentEntry file = upload("original.txt", "hello");
AttachmentContent content = service.readContent(file.id(), 5);
assertThat(content.attachmentId()).isEqualTo(file.id());
assertThat(content.sha256()).isEqualTo(file.sha256());
assertThat(content.contentType()).isEqualTo("text/plain");
assertThat(content.bytes()).isEqualTo("hello".getBytes(StandardCharsets.UTF_8));
byte[] copy = content.bytes();
copy[0] = 0;
assertThat(content.bytes()[0]).isEqualTo((byte) 'h');
assertThat(storage.lastReadClosed).isTrue();
assertThat(storage.signatures).isEmpty();
assertThatThrownBy(() -> service.readContent(file.id(), 4)).hasMessageContaining("limit");
assertThatThrownBy(() -> service.readContent(file.id(), 21 * 1024 * 1024)).hasMessageContaining("20 MiB");
assertThat(storage.readCount).isEqualTo(1);
QContext.getQSession().getSecurityKeyValues().remove("tenant");
assertThatThrownBy(() -> service.readContent(file.id(), 5)).isInstanceOf(QNotFoundException.class);
assertThat(storage.readCount).isEqualTo(1);
}
/*******************************************************************************
** Changed, truncated and oversized originals never escape the integrity gate.
*******************************************************************************/
@Test
void corruptContentRead() throws Exception
{
AttachmentEntry file = upload("original.txt", "hello");
String key = storage.objects.keySet().iterator().next();
storage.objects.put(key, "wrong".getBytes(StandardCharsets.UTF_8));
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("integrity");
assertThat(storage.lastReadClosed).isTrue();
storage.objects.put(key, "four".getBytes(StandardCharsets.UTF_8));
assertThatThrownBy(() -> service.readContent(file.id(), 5)).isInstanceOf(AttachmentContentIntegrityException.class);
storage.reportLength = false;
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("integrity");
storage.objects.put(key, "toolong".getBytes(StandardCharsets.UTF_8));
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("integrity");
assertThat(storage.lastReadClosed).isTrue();
}
/*******************************************************************************
** Same intent reuses one original while conflicting bytes or metadata fail.
*******************************************************************************/
@Test
void retryableIntakePreservesOneOriginal() throws Exception
{
String request = "a".repeat(64);
String manifest = "b".repeat(64);
AttachmentEntry first = service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), "caption", request, manifest);
AttachmentEntry replay = service.upload("document", "01", "evidence.txt", "text/plain", stream("hello"), "caption", request, manifest);
assertThat(replay.id()).isEqualTo(first.id());
assertThat(storage.objects).hasSize(1);
assertThatThrownBy(() -> service.upload("document", 1, "evidence.txt", "text/plain", stream("other"), "caption", request, manifest))
.hasMessageContaining("conflicts");
assertThatThrownBy(() -> service.upload("document", 1, "renamed.txt", "text/plain", stream("hello"), "caption", request, manifest))
.hasMessageContaining("conflicts");
assertThat(storage.objects).hasSize(1);
service.upload("document", 2, "evidence.txt", "text/plain", stream("hello"), "caption", request, manifest);
assertThat(storage.objects).hasSize(2);
}
/*******************************************************************************
** Completed retained retries do not create another file-added timeline event.
*******************************************************************************/
@Test
void completedRetainedReplayDoesNotRepeatTimelineActivity() throws Exception
{
withLifecycle();
lifecycle.retained = true;
String request = "c".repeat(64);
String manifest = "d".repeat(64);
var first = service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), null, request, manifest);
var replay = service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), null, request, manifest);
assertThat(replay.id()).isEqualTo(first.id());
assertThat(com.kingsrook.qbits.collaboration.CollaborationRecords.query(CollaborationTables.ACTIVITY,
new QQueryFilter().withCriteria("type", QCriteriaOperator.EQUALS, "FILE_ADDED"))).hasSize(1);
assertThat(lifecycle.storedCalls).as("mandatory idempotent registration runs for each coordinator replay").isEqualTo(2);
assertThat(storage.objects).hasSize(1);
}
/*******************************************************************************
** A metadata insert failure leaves a deterministic original recoverable.
*******************************************************************************/
@Test
void retryableIntakeRecoversStoredObject() throws Exception
{
TestLifecycle lifecycle = new TestLifecycle();
service = new AttachmentsService(config, storage, lifecycle, journal);
QTableMetaData table = instance.getTable(CollaborationTables.ATTACHMENT);
Map<String, QCodeReference> original = new HashMap<>(table.getCustomizers());
table.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(RejectInsert.class));
String request = "c".repeat(64);
String manifest = "d".repeat(64);
assertThatThrownBy(() -> service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), null, request, manifest))
.hasMessageContaining("save attachment");
assertThat(storage.objects).hasSize(1);
table.setCustomizers(original);
AttachmentEntry recovered = service.upload("document", 1, "evidence.txt", "text/plain", stream("hello"), null, request, manifest);
assertThat(recovered.sha256()).hasSize(64);
assertThat(storage.objects).hasSize(1);
assertThat(service.list("document", 1)).hasSize(1);
}
/*******************************************************************************
** Both audited content-read phases must succeed before releasing any bytes.
*******************************************************************************/
@Test
void retainedContentReadRequiresExplicitAuditPolicy() throws Exception
{
AttachmentLifecyclePolicy policy = new AttachmentLifecyclePolicy()
{
/***********************************************************************
** Mark the fixture retained while retaining the default read denial.
***********************************************************************/
@Override
public boolean isRetained(QRecord attachment)
{
return (true);
}
/***********************************************************************
** This fixture exercises read denial after explicitly allowed storage.
***********************************************************************/
@Override
public void beforeStored(QRecord attachment)
{
}
};
service = new AttachmentsService(config, storage, policy, journal);
AttachmentEntry file = upload("retained.txt", "hello");
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("content-read policy");
assertThat(storage.readCount).isZero();
AttachmentLifecyclePolicy partialPolicy = new AttachmentLifecyclePolicy()
{
/***********************************************************************
** A before hook alone must not bypass mandatory read-result recording.
***********************************************************************/
@Override
public void beforeContentRead(QRecord attachment)
{
}
};
service = new AttachmentsService(config, storage, partialPolicy, journal);
assertThatThrownBy(() -> service.readContent(file.id(), 5)).hasMessageContaining("read-result policy");
assertThat(storage.readCount).isEqualTo(1);
assertThat(storage.lastReadClosed).isTrue();
}
/*******************************************************************************
** An older retention provider cannot silently omit atomic metadata auditing.
*******************************************************************************/
@Test
void retainedMetadataRequiresTransactionPolicyBeforeMutation() throws Exception
{
withLifecycle();
lifecycle.retained = true;
AttachmentEntry file = upload("original.txt", "hello");
AttachmentLifecyclePolicy legacyProvider = new AttachmentLifecyclePolicy()
{
/***********************************************************************
** An older provider handles intent but has no atomic result contract.
***********************************************************************/
@Override
public void beforeMetadataChange(QRecord attachment, QRecord changes)
{
}
};
service = new AttachmentsService(config, storage, legacyProvider, journal);
assertThatThrownBy(() -> service.rename(file.id(), "changed.txt", null))
.hasMessageContaining("atomic audit transaction policy");
assertThat(raw(file.id()).getValueString("fileName")).isEqualTo("original.txt");
}
/*******************************************************************************
** Both collaboration and bucket size limits are enforced while streaming.
*******************************************************************************/
@@ -1020,7 +275,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
QRecord visible = raw(row.id());
QRecord hidden = new QRecord(visible).withValue("recordId", "3");
AttachmentPreviewCustomizer customizer = new AttachmentPreviewCustomizer();
List<QRecord> results = customizer.filter(List.of(visible, hidden), false, true, service);
List<QRecord> results = customizer.filter(List.of(visible, hidden), false, service);
assertThat(results).hasSize(1);
assertThat(storage.signatures).hasSize(2);
assertThat(results.getFirst().getValueString("previewUrl")).contains("signed.example");
@@ -1029,7 +284,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
assertThat(results.getFirst().getDisplayValue("downloadUrl:downloadUrlDynamic")).isEqualTo(results.getFirst().getValueString("downloadUrl"));
assertThat(visible.getValue("previewUrl")).isNull();
assertThat(raw(row.id()).getValue("previewUrl")).isNull();
assertThatThrownBy(() -> customizer.filter(List.of(hidden), true, true, service)).hasMessage("Not found");
assertThatThrownBy(() -> customizer.filter(List.of(hidden), true, service)).hasMessage("Not found");
}
/*******************************************************************************
@@ -1128,7 +383,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
}
/*******************************************************************************
** Storage failures leave durable cleanup; trusted cleanup works after parent deletion.
** Storage failures leave rows for retries; trusted cleanup works after parent deletion.
*******************************************************************************/
@Test
void failuresAndCleanup() throws Exception
@@ -1136,13 +391,8 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
AttachmentEntry row = upload("file.txt", "x");
storage.failDelete = true;
assertThatThrownBy(() -> service.delete(List.of(row.id()))).hasMessage("Storage unavailable");
assertThat(service.list("document", 1)).isEmpty();
assertThat(storage.objects).hasSize(1);
assertThat(service.list("document", 1)).hasSize(1);
storage.failDelete = false;
admin();
assertThat(new AttachmentCleanupQueue(storage).retryPending()).isEqualTo(1);
login("alice");
upload("parent-cleanup.txt", "parent bytes");
new DeleteAction().execute(new DeleteInput("document").withPrimaryKeys(List.of(1)));
assertThatThrownBy(() -> service.list("document", 1)).hasMessage("Not found");
QContext.getQSession().getPermissions().clear();
@@ -1150,56 +400,10 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
cleanup.onParentsDeleted("document", List.of());
cleanup.onParentsDeleted("document", List.of(1));
cleanup.onParentsDeleted("document", List.of(1));
admin();
new AttachmentCleanupQueue(storage).retryPending();
assertThat(storage.objects).isEmpty();
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
}
/*******************************************************************************
** A required intent failure prevents even the first object write.
*******************************************************************************/
@Test
void retainedStorageRequiresIntentBeforePut() throws Exception
{
withLifecycle();
lifecycle.retained = true;
lifecycle.beforeStoredFails = true;
assertThatThrownBy(() -> upload("evidence.txt", "original")).hasMessageContaining("Storage intent unavailable");
assertThat(storage.objects).isEmpty();
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
assertThat(lifecycle.storedCalls).isZero();
assertThatThrownBy(() -> new AttachmentLifecyclePolicy().beforeStored(new QRecord().withValue("retained", true)))
.hasMessageContaining("durable write-intent");
lifecycle.beforeStoredFails = false;
QContext.setTransaction(new QBackendTransaction());
try
{
assertThatThrownBy(() -> upload("evidence.txt", "original")).isInstanceOf(QException.class).hasMessageContaining("own committed transactions");
}
finally
{
QContext.clearTransaction();
}
assertThat(storage.objects).isEmpty();
}
/*******************************************************************************
** A stored retained original survives a metadata insertion failure.
*******************************************************************************/
@Test
void retainedFailedInsertNeverDeletesOriginal() throws Exception
{
withLifecycle();
lifecycle.retained = true;
instance.getTable(CollaborationTables.ATTACHMENT).withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(RejectInsert.class));
assertThatThrownBy(() -> upload("evidence.txt", "original")).hasMessageContaining("Could not save attachment");
assertThat(storage.objects).hasSize(1);
assertThat(AttachmentRecords.query(new QQueryFilter())).isEmpty();
assertThat(lifecycle.beforeStoredCalls).isEqualTo(1);
assertThat(lifecycle.storedCalls).isZero();
}
/*******************************************************************************
** A rejected row compensates the put; a broken upload never creates a row.
*******************************************************************************/
@@ -1254,8 +458,7 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
assertThat(CollaborationHome.humanSize(3L * 1024 * 1024 * 1024)).isEqualTo("3.0 GB");
String ddl = CollaborationSchema.postgresScript(config);
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS collaboration_attachment", "size_bytes BIGINT", "parent_deleted BOOLEAN DEFAULT FALSE NOT NULL",
"ON collaboration_attachment (table_name, record_id)", "retained BOOLEAN DEFAULT FALSE NOT NULL",
"ALTER TABLE collaboration_attachment ADD COLUMN IF NOT EXISTS retained BOOLEAN NOT NULL DEFAULT FALSE").doesNotContain("preview_url", "download_url");
"ON collaboration_attachment (table_name, record_id)").doesNotContain("preview_url", "download_url");
QTableMetaData table = instance.getTable(CollaborationTables.ATTACHMENT);
assertThat(table.getField("sizeBytes").getType()).isEqualTo(QFieldType.LONG);
assertThat(table.getVirtualField("previewUrl")).isNotNull();
@@ -1295,50 +498,11 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
private static class FakeStorage extends AttachmentStorage
{
private final Map<String, byte[]> objects = new HashMap<>();
private final Map<String, Map<String, String>> objectMetadata = new HashMap<>();
private final List<PresignGet> signatures = new ArrayList<>();
private long limit = Long.MAX_VALUE;
private PutRequest lastPut;
private PresignGet lastGet;
private boolean failDelete;
private Integer readCount = 0;
private Boolean lastReadClosed = false;
private Boolean reportLength = true;
/***************************************************************************
** Simulate a closeable object response with independently reported length.
***************************************************************************/
@Override
public ObjectStream open(String name, String key)
{
readCount++;
lastReadClosed = false;
byte[] bytes = objects.get(name + "/" + key);
ByteArrayInputStream input = new ByteArrayInputStream(bytes)
{
/*******************************************************************
** Observe cleanup on success and on every integrity failure path.
*******************************************************************/
@Override
public void close() throws IOException
{
lastReadClosed = true;
super.close();
}
};
return (new ObjectStream(input, reportLength ? (long) bytes.length : null, "text/plain", null, Instant.now(),
new ObjectHeaders().withUserMetadata(objectMetadata.get(name + "/" + key))));
}
/***************************************************************************
** Report presence for retry reconciliation without exposing content.
***************************************************************************/
@Override
public Optional<StoredObject> head(String name, String key)
{
byte[] bytes = objects.get(name + "/" + key);
return (bytes == null ? Optional.empty() : Optional.of(new StoredObject().withSize((long) bytes.length)));
}
@Override
public long maxBytes(String name)
@@ -1350,15 +514,9 @@ public class AttachmentsServiceTest extends PersonalServiceFixture
public void put(String name, PutRequest request)
{
lastPut = request;
String key = name + "/" + request.getKey();
if(Boolean.TRUE.equals(request.getIfNoneMatch()) && objects.containsKey(key))
{
throw (new StorageException(StorageException.Reason.PRECONDITION_FAILED, "Object already exists"));
}
try
{
objects.put(key, request.getBytes() == null ? request.getStream().readAllBytes() : request.getBytes().clone());
objectMetadata.put(key, request.getMetadata());
objects.put(name + "/" + request.getKey(), request.getStream().readAllBytes());
}
catch(IOException e)
{
@@ -1,248 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.io.ByteArrayInputStream;
import java.sql.DriverManager;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qbits.objectstorage.api.ObjectStream;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Owner transaction/control tests with real H2 and fixed existing bytes. The
** injected lifecycle is a test policy, not a durability qualification claim.
*******************************************************************************/
class ExistingAttachmentAdoptionTest extends PersonalServiceFixture
{
private static final byte[] BYTES = new byte[] {0, 1, 2, 3};
private RDBMSBackendMetaData database;
private Policy policy;
private AttachmentsService service;
private int reads;
/***************************************************************************
** Native metadata controls physical field mappings; no validation bypass.
***************************************************************************/
@BeforeEach
void database() throws Exception
{
ConnectionManager.closeConnectionProviders();
database = new RDBMSBackendMetaData().withName("adoption_database").withVendor("h2").withUsername("sa")
.withJdbcUrl("jdbc:h2:mem:adoption_" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=FALSE");
instance.addBackend(database);
var table = instance.getTable(CollaborationTables.ATTACHMENT);
table.setBackendName(database.getName());
try(var connection = DriverManager.getConnection(database.getJdbcUrl(), "sa", ""); var statement = connection.createStatement())
{
List<String> columns = new ArrayList<>();
for(var field : table.getFields().values())
{
String type = switch(field.getType())
{
case INTEGER -> "INTEGER";
case LONG -> "BIGINT";
case BOOLEAN -> "BOOLEAN";
case DATE_TIME -> "TIMESTAMP";
case STRING, TEXT -> "VARCHAR(4096)";
default -> throw new IllegalStateException("Unexpected fixture field");
};
columns.add(field.getBackendName() + " " + type + (field.getName().equals("id") ? " PRIMARY KEY" : "")
+ (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : ""));
}
statement.execute("CREATE TABLE collaboration_attachment (" + String.join(",", columns) + ")");
String hash = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(BYTES));
statement.execute("INSERT INTO collaboration_attachment (id,table_name,record_id,file_name,content_type,size_bytes,sha256,bucket_name,storage_key,created_by_user_id,parent_deleted,retained,create_date) VALUES ("
+ "9,'document','1','original.bin','application/octet-stream',4,'" + hash + "','test','original-key','alice',FALSE,FALSE,TIMESTAMP '2026-10-31 00:00:00')");
}
policy = new Policy();
service = new AttachmentsService(config, new AttachmentStorage()
{
/*******************************************************************
** Fixed existing storage: any put/delete falls through to refusal.
*******************************************************************/
@Override
public ObjectStream open(String bucket, String key)
{
assertThat(bucket).isEqualTo("test");
assertThat(key).isEqualTo("original-key");
reads++;
return (new ObjectStream(new ByteArrayInputStream(BYTES), 4L, "application/octet-stream", null, java.time.Instant.now(), null));
}
}, policy);
}
/***************************************************************************
** Replay keeps the same row and does not repeat the retention transition.
***************************************************************************/
@Test
void sameIdentityAndBytesAreAdoptedWithoutUpload() throws Exception
{
assertThat(service.adoptRetainedAttachment(9).id()).isEqualTo(9);
assertThat(service.adoptRetainedAttachment(9).id()).isEqualTo(9);
assertThat(reads).isEqualTo(2);
assertThat(policy.transitions).isEqualTo(1);
assertThat(policy.registrations).isEqualTo(2);
assertThat(retained()).isTrue();
}
/***************************************************************************
** Failure before read cannot transition retention or register anything.
***************************************************************************/
@Test
void refusedIntentDoesNotReadOrChangeTheOwner() throws Exception
{
policy.failIntent = true;
assertThatThrownBy(() -> service.adoptRetainedAttachment(9)).hasMessage("intent refused");
assertThat(reads).isZero();
assertThat(retained()).isFalse();
assertThat(policy.registrations).isZero();
}
/***************************************************************************
** The retention result callback is part of the owner's actual transaction.
***************************************************************************/
@Test
void outcomeFailureRollsBackRetention() throws Exception
{
policy.failOutcome = true;
assertThatThrownBy(() -> service.adoptRetainedAttachment(9)).hasMessage("outcome refused");
assertThat(reads).isEqualTo(1);
assertThat(retained()).isFalse();
assertThat(policy.registrations).isZero();
}
/***************************************************************************
** Later registration failure cannot pretend the earlier retention rolled back.
***************************************************************************/
@Test
void registrationFailureRemainsRetainedAndRetriesSameRow() throws Exception
{
policy.failRegistration = true;
assertThatThrownBy(() -> service.adoptRetainedAttachment(9)).hasMessage("registration refused");
assertThat(retained()).isTrue();
policy.failRegistration = false;
assertThat(service.adoptRetainedAttachment(9).id()).isEqualTo(9);
assertThat(policy.transitions).isEqualTo(1);
}
/***************************************************************************
** Ordinary/default policy does not silently become an adoption authority.
***************************************************************************/
@Test
void absentPolicyAndDifferentAuthorRefuse() throws Exception
{
assertThatThrownBy(() -> new AttachmentsService(config, new AttachmentStorage(), new AttachmentLifecyclePolicy())
.adoptRetainedAttachment(9)).hasMessageContaining("not configured");
login("bob");
assertThatThrownBy(() -> service.adoptRetainedAttachment(9)).hasMessageContaining("author");
assertThat(reads).isZero();
}
/***************************************************************************
** Independent connection proves committed state rather than in-memory values.
***************************************************************************/
private boolean retained() throws Exception
{
try(var connection = DriverManager.getConnection(database.getJdbcUrl(), "sa", "");
var statement = connection.createStatement(); var result = statement.executeQuery("SELECT retained FROM collaboration_attachment WHERE id=9"))
{
assertThat(result.next()).isTrue();
return (result.getBoolean(1));
}
}
/***************************************************************************
** Explicit fixture hooks isolate owner transaction behavior from RF auditing.
***************************************************************************/
private static final class Policy extends AttachmentLifecyclePolicy
{
private boolean failIntent;
private boolean failOutcome;
private boolean failRegistration;
private int transitions;
private int registrations;
/***********************************************************************
** Fixture current-authority grant only.
***********************************************************************/
@Override
public void authorizeAdoption(QRecord attachment) {}
/***********************************************************************
** Fault before the owner opens existing bytes.
***********************************************************************/
@Override
public void beforeAdoption(QRecord attachment) throws QException
{
if(failIntent)
{
throw (new QException("intent refused"));
}
}
/***********************************************************************
** The fixture admits its real H2 transaction without claiming RF durability.
***********************************************************************/
@Override
public void validateMetadataTransaction(QRecord attachment, QBackendTransaction transaction) {}
/***********************************************************************
** Failure must roll back the actual retained column.
***********************************************************************/
@Override
public void afterAdoption(QRecord attachment, QBackendTransaction transaction) throws QException
{
if(failOutcome)
{
throw (new QException("outcome refused"));
}
transitions++;
}
/***********************************************************************
** Failure occurs after the owner's retention commit.
***********************************************************************/
@Override
public void afterStored(QRecord attachment) throws QException
{
if(failRegistration)
{
throw (new QException("registration refused"));
}
registrations++;
}
}
}
@@ -1,140 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.collaboration.CollaborationRecords;
import com.kingsrook.qbits.collaboration.PersonalServiceFixture;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalTable;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadRecoveryNavigation;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.expressions.CurrentUser;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeature;
import com.kingsrook.qqq.backend.core.model.metadata.collaboration.RecordFeaturesMetaData;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Native visibility and predicate tests. Memory is only the ordinary query
** backend here; no memory durability or retained upload claim is made.
*******************************************************************************/
public class UploadJournalVisibilityTest extends PersonalServiceFixture
{
@Test
void journalRequiresOriginalActorAndCurrentParentVisibility() throws Exception
{
login("alice", "collaboration.view");
QRecord row = seed();
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME)).getRecords()).hasSize(1);
assertThat(new GetAction().execute(new GetInput(UploadJournalTable.NAME).withPrimaryKey(row.getValue("id"))).getRecord()).isNotNull();
login("bob", "collaboration.view");
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME)).getRecords()).isEmpty();
login("alice", "collaboration.view");
com.kingsrook.qqq.backend.core.context.QContext.getQSession().getSecurityKeyValues().remove("tenant");
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME)).getRecords()).isEmpty();
}
@Test
void privatePhysicalColumnsCannotBeProbedOrProjected() throws Exception
{
login("alice", "collaboration.view");
seed();
for(String field : List.of("storageKey", "storage_key", "bucket_name", "manifest_hash", "lease_token"))
{
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(
new QQueryFilter().withCriteria(field, QCriteriaOperator.EQUALS, "probe")))).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(
new QQueryFilter().withOrderBy(new QFilterOrderBy(field, true))))).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> new GetAction().execute(new GetInput(UploadJournalTable.NAME).withUniqueKey(Map.of(field, "probe"))))
.isInstanceOf(QPermissionDeniedException.class);
}
assertThatThrownBy(() -> new CountAction().execute(new CountInput(UploadJournalTable.NAME))).isInstanceOf(QPermissionDeniedException.class);
}
@Test
void recoveryProcessRequiresOneVisibleSelectionAndExplicitConfirmation() throws Exception
{
login("alice", "collaboration.view");
var metadata = RecoverRetainedUploadProcess.metadata();
var prepare = new RecoverRetainedUploadProcess.Prepare();
var input = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput().withProcessName(metadata.getName());
var output = new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput();
assertThatThrownBy(() -> prepare.run(input, output)).hasMessageContaining("exactly one");
input.setRecords(List.of(seed()));
prepare.run(input, output);
assertThat(output.getValue("journalId")).isNotNull();
assertThat(output.getValue("confirmRecovery")).isEqualTo(false);
assertThatThrownBy(() -> new RecoverRetainedUploadProcess.Recover().run(input, new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput()))
.hasMessageContaining("Confirm");
}
@Test
void recoveryNavigationUsesCurrentActorAndCanonicalSourcePins() throws Exception
{
login("alice", "collaboration.view");
seed();
var source = com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable("document");
UploadRecoveryNavigation.configure(source);
Object descriptor = RecordFeaturesMetaData.of(source).getSettings().get(RecordFeature.ATTACHMENTS).get("recoveryView");
assertThat(descriptor).isInstanceOf(Map.class);
Map<?, ?> view = (Map<?, ?>) descriptor;
assertThat(view.get("tableName")).isEqualTo(UploadJournalTable.NAME);
assertThat(view.get("label")).isEqualTo("Review interrupted uploads");
QQueryFilter filter = (QQueryFilter) view.get("filter");
filter.withCriteria("tableName", QCriteriaOperator.EQUALS, "document").withCriteria("recordId", QCriteriaOperator.EQUALS, "1");
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(filter)).getRecords()).hasSize(1);
login("bob", "collaboration.view");
assertThat(new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(filter)).getRecords()).isEmpty();
login("alice", "collaboration.view");
for(String field : List.of("recordId", "state"))
{
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(
new QQueryFilter().withCriteria(field, QCriteriaOperator.EQUALS, new CurrentUser())))).isInstanceOf(QPermissionDeniedException.class);
}
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(UploadJournalTable.NAME).withFilter(
new QQueryFilter().withCriteria("createdByUserId", QCriteriaOperator.IN, new CurrentUser())))).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Public manifest projection only; physical storage secrets are not fields.
*******************************************************************************/
private QRecord seed() throws Exception
{
return (CollaborationRecords.insert(UploadJournalTable.NAME, new QRecord().withValue("tableName", "document").withValue("recordId", "1")
.withValue("createdByUserId", "alice").withValue("fileName", "pending.txt").withValue("state", "PREPARED")
.withValue("storageState", "UNKNOWN").withValue("sha256", "a".repeat(64)).withValue("sizeBytes", 5L).withValue("locatorHash", "b".repeat(64))));
}
}
@@ -1,263 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Explicit test-only journal for existing memory-backend service behavior tests.
** It provides no durability or rollback evidence. Production constructors use
** JdbcUploadJournal; actual transaction/restart tests exercise that implementation.
*******************************************************************************/
public final class MemoryUploadJournal implements UploadJournal
{
private final Map<Integer, UploadEntry> rows = new LinkedHashMap<>();
private final Map<Integer, String> tokens = new LinkedHashMap<>();
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized UploadEntry prepare(UploadCandidate candidate) throws QException
{
for(UploadEntry entry : rows.values())
{
if(entry.candidate().locatorHash().equals(candidate.locatorHash())
|| (candidate.intakeKey() != null && candidate.intakeKey().equals(entry.candidate().intakeKey())))
{
if(!entry.candidate().equals(candidate))
{
throw failure(UploadJournalException.Reason.IMMUTABLE_CONFLICT);
}
return (entry);
}
}
UploadEntry entry = new UploadEntry(rows.size() + 1, candidate, UploadJournalState.PREPARED, UploadStorageState.PENDING, null, 0L, null);
rows.put(entry.id(), entry);
return (entry);
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized UploadReservation reserve(Integer journalId, Duration lease) throws QException
{
duration(lease);
UploadEntry entry = require(journalId);
if(entry.isLeased(Instant.now()))
{
throw failure(UploadJournalException.Reason.LEASE_HELD);
}
entry = entry.leased(entry.fence() + 1, Instant.now().plus(lease));
rows.put(journalId, entry);
String token = UUID.randomUUID().toString();
tokens.put(journalId, token);
return (new UploadReservation(entry, token, entry.fence(), entry.leaseUntil()));
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized UploadReservation renew(UploadReservation reservation, Duration lease) throws QException
{
duration(lease);
UploadEntry entry = holder(reservation).leased(reservation.fence(), Instant.now().plus(lease));
rows.put(entry.id(), entry);
return (new UploadReservation(entry, reservation.leaseToken(), entry.fence(), entry.leaseUntil()));
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized UploadEntry recordStorage(UploadReservation reservation, UploadStorageState storageState) throws QException
{
UploadEntry entry = holder(reservation);
if(storageState == null || storageState.ordinal() < entry.storageState().ordinal())
{
throw failure(UploadJournalException.Reason.ILLEGAL_STATE);
}
return (replace(entry, entry.state(), storageState, entry.attachmentId(), entry.leaseUntil()));
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized UploadEntry register(UploadReservation reservation, AttachmentRegistrar registrar) throws QException
{
UploadEntry entry = holder(reservation);
if(entry.attachmentId() != null)
{
return (entry);
}
if(entry.storageState() != UploadStorageState.VERIFIED)
{
throw failure(UploadJournalException.Reason.STORAGE_NOT_VERIFIED);
}
// Memory-backend fixtures do not prove atomic rollback. The JDBC tests do.
Integer attachment = registrar.insert(new QBackendTransaction(), entry.candidate(), entry.id());
return (replace(entry, UploadJournalState.REGISTERED, entry.storageState(), attachment, entry.leaseUntil()));
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized UploadEntry complete(UploadReservation reservation) throws QException
{
UploadEntry entry = require(reservation.journalId());
if(entry.state() == UploadJournalState.COMPLETE && entry.fence().equals(reservation.fence()))
{
return (entry);
}
entry = holder(reservation);
if(entry.state() != UploadJournalState.REGISTERED || entry.attachmentId() == null)
{
throw failure(UploadJournalException.Reason.ILLEGAL_STATE);
}
tokens.remove(entry.id());
return (replace(entry, UploadJournalState.COMPLETE, entry.storageState(), entry.attachmentId(), null));
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized boolean release(UploadReservation reservation) throws QException
{
UploadEntry entry = require(reservation.journalId());
if(entry.state() == UploadJournalState.COMPLETE || !matches(entry, reservation))
{
return (false);
}
tokens.remove(entry.id());
replace(entry, entry.state(), entry.storageState(), entry.attachmentId(), null);
return (true);
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized Optional<UploadEntry> find(Integer journalId)
{
return (Optional.ofNullable(rows.get(journalId)));
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized Optional<UploadEntry> findByLocation(String bucketName, String storageKey)
{
String locator = UploadCandidate.locatorHash(bucketName, storageKey);
return (rows.values().stream().filter(entry -> entry.candidate().locatorHash().equals(locator)).findFirst());
}
/*******************************************************************************
** Test-only implementation; does not establish durability.
*******************************************************************************/
@Override
public synchronized List<UploadEntry> listIncomplete(int limit)
{
if(limit < 1 || limit > 500)
{
throw new IllegalArgumentException("Invalid fixture limit.");
}
return (rows.values().stream().filter(entry -> entry.state() != UploadJournalState.COMPLETE).limit(limit).toList());
}
/*******************************************************************************
** Enforce fixture state without claiming production transaction guarantees.
*******************************************************************************/
private UploadEntry require(Integer id) throws QException
{
UploadEntry entry = rows.get(id);
if(entry == null)
{
throw failure(UploadJournalException.Reason.NOT_FOUND);
}
return (entry);
}
/*******************************************************************************
** Enforce fixture state without claiming production transaction guarantees.
*******************************************************************************/
private UploadEntry holder(UploadReservation reservation) throws QException
{
UploadEntry entry = require(reservation.journalId());
if(!matches(entry, reservation))
{
throw failure(UploadJournalException.Reason.STALE_RESERVATION);
}
return (entry);
}
/*******************************************************************************
** Enforce fixture state without claiming production transaction guarantees.
*******************************************************************************/
private boolean matches(UploadEntry entry, UploadReservation reservation)
{
return (entry.fence().equals(reservation.fence()) && entry.isLeased(Instant.now())
&& reservation.leaseToken().equals(tokens.get(entry.id())));
}
/*******************************************************************************
** Enforce fixture state without claiming production transaction guarantees.
*******************************************************************************/
private UploadEntry replace(UploadEntry entry, UploadJournalState state, UploadStorageState storage, Integer attachment, Instant leaseUntil)
{
UploadEntry changed = new UploadEntry(entry.id(), entry.candidate(), state, storage, attachment, entry.fence(), leaseUntil);
rows.put(entry.id(), changed);
return (changed);
}
/*******************************************************************************
** Enforce fixture state without claiming production transaction guarantees.
*******************************************************************************/
private static void duration(Duration lease)
{
if(lease == null || lease.compareTo(Duration.ofSeconds(1)) < 0 || lease.compareTo(Duration.ofMinutes(30)) > 0)
{
throw new IllegalArgumentException("Invalid fixture lease.");
}
}
/*******************************************************************************
** Enforce fixture state without claiming production transaction guarantees.
*******************************************************************************/
private static UploadJournalException failure(UploadJournalException.Reason reason)
{
return (new UploadJournalException(reason, "Memory test journal refused: " + reason));
}
}
@@ -1,697 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import com.github.dockerjava.api.model.ExposedPort;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalException.Reason;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.rdbms.RDBMSBackendModule;
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.config.Configurator;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.junit.jupiter.api.extension.TestWatcher;
import org.testcontainers.mysql.MySQLContainer;
import org.testcontainers.utility.DockerImageName;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Explicit opt-in real MySQL 8.4 qualification. The container and anonymous data
** volume belong only to this suite; no existing server, database or credentials
** are used. The attachment row is a minimal stand-in, not a full intake fixture.
*******************************************************************************/
@EnabledIfSystemProperty(named = "mysqlJournalTests", matches = "true")
class MySqlUploadJournalTest
{
private static final String IMAGE = "mysql@sha256:a0225d1ea6631c8bc2385dc4c2faba17bbf1834ecf369e1659a9a382bd8d5dd7";
private static final Instant START = Instant.parse("2026-10-30T12:00:00.123Z");
private static MySQLContainer mysql;
private static int currentPort;
private static final Map<String, Level> LOG_LEVELS = new HashMap<>();
private RDBMSBackendMetaData backend;
private QInstance instance;
private JdbcUploadJournal journal;
@RegisterExtension
static final TestWatcher FAILURE_DIAGNOSTICS = new TestWatcher()
{
/*******************************************************************************
** Capture only bounded health/counts after failure, before container teardown.
*******************************************************************************/
@Override
public void testFailed(ExtensionContext context, Throwable cause)
{
diagnoseFailure(cause);
}
};
/*******************************************************************************
** Never log exception messages, SQL, credentials, row values or session IDs.
** Independent diagnostic deadlines do not alter application/test deadlines.
*******************************************************************************/
private static void diagnoseFailure(Throwable failure)
{
diagnosticCause(failure);
try
{
var inspection = mysql.getDockerClient().inspectContainerCmd(mysql.getContainerId()).exec();
var state = inspection.getState();
System.out.println("MYSQL_FIXTURE_HEALTH running=" + state.getRunning() + " oomKilled=" + state.getOOMKilled()
+ " exitCode=" + state.getExitCodeLong() + " restarts=" + inspection.getRestartCount());
}
catch(Exception unavailable)
{
diagnosticCause(unavailable);
}
try(Connection connection = DriverManager.getConnection("jdbc:mysql://" + mysql.getHost() + ":" + currentPort + "/" + mysql.getDatabaseName()
+ "?useSSL=false&allowPublicKeyRetrieval=true&connectTimeout=1000&socketTimeout=1000", mysql.getUsername(), mysql.getPassword());
Statement statement = connection.createStatement())
{
statement.setQueryTimeout(1);
try(ResultSet result = statement.executeQuery("SELECT 1"))
{
System.out.println("MYSQL_FIXTURE_SELECT_ONE success=" + (result.next() && result.getInt(1) == 1));
}
try(ResultSet result = statement.executeQuery("SELECT COUNT(*), SUM(COMMAND = 'Sleep') FROM information_schema.PROCESSLIST"))
{
if(result.next())
{
System.out.println("MYSQL_FIXTURE_CONNECTIONS total=" + result.getLong(1) + " sleeping=" + result.getLong(2));
}
}
try(ResultSet result = statement.executeQuery("SELECT COUNT(*), SUM(TRX_STATE = 'LOCK WAIT') FROM information_schema.INNODB_TRX"))
{
if(result.next())
{
System.out.println("MYSQL_FIXTURE_TRANSACTIONS total=" + result.getLong(1) + " lockWaiting=" + result.getLong(2));
}
}
try(ResultSet result = statement.executeQuery("SELECT COUNT(*), SUM(LOCK_STATUS = 'PENDING') FROM performance_schema.metadata_locks"))
{
if(result.next())
{
System.out.println("MYSQL_FIXTURE_METADATA_LOCKS total=" + result.getLong(1) + " pending=" + result.getLong(2));
}
}
}
catch(Exception unavailable)
{
diagnosticCause(unavailable);
}
}
/*******************************************************************************
** A bounded class/state chain is safe even when driver messages contain secrets.
*******************************************************************************/
private static void diagnosticCause(Throwable failure)
{
for(int depth = 0; failure != null && depth < 12; depth++, failure = failure.getCause())
{
String detail = "";
if(failure instanceof SQLException sql)
{
String state = sql.getSQLState();
detail = " state=" + (state != null && state.matches("[A-Z0-9]{5}") ? state : "unknown") + " code=" + sql.getErrorCode();
}
System.out.println("MYSQL_FIXTURE_FAILURE class=" + failure.getClass().getName() + detail);
}
}
/*******************************************************************************
** Start only the pinned disposable provider, with a random synthetic password.
*******************************************************************************/
@BeforeAll
static void start()
{
for(String logger : List.of("org.apache.hc", "org.apache.http", "org.testcontainers", "com.github.dockerjava"))
{
LOG_LEVELS.put(logger, LogManager.getLogger(logger).getLevel());
Configurator.setLevel(logger, Level.WARN);
}
mysql = new MySQLContainer(DockerImageName.parse(IMAGE)).withDatabaseName("journal_test").withUsername("root")
.withPassword(UUID.randomUUID().toString()).withCommand("--innodb-flush-log-at-trx-commit=1", "--sync-binlog=1", "--innodb-lock-wait-timeout=5");
mysql.start();
currentPort = mysql.getMappedPort(3306);
}
/*******************************************************************************
** Destroy only the provider created by this suite and its private volume.
*******************************************************************************/
@AfterAll
static void stop()
{
if(mysql != null)
{
mysql.stop();
}
LOG_LEVELS.forEach(Configurator::setLevel);
}
/*******************************************************************************
** Exercise native URL construction, rather than configuring a custom provider.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
ConnectionManager.resetConnectionProviders();
backend = new RDBMSBackendMetaData().withName("mysql_journal").withVendor("mysql").withHostName(mysql.getHost())
.withPort(currentPort).withDatabaseName(mysql.getDatabaseName()).withUsername(mysql.getUsername()).withPassword(mysql.getPassword());
instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(backend);
for(String name : List.of(UploadJournalTable.NAME, CollaborationTables.ATTACHMENT))
{
instance.addTable(new QTableMetaData().withName(name).withBackendName(backend.getName()).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
}
context();
execute("DROP TABLE IF EXISTS collaboration_upload_journal");
execute("DROP TABLE IF EXISTS collaboration_attachment");
execute(UploadJournalTable.mysqlScript());
execute("CREATE TABLE collaboration_attachment (id INTEGER AUTO_INCREMENT PRIMARY KEY, file_name VARCHAR(255)) ENGINE=InnoDB");
journal = new JdbcUploadJournal(Clock.fixed(START, ZoneOffset.UTC));
}
/*******************************************************************************
** No connection or thread-local authority survives a fixture.
*******************************************************************************/
@AfterEach
void close() throws Exception
{
QContext.clearTransaction();
QContext.clear();
ConnectionManager.resetConnectionProviders();
}
/*******************************************************************************
** Acknowledged metadata survives a real database process restart, not merely
** an open pooled connection. The same candidate replays its original row.
*******************************************************************************/
@Test
void committedCandidateSurvivesDatabaseRestart() throws Exception
{
UploadEntry original = journal.prepare(candidate("restart"));
ConnectionManager.resetConnectionProviders();
mysql.getDockerClient().restartContainerCmd(mysql.getContainerId()).exec();
currentPort = Integer.parseInt(mysql.getCurrentContainerInfo().getNetworkSettings().getPorts().getBindings().get(new ExposedPort(3306))[0].getHostPortSpec());
backend.setPort(currentPort);
long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos();
boolean ready = false;
while(System.nanoTime() < deadline)
{
try(Connection connection = connection(); Statement statement = connection.createStatement())
{
statement.execute("SELECT 1");
ready = true;
break;
}
catch(SQLException transientStartup)
{
Thread.sleep(250);
}
}
assertThat(ready).isTrue();
assertThat(journal.prepare(candidate("restart")).id()).isEqualTo(original.id());
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
}
/*******************************************************************************
** A registrar failure rolls back both its attachment and the owner checkpoint.
*******************************************************************************/
@Test
void registrationAndCheckpointCommitOrRollbackTogether() throws Exception
{
UploadReservation reservation = verified("rollback");
assertThatThrownBy(() -> journal.register(reservation, (transaction, candidate, id) ->
{
insertAttachment(transaction);
throw (new QException("Synthetic registrar failure."));
})).isInstanceOf(QException.class);
assertThat(count("collaboration_attachment")).isZero();
assertThat(journal.find(reservation.journalId()).orElseThrow().state()).isEqualTo(UploadJournalState.PREPARED);
UploadEntry registered = journal.register(reservation, (transaction, candidate, id) -> insertAttachment(transaction));
assertThat(registered.state()).isEqualTo(UploadJournalState.REGISTERED);
assertThat(journal.complete(reservation).state()).isEqualTo(UploadJournalState.COMPLETE);
assertThat(journal.complete(reservation).attachmentId()).isEqualTo(registered.attachmentId());
assertThat(count("collaboration_attachment")).isEqualTo(1);
}
/*******************************************************************************
** Unique preparation and exclusive leases hold across real simultaneous JDBC
** connections. A new fence rejects the old holder after expiry.
*******************************************************************************/
@Test
void concurrentPrepareAndReservationFence() throws Exception
{
try(var executor = Executors.newFixedThreadPool(4))
{
CountDownLatch start = new CountDownLatch(1);
List<Future<UploadEntry>> prepares = new ArrayList<>();
for(int i = 0; i < 4; i++)
{
prepares.add(executor.submit(() ->
{
context();
start.await();
return (journal.prepare(candidate("race")));
}));
}
start.countDown();
Integer id = prepares.getFirst().get(30, TimeUnit.SECONDS).id();
for(Future<UploadEntry> prepared : prepares)
{
assertThat(prepared.get(30, TimeUnit.SECONDS).id()).isEqualTo(id);
}
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
List<Future<Object>> reservations = new ArrayList<>();
CountDownLatch reserveStart = new CountDownLatch(1);
for(int i = 0; i < 4; i++)
{
reservations.add(executor.submit(() ->
{
context();
reserveStart.await();
try
{
return (journal.reserve(id, Duration.ofSeconds(5)));
}
catch(UploadJournalException denied)
{
return (denied.getReason());
}
}));
}
reserveStart.countDown();
List<Object> outcomes = new ArrayList<>();
for(Future<Object> reservation : reservations)
{
outcomes.add(reservation.get(30, TimeUnit.SECONDS));
}
assertThat(outcomes.stream().filter(UploadReservation.class::isInstance)).hasSize(1);
assertThat(outcomes.stream().filter(Reason.class::isInstance)).containsOnly(Reason.LEASE_HELD);
UploadReservation old = (UploadReservation) outcomes.stream().filter(UploadReservation.class::isInstance).findFirst().orElseThrow();
JdbcUploadJournal later = new JdbcUploadJournal(Clock.fixed(START.plusSeconds(6), ZoneOffset.UTC));
UploadReservation next = later.reserve(id, Duration.ofSeconds(5));
assertThat(next.fence()).isGreaterThan(old.fence());
assertThatThrownBy(() -> later.recordStorage(old, UploadStorageState.VERIFIED)).isInstanceOf(UploadJournalException.class);
}
}
/*******************************************************************************
** Wrong metadata mappings, catalogs, and ambient transactions fail before a
** manifest write. Aurora is not an alias for the tested MySQL provider.
*******************************************************************************/
@Test
void rejectsWrongBackendCatalogMappingAndAmbientTransaction() throws Exception
{
instance.addBackend(new RDBMSBackendMetaData().withName("other_backend").withVendor("mysql"));
instance.getTable(CollaborationTables.ATTACHMENT).setBackendName("other_backend");
assertUnsupported();
instance.getTable(CollaborationTables.ATTACHMENT).setBackendName(backend.getName());
instance.getTable(CollaborationTables.ATTACHMENT).setBackendDetails(new RDBMSTableBackendDetails().withTableName("other_attachment"));
assertUnsupported();
instance.getTable(CollaborationTables.ATTACHMENT).setBackendDetails(null);
try(Connection connection = connection())
{
connection.setAutoCommit(false);
connection.setCatalog("mysql");
assertThatThrownBy(() -> MySqlJournalDatabase.requireQualifiedConnection(connection, backend)).isInstanceOf(UploadJournalException.class);
}
backend.setVendor("aurora-mysql");
assertUnsupported();
backend.setVendor("mysql");
try(QBackendTransaction transaction = new RDBMSBackendModule().openTransaction(new InsertInput(UploadJournalTable.NAME)))
{
QContext.setTransaction(transaction);
assertThatThrownBy(() -> journal.prepare(candidate("denied"))).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.AMBIENT_TRANSACTION);
}
QContext.clearTransaction();
assertThat(count("collaboration_upload_journal")).isZero();
}
/*******************************************************************************
** Runtime settings and the real attachment engine are authoritative. Restore
** all disposable-server globals even when an assertion fails.
*******************************************************************************/
@Test
void rejectsNonDurableSettingsAndNonTransactionalAttachment() throws Exception
{
try
{
execute("SET GLOBAL innodb_flush_log_at_trx_commit=2");
assertUnsupported();
execute("SET GLOBAL innodb_flush_log_at_trx_commit=1");
execute("SET GLOBAL sync_binlog=0");
assertUnsupported();
}
finally
{
execute("SET GLOBAL innodb_flush_log_at_trx_commit=1");
execute("SET GLOBAL sync_binlog=1");
}
execute("ALTER TABLE collaboration_attachment ENGINE=MyISAM");
assertUnsupported();
assertThat(count("collaboration_upload_journal")).isZero();
}
/*******************************************************************************
** The SQL schema itself preserves case and trailing spaces in identifiers,
** and native generic writes cannot bypass journal ownership.
*******************************************************************************/
@Test
void binaryIdentifiersAndNativeWritePolicy() throws Exception
{
journal.prepare(candidate("binary"));
try(Connection connection = connection(); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM collaboration_upload_journal WHERE created_by_user_id IN ('Alice', 'alice ')"))
{
result.next();
assertThat(result.getInt(1)).isZero();
}
assertThatThrownBy(() -> new UploadJournalSecurity().checkPermissionsThrowing(new InsertInput(UploadJournalTable.NAME), instance.getTable(UploadJournalTable.NAME)))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
execute("ALTER TABLE collaboration_upload_journal DEFAULT COLLATE=utf8mb4_0900_ai_ci");
assertUnsupported();
}
/*******************************************************************************
** The actual server lock timeout maps to BUSY and leaves no granted lease.
*******************************************************************************/
@Test
void lockTimeoutIsBoundedAndDoesNotGrantLease() throws Exception
{
Integer id = journal.prepare(candidate("locked")).id();
try(Connection connection = connection(); PreparedStatement lock = connection.prepareStatement("SELECT id FROM collaboration_upload_journal WHERE id=? FOR UPDATE"))
{
connection.setAutoCommit(false);
lock.setInt(1, id);
try(ResultSet row = lock.executeQuery())
{
assertThat(row.next()).isTrue();
long started = System.nanoTime();
assertThatThrownBy(() -> journal.reserve(id, Duration.ofSeconds(5))).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.BUSY);
System.out.println("MYSQL_FIXTURE_PHASE reserveBusyMillis=" + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started));
}
System.out.println("MYSQL_FIXTURE_PHASE beforeIndependentRollback");
diagnoseFailure(null);
connection.rollback();
System.out.println("MYSQL_FIXTURE_PHASE afterIndependentRollback");
}
assertThat(journal.find(id).orElseThrow().fence()).isZero();
assertThat(journal.reserve(id, Duration.ofSeconds(5)).fence()).isEqualTo(1L);
}
/*******************************************************************************
** Actual column/index drift is refused even if the table default still looks
** correct. A changed candidate at the original location remains a conflict.
*******************************************************************************/
@Test
void columnAndIndexDriftAndManifestConflictFailClosed() throws Exception
{
UploadCandidate original = candidate("immutable");
journal.prepare(original);
UploadCandidate changed = new UploadCandidate(original.tableName(), original.recordId(), original.actorId(), "changed.pdf", original.contentType(), original.description(),
original.sizeBytes(), original.sha256(), original.bucketName(), original.storageKey(), true, null, null);
assertThatThrownBy(() -> journal.prepare(changed)).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.IMMUTABLE_CONFLICT);
execute("ALTER TABLE collaboration_upload_journal MODIFY created_by_user_id VARCHAR(255) COLLATE utf8mb4_0900_ai_ci NOT NULL");
assertUnsupported();
execute("ALTER TABLE collaboration_upload_journal MODIFY created_by_user_id VARCHAR(255) COLLATE utf8mb4_0900_bin NOT NULL");
execute("DROP INDEX uq_collaboration_upload_journal_locator ON collaboration_upload_journal");
assertUnsupported();
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
}
/*******************************************************************************
** Lease instants are stable when app hosts use different JVM default zones.
** A non-UTC database session is refused rather than silently shifting leases.
*******************************************************************************/
@Test
void leaseInstantsUseExplicitUtcAcrossJvmZones() throws Exception
{
TimeZone prior = TimeZone.getDefault();
try
{
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Honolulu"));
UploadReservation reservation = verified("timezone");
assertThat(reservation.leaseUntil()).isEqualTo(START.plusSeconds(60));
ConnectionManager.resetConnectionProviders();
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Kiritimati"));
assertThat(journal.find(reservation.journalId()).orElseThrow().leaseUntil()).isEqualTo(START.plusSeconds(60));
assertThat(journal.renew(reservation, Duration.ofMinutes(2)).leaseUntil()).isEqualTo(START.plusSeconds(120));
try(Connection connection = connection(); Statement statement = connection.createStatement())
{
connection.setAutoCommit(false);
statement.execute("SET SESSION time_zone='+01:00'");
assertThatThrownBy(() -> MySqlJournalDatabase.requireQualifiedConnection(connection, backend)).isInstanceOf(UploadJournalException.class);
}
}
finally
{
ConnectionManager.resetConnectionProviders();
TimeZone.setDefault(prior);
}
}
/*******************************************************************************
** Qualification never mutates a host's pooled session. Accepted settings are
** preserved, while an excessive wait is refused and also left unchanged.
*******************************************************************************/
@Test
void lockTimeoutQualificationPreservesHostSession() throws Exception
{
try(Connection connection = connection(); Statement statement = connection.createStatement())
{
connection.setAutoCommit(false);
statement.execute("SET SESSION innodb_lock_wait_timeout=3");
MySqlJournalDatabase.requireQualifiedConnection(connection, backend);
try(ResultSet value = statement.executeQuery("SELECT @@SESSION.innodb_lock_wait_timeout"))
{
value.next();
assertThat(value.getInt(1)).isEqualTo(3);
}
statement.execute("SET SESSION innodb_lock_wait_timeout=6");
assertThatThrownBy(() -> MySqlJournalDatabase.requireQualifiedConnection(connection, backend)).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.UNSUPPORTED_BACKEND);
try(ResultSet value = statement.executeQuery("SELECT @@SESSION.innodb_lock_wait_timeout"))
{
value.next();
assertThat(value.getInt(1)).isEqualTo(6);
}
}
}
/*******************************************************************************
** Detect-only page protection and read-only sessions cannot promise durable
** journal writes. Qualification must leave the host session unchanged.
*******************************************************************************/
@Test
void doublewriteAndSessionReadOnlyAreRequired() throws Exception
{
try
{
execute("SET GLOBAL innodb_doublewrite=DETECT_ONLY");
assertUnsupported();
}
finally
{
execute("SET GLOBAL innodb_doublewrite=ON");
}
try(Connection connection = connection(); Statement statement = connection.createStatement())
{
connection.setAutoCommit(false);
statement.execute("SET SESSION transaction_read_only=1");
assertThatThrownBy(() -> MySqlJournalDatabase.requireQualifiedConnection(connection, backend)).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.UNSUPPORTED_BACKEND);
try(ResultSet value = statement.executeQuery("SELECT @@SESSION.transaction_read_only"))
{
value.next();
assertThat(value.getBoolean(1)).isTrue();
}
statement.execute("SET SESSION transaction_read_only=0");
MySqlJournalDatabase.requireQualifiedConnection(connection, backend);
}
assertThat(journal.prepare(candidate("restored-durability")).id()).isPositive();
}
/*******************************************************************************
** Assert a typed refusal, with no connection URL in its public message.
*******************************************************************************/
private void assertUnsupported()
{
assertThatThrownBy(() -> journal.prepare(candidate("denied"))).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.UNSUPPORTED_BACKEND);
}
/*******************************************************************************
** Prepare a leased manifest whose stored bytes have been verified by owner.
*******************************************************************************/
private UploadReservation verified(String label) throws Exception
{
UploadReservation reservation = journal.reserve(journal.prepare(candidate(label)).id(), Duration.ofMinutes(1));
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
return (reservation);
}
/*******************************************************************************
** Insert only through the owner's exact transaction.
*******************************************************************************/
private static Integer insertAttachment(QBackendTransaction transaction) throws QException
{
try(PreparedStatement statement = ((RDBMSTransaction) transaction).getConnection()
.prepareStatement("INSERT INTO collaboration_attachment(file_name) VALUES ('fixture.pdf')", Statement.RETURN_GENERATED_KEYS))
{
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys())
{
keys.next();
return (keys.getInt(1));
}
}
catch(SQLException e)
{
throw (new QException("Synthetic attachment insert failed.", e));
}
}
/*******************************************************************************
** Independent connection for committed-state assertions and fixture setup.
*******************************************************************************/
private Connection connection() throws SQLException
{
return (DriverManager.getConnection("jdbc:mysql://" + mysql.getHost() + ":" + currentPort + "/" + mysql.getDatabaseName()
+ "?useSSL=false&allowPublicKeyRetrieval=true&connectTimeout=1000&socketTimeout=10000", mysql.getUsername(), mysql.getPassword()));
}
/*******************************************************************************
** Execute fixed trusted fixture SQL, never application-supplied identifiers.
*******************************************************************************/
private void execute(String sql) throws SQLException
{
try(Connection connection = connection(); Statement statement = connection.createStatement())
{
statement.execute(sql);
}
}
/*******************************************************************************
** Count a fixed owner table through a new committed connection.
*******************************************************************************/
private long count(String table) throws SQLException
{
try(Connection connection = connection(); Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM " + table))
{
result.next();
return (result.getLong(1));
}
}
/*******************************************************************************
** Establish the same synthetic actor independently on each test thread.
*******************************************************************************/
private void context()
{
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("alice")));
}
/*******************************************************************************
** Server-shaped immutable candidate; no real customer data or storage is used.
*******************************************************************************/
private static UploadCandidate candidate(String label)
{
return (new UploadCandidate("document", "1", "alice", label + ".pdf", "application/pdf", "Synthetic fixture", 5L, "a".repeat(64),
"fixture-bucket", "retained/" + label, true, null, null));
}
}
@@ -1,689 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.TimeZone;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import com.github.dockerjava.api.model.ExposedPort;
import com.github.dockerjava.api.model.PortBinding;
import com.github.dockerjava.api.model.Ports;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalException.Reason;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.RDBMSBackendModule;
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSTableBackendDetails;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Explicit opt-in real PostgreSQL 17 qualification of the upload journal on the
** native postgres backend. The container belongs only to this suite (loopback,
** dynamic port, random synthetic passwords); the application connects as a
** DML-only role, and the superuser is used only for fixture DDL and fault
** injection. The attachment row is a minimal stand-in, not a full intake fixture.
*******************************************************************************/
@EnabledIfSystemProperty(named = "postgresJournalTests", matches = "true")
class PostgresUploadJournalTest
{
private static final Instant START = Instant.parse("2026-10-30T12:00:00.123Z");
private static final String DATABASE = "journal_test";
private static final String APP_ROLE = "journal_app";
private static final String ROOT_PASSWORD = UUID.randomUUID().toString();
private static final String APP_PASSWORD = UUID.randomUUID().toString();
private static final String OPTIONS = "?connectTimeout=5&socketTimeout=30&options=-c%20TimeZone%3DUTC";
private static GenericContainer<?> postgres;
private static TimeZone previousZone;
private static int currentPort;
// opt-in: reach the disposable server by its container bridge address (no host port published) - for the
// sibling-container Linux gate, where the host loopback is not the test JVM's loopback
private static final boolean BRIDGE_ADDRESS = Boolean.getBoolean("postgresJournalTests.bridgeAddress");
private static String currentHost = "127.0.0.1";
private PostgreSQLBackendMetaData backend;
private QInstance instance;
private JdbcUploadJournal journal;
/*******************************************************************************
** Start only the disposable server with explicit durable settings. pgjdbc
** announces the JVM zone as the session TimeZone, so the suite runs in UTC.
*******************************************************************************/
@BeforeAll
static void start() throws Exception
{
previousZone = TimeZone.getDefault();
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
postgres = new GenericContainer<>("postgres:17")
.withEnv("POSTGRES_PASSWORD", ROOT_PASSWORD).withEnv("POSTGRES_DB", DATABASE)
.withCommand("postgres", "-c", "fsync=on", "-c", "synchronous_commit=on", "-c", "full_page_writes=on")
.withExposedPorts(5432)
.withCreateContainerCmdModifier(command ->
{
command.getHostConfig().withMemory(768L * 1024 * 1024);
if(!BRIDGE_ADDRESS)
{
command.getHostConfig().withPortBindings(new PortBinding(Ports.Binding.bindIpAndPort("127.0.0.1", 0), new ExposedPort(5432)));
}
})
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\s", 2).withStartupTimeout(Duration.ofMinutes(2)));
postgres.start();
locateServer();
try(Connection connection = root(); Statement statement = connection.createStatement())
{
statement.execute("CREATE ROLE " + APP_ROLE + " LOGIN PASSWORD '" + APP_PASSWORD + "' NOSUPERUSER NOCREATEDB NOCREATEROLE");
statement.execute("ALTER ROLE " + APP_ROLE + " SET lock_timeout='5s'");
statement.execute("DROP SCHEMA public CASCADE");
statement.execute("CREATE SCHEMA public");
statement.execute("GRANT USAGE ON SCHEMA public TO " + APP_ROLE);
statement.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO " + APP_ROLE);
statement.execute("ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO " + APP_ROLE);
}
}
/*******************************************************************************
** Destroy only the container created by this suite and restore the JVM zone.
*******************************************************************************/
@AfterAll
static void stop()
{
ConnectionManager.closeConnectionProviders();
if(postgres != null)
{
postgres.close();
}
if(previousZone != null)
{
TimeZone.setDefault(previousZone);
}
}
/*******************************************************************************
** Native postgres backend metadata with the DML-only identity, a fresh journal
** and attachment pair, and the shipped owner script.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
ConnectionManager.closeConnectionProviders();
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
execute("ALTER ROLE " + APP_ROLE + " SET lock_timeout='5s'");
execute("ALTER ROLE " + APP_ROLE + " RESET synchronous_commit");
execute("ALTER ROLE " + APP_ROLE + " RESET default_transaction_isolation");
execute("ALTER ROLE " + APP_ROLE + " RESET default_transaction_read_only");
backend = new PostgreSQLBackendMetaData();
backend.setName("postgres_journal");
backend.setDatabaseName(DATABASE);
backend.setUsername(APP_ROLE);
backend.setPassword(APP_PASSWORD);
backend.setJdbcUrl(appUrl());
instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
instance.addBackend(backend);
for(String name : List.of(UploadJournalTable.NAME, CollaborationTables.ATTACHMENT))
{
instance.addTable(new QTableMetaData().withName(name).withBackendName(backend.getName()).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
}
context();
execute("DROP TABLE IF EXISTS collaboration_upload_journal");
execute("DROP TABLE IF EXISTS collaboration_attachment");
execute("DROP COLLATION IF EXISTS journal_case_insensitive");
execute(UploadJournalTable.postgresScript());
execute("CREATE TABLE collaboration_attachment (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, file_name VARCHAR(255))");
journal = new JdbcUploadJournal(Clock.fixed(START, ZoneOffset.UTC));
}
/*******************************************************************************
** No connection or thread-local authority survives a fixture.
*******************************************************************************/
@AfterEach
void close() throws Exception
{
QContext.clearTransaction();
QContext.clear();
ConnectionManager.closeConnectionProviders();
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
}
/*******************************************************************************
** Only the native postgres module with both strings is admitted; the generic
** rdbms backend type with a postgres vendor label is not.
*******************************************************************************/
@Test
void admitsOnlyTheNativePostgresBackend()
{
assertThat(JdbcUploadJournal.isQualifiedBackend(backend)).isTrue();
backend.setBackendType("rdbms");
assertThat(JdbcUploadJournal.isQualifiedBackend(backend)).isFalse();
assertUnsupported();
backend.setBackendType("postgres");
backend.setVendor("aurora-postgresql");
assertThat(JdbcUploadJournal.isQualifiedBackend(backend)).isFalse();
backend.setVendor("postgres");
assertThat(JdbcUploadJournal.isQualifiedBackend(backend)).isTrue();
}
/*******************************************************************************
** The shipped script is re-runnable, and its CHECK guards stand on the server:
** REGISTERED or COMPLETE without an attachment id, and a half intake pair, are refused.
*******************************************************************************/
@Test
void scriptIsRerunnableAndConstraintsHold() throws Exception
{
execute(UploadJournalTable.postgresScript());
String insert = "INSERT INTO collaboration_upload_journal (create_date, modify_date, table_name, record_id, created_by_user_id, file_name, content_type,"
+ " size_bytes, sha256, bucket_name, storage_key, retained, intake_key, intake_fingerprint, locator_hash, manifest_hash, state, storage_state, attachment_id, fence)"
+ " VALUES (now(), now(), 't', '1', 'a', 'f', 'c', 1, 's', 'b', 'k', TRUE, %s, %s, 'L%s', 'm', '%s', '%s', %s, 0)";
assertThatThrownBy(() -> execute(String.format(insert, "NULL", "NULL", "1", "REGISTERED", "VERIFIED", "NULL"))).isInstanceOf(SQLException.class)
.extracting(e -> ((SQLException) e).getSQLState()).isEqualTo("23514");
assertThatThrownBy(() -> execute(String.format(insert, "'k'", "NULL", "2", "PREPARED", "PENDING", "NULL"))).isInstanceOf(SQLException.class)
.extracting(e -> ((SQLException) e).getSQLState()).isEqualTo("23514");
assertThatThrownBy(() -> execute(String.format(insert, "NULL", "NULL", "3", "REGISTERED", "PENDING", "1"))).isInstanceOf(SQLException.class)
.extracting(e -> ((SQLException) e).getSQLState()).isEqualTo("23514");
execute(String.format(insert, "NULL", "NULL", "4", "PREPARED", "PENDING", "NULL"));
assertThatThrownBy(() -> execute(String.format(insert, "NULL", "NULL", "4", "PREPARED", "PENDING", "NULL"))).isInstanceOf(SQLException.class)
.extracting(e -> ((SQLException) e).getSQLState()).isEqualTo("23505");
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
}
/*******************************************************************************
** Acknowledged metadata survives a real database process restart, not merely
** an open pooled connection. The same candidate replays its original row.
*******************************************************************************/
@Test
void committedCandidateSurvivesDatabaseRestart() throws Exception
{
UploadEntry original = journal.prepare(candidate("restart"));
ConnectionManager.closeConnectionProviders();
postgres.getDockerClient().restartContainerCmd(postgres.getContainerId()).exec();
locateServer();
backend.setJdbcUrl(appUrl());
long deadline = System.nanoTime() + Duration.ofSeconds(60).toNanos();
boolean ready = false;
while(System.nanoTime() < deadline)
{
try(Connection connection = root(); Statement statement = connection.createStatement())
{
statement.execute("SELECT 1");
ready = true;
break;
}
catch(SQLException transientStartup)
{
Thread.sleep(250);
}
}
assertThat(ready).isTrue();
assertThat(journal.prepare(candidate("restart")).id()).isEqualTo(original.id());
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
}
/*******************************************************************************
** A registrar failure rolls back both its attachment and the owner checkpoint.
*******************************************************************************/
@Test
void registrationAndCheckpointCommitOrRollbackTogether() throws Exception
{
UploadReservation reservation = verified("rollback");
assertThatThrownBy(() -> journal.register(reservation, (transaction, candidate, id) ->
{
insertAttachment(transaction);
throw (new QException("Synthetic registrar failure."));
})).isInstanceOf(QException.class);
assertThat(count("collaboration_attachment")).isZero();
assertThat(journal.find(reservation.journalId()).orElseThrow().state()).isEqualTo(UploadJournalState.PREPARED);
UploadEntry registered = journal.register(reservation, (transaction, candidate, id) -> insertAttachment(transaction));
assertThat(registered.state()).isEqualTo(UploadJournalState.REGISTERED);
assertThat(journal.complete(reservation).state()).isEqualTo(UploadJournalState.COMPLETE);
assertThat(journal.complete(reservation).attachmentId()).isEqualTo(registered.attachmentId());
assertThat(count("collaboration_attachment")).isEqualTo(1);
}
/*******************************************************************************
** Unique preparation and exclusive leases hold across real simultaneous JDBC
** connections. A new fence rejects the old holder after expiry.
*******************************************************************************/
@Test
void concurrentPrepareAndReservationFence() throws Exception
{
try(var executor = Executors.newFixedThreadPool(4))
{
CountDownLatch start = new CountDownLatch(1);
List<Future<UploadEntry>> prepares = new ArrayList<>();
for(int i = 0; i < 4; i++)
{
prepares.add(executor.submit(() ->
{
context();
start.await();
return (journal.prepare(candidate("race")));
}));
}
start.countDown();
Integer id = prepares.getFirst().get(30, TimeUnit.SECONDS).id();
for(Future<UploadEntry> prepared : prepares)
{
assertThat(prepared.get(30, TimeUnit.SECONDS).id()).isEqualTo(id);
}
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
List<Future<Object>> reservations = new ArrayList<>();
CountDownLatch reserveStart = new CountDownLatch(1);
for(int i = 0; i < 4; i++)
{
reservations.add(executor.submit(() ->
{
context();
reserveStart.await();
try
{
return (journal.reserve(id, Duration.ofSeconds(5)));
}
catch(UploadJournalException denied)
{
return (denied.getReason());
}
}));
}
reserveStart.countDown();
List<Object> outcomes = new ArrayList<>();
for(Future<Object> reservation : reservations)
{
outcomes.add(reservation.get(30, TimeUnit.SECONDS));
}
assertThat(outcomes.stream().filter(UploadReservation.class::isInstance)).hasSize(1);
assertThat(outcomes.stream().filter(Reason.class::isInstance)).containsOnly(Reason.LEASE_HELD);
UploadReservation old = (UploadReservation) outcomes.stream().filter(UploadReservation.class::isInstance).findFirst().orElseThrow();
JdbcUploadJournal later = new JdbcUploadJournal(Clock.fixed(START.plusSeconds(6), ZoneOffset.UTC));
UploadReservation next = later.reserve(id, Duration.ofSeconds(5));
assertThat(next.fence()).isGreaterThan(old.fence());
assertThatThrownBy(() -> later.recordStorage(old, UploadStorageState.VERIFIED)).isInstanceOf(UploadJournalException.class);
}
}
/*******************************************************************************
** Wrong metadata mappings, databases and ambient transactions fail before a
** manifest write.
*******************************************************************************/
@Test
void rejectsWrongBackendDatabaseMappingAndAmbientTransaction() throws Exception
{
PostgreSQLBackendMetaData other = new PostgreSQLBackendMetaData();
other.setName("other_backend");
instance.addBackend(other);
instance.getTable(CollaborationTables.ATTACHMENT).setBackendName("other_backend");
assertUnsupported();
instance.getTable(CollaborationTables.ATTACHMENT).setBackendName(backend.getName());
instance.getTable(CollaborationTables.ATTACHMENT).setBackendDetails(new RDBMSTableBackendDetails().withTableName("other_attachment"));
assertUnsupported();
instance.getTable(CollaborationTables.ATTACHMENT).setBackendDetails(null);
try(Connection connection = DriverManager.getConnection("jdbc:postgresql://" + currentHost + ":" + currentPort + "/postgres" + OPTIONS, "postgres", ROOT_PASSWORD))
{
connection.setAutoCommit(false);
assertThatThrownBy(() -> PostgresJournalDatabase.requireQualifiedConnection(connection, backend)).isInstanceOf(UploadJournalException.class);
}
try(QBackendTransaction transaction = new RDBMSBackendModule().openTransaction(new InsertInput(UploadJournalTable.NAME)))
{
QContext.setTransaction(transaction);
assertThatThrownBy(() -> journal.prepare(candidate("denied"))).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.AMBIENT_TRANSACTION);
}
QContext.clearTransaction();
assertThat(count("collaboration_upload_journal")).isZero();
}
/*******************************************************************************
** Runtime settings are authoritative: weak commit durability, an unbounded or
** missing lock timeout, a stronger isolation level and a read-only default
** each refuse before a write. Role-level settings are restored in setUp.
*******************************************************************************/
@Test
void rejectsUnsafeSessionSettings() throws Exception
{
journal.prepare(candidate("baseline"));
execute("ALTER ROLE " + APP_ROLE + " SET synchronous_commit='off'");
assertUnsupported();
execute("ALTER ROLE " + APP_ROLE + " RESET synchronous_commit");
execute("ALTER ROLE " + APP_ROLE + " SET lock_timeout='60s'");
assertUnsupported();
execute("ALTER ROLE " + APP_ROLE + " RESET lock_timeout");
assertUnsupported();
execute("ALTER ROLE " + APP_ROLE + " SET lock_timeout='5s'");
execute("ALTER ROLE " + APP_ROLE + " SET default_transaction_isolation='repeatable read'");
assertUnsupported();
execute("ALTER ROLE " + APP_ROLE + " RESET default_transaction_isolation");
execute("ALTER ROLE " + APP_ROLE + " SET default_transaction_read_only=on");
assertUnsupported();
execute("ALTER ROLE " + APP_ROLE + " RESET default_transaction_read_only");
ConnectionManager.closeConnectionProviders();
assertThat(journal.prepare(candidate("restored")).id()).isPositive();
assertThat(count("collaboration_upload_journal")).isEqualTo(2);
}
/*******************************************************************************
** Physical drift is refused: an unlogged owner table (lost in crash recovery)
** and a non-deterministic collation on a journal column.
*******************************************************************************/
@Test
void rejectsUnloggedTableAndNonDeterministicCollation() throws Exception
{
execute("ALTER TABLE collaboration_attachment SET UNLOGGED");
assertUnsupported();
execute("ALTER TABLE collaboration_attachment SET LOGGED");
execute("ALTER TABLE collaboration_upload_journal SET UNLOGGED");
assertUnsupported();
execute("ALTER TABLE collaboration_upload_journal SET LOGGED");
execute("CREATE COLLATION journal_case_insensitive (provider = icu, locale = 'und-u-ks-level2', deterministic = false)");
execute("ALTER TABLE collaboration_upload_journal ALTER COLUMN created_by_user_id TYPE VARCHAR(255) COLLATE journal_case_insensitive");
assertUnsupported();
execute("ALTER TABLE collaboration_upload_journal ALTER COLUMN created_by_user_id TYPE VARCHAR(255) COLLATE \"default\"");
assertThat(journal.prepare(candidate("restored-drift")).id()).isPositive();
}
/*******************************************************************************
** A missing or partial unique index would let identities duplicate, so each is
** refused until the complete index is back. A changed candidate at the original
** location stays an immutable conflict.
*******************************************************************************/
@Test
void rejectsMissingAndPartialUniqueIndexesAndManifestConflict() throws Exception
{
UploadCandidate original = candidate("immutable");
journal.prepare(original);
UploadCandidate changed = new UploadCandidate(original.tableName(), original.recordId(), original.actorId(), "changed.pdf", original.contentType(), original.description(),
original.sizeBytes(), original.sha256(), original.bucketName(), original.storageKey(), true, null, null);
assertThatThrownBy(() -> journal.prepare(changed)).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.IMMUTABLE_CONFLICT);
execute("DROP INDEX uq_collaboration_upload_journal_locator");
assertUnsupported();
execute("CREATE UNIQUE INDEX uq_collaboration_upload_journal_locator ON collaboration_upload_journal (locator_hash) WHERE locator_hash <> ''");
assertUnsupported();
execute("DROP INDEX uq_collaboration_upload_journal_locator");
execute("CREATE UNIQUE INDEX uq_collaboration_upload_journal_locator ON collaboration_upload_journal (locator_hash)");
execute("DROP INDEX uq_collaboration_upload_journal_intake");
assertUnsupported();
execute("CREATE UNIQUE INDEX uq_collaboration_upload_journal_intake ON collaboration_upload_journal (intake_key)");
assertThat(journal.prepare(original).id()).isPositive();
assertThat(count("collaboration_upload_journal")).isEqualTo(1);
}
/*******************************************************************************
** Native generic writes cannot bypass journal ownership.
*******************************************************************************/
@Test
void nativeWritePolicyDeniesDirectInsert()
{
assertThatThrownBy(() -> new UploadJournalSecurity().checkPermissionsThrowing(new InsertInput(UploadJournalTable.NAME), instance.getTable(UploadJournalTable.NAME)))
.isInstanceOf(com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException.class);
}
/*******************************************************************************
** The actual server lock timeout (SQLSTATE 55P03) maps to BUSY and leaves no
** granted lease.
*******************************************************************************/
@Test
void lockTimeoutIsBoundedAndDoesNotGrantLease() throws Exception
{
Integer id = journal.prepare(candidate("locked")).id();
try(Connection connection = root(); PreparedStatement lock = connection.prepareStatement("SELECT id FROM collaboration_upload_journal WHERE id=? FOR UPDATE"))
{
connection.setAutoCommit(false);
lock.setInt(1, id);
try(ResultSet row = lock.executeQuery())
{
assertThat(row.next()).isTrue();
long started = System.nanoTime();
assertThatThrownBy(() -> journal.reserve(id, Duration.ofSeconds(5))).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.BUSY);
assertThat(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)).isBetween(3000L, 20000L);
}
connection.rollback();
}
assertThat(journal.find(id).orElseThrow().fence()).isZero();
assertThat(journal.reserve(id, Duration.ofSeconds(5)).fence()).isEqualTo(1L);
}
/*******************************************************************************
** Lease instants round-trip as TIMESTAMPTZ. A host whose JVM zone is not UTC
** is refused (pgjdbc would announce that zone to the session) rather than
** silently shifting leases, and the instant is exact once the zone is UTC.
*******************************************************************************/
@Test
void leaseInstantsRoundTripAndNonUtcHostIsRefused() throws Exception
{
UploadReservation reservation = verified("timezone");
assertThat(reservation.leaseUntil()).isEqualTo(START.plusSeconds(60));
TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Honolulu"));
ConnectionManager.closeConnectionProviders();
assertUnsupported();
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
ConnectionManager.closeConnectionProviders();
assertThat(journal.find(reservation.journalId()).orElseThrow().leaseUntil()).isEqualTo(START.plusSeconds(60));
assertThat(journal.renew(reservation, Duration.ofMinutes(2)).leaseUntil()).isEqualTo(START.plusSeconds(120));
}
/*******************************************************************************
** Assert a typed refusal on a fresh pooled session, so role-level setting
** changes are actually observed.
*******************************************************************************/
private void assertUnsupported()
{
ConnectionManager.closeConnectionProviders();
assertThatThrownBy(() -> journal.prepare(candidate("denied"))).isInstanceOf(UploadJournalException.class)
.extracting(e -> ((UploadJournalException) e).getReason()).isEqualTo(Reason.UNSUPPORTED_BACKEND);
}
/*******************************************************************************
** Prepare a leased manifest whose stored bytes have been verified by owner.
*******************************************************************************/
private UploadReservation verified(String label) throws Exception
{
UploadReservation reservation = journal.reserve(journal.prepare(candidate(label)).id(), Duration.ofMinutes(1));
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
return (reservation);
}
/*******************************************************************************
** Insert only through the owner's exact transaction.
*******************************************************************************/
private static Integer insertAttachment(QBackendTransaction transaction) throws QException
{
try(PreparedStatement statement = ((RDBMSTransaction) transaction).getConnection()
.prepareStatement("INSERT INTO collaboration_attachment(file_name) VALUES ('fixture.pdf')", new String[] { "id" }))
{
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys())
{
keys.next();
return (keys.getInt(1));
}
}
catch(SQLException e)
{
throw (new QException("Synthetic attachment insert failed.", e));
}
}
/*******************************************************************************
** The application's JDBC URL for the current dynamic port.
*******************************************************************************/
private static String appUrl()
{
return ("jdbc:postgresql://" + currentHost + ":" + currentPort + "/" + DATABASE + OPTIONS);
}
/*******************************************************************************
** Find the disposable server: the published loopback port by default, or (opt-in
** bridge mode) the container's own bridge address and the server port.
*******************************************************************************/
private static void locateServer()
{
if(BRIDGE_ADDRESS)
{
currentHost = postgres.getContainerInfo().getNetworkSettings().getNetworks().values().iterator().next().getIpAddress();
currentPort = 5432;
}
else
{
currentHost = "127.0.0.1";
currentPort = Integer.parseInt(postgres.getCurrentContainerInfo().getNetworkSettings().getPorts().getBindings().get(new ExposedPort(5432))[0].getHostPortSpec());
}
}
/*******************************************************************************
** Independent superuser connection for fixture DDL, fault injection and
** committed-state assertions.
*******************************************************************************/
private static Connection root() throws SQLException
{
return (DriverManager.getConnection("jdbc:postgresql://" + currentHost + ":" + currentPort + "/" + DATABASE + OPTIONS, "postgres", ROOT_PASSWORD));
}
/*******************************************************************************
** Execute fixed trusted fixture SQL, never application-supplied identifiers.
*******************************************************************************/
private void execute(String sql) throws SQLException
{
try(Connection connection = root(); Statement statement = connection.createStatement())
{
statement.execute(sql);
}
}
/*******************************************************************************
** Count a fixed owner table through a new committed connection.
*******************************************************************************/
private long count(String table) throws SQLException
{
try(Connection connection = root(); Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery("SELECT COUNT(*) FROM " + table))
{
result.next();
return (result.getLong(1));
}
}
/*******************************************************************************
** Establish the same synthetic actor independently on each test thread.
*******************************************************************************/
private void context()
{
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("alice")));
}
/*******************************************************************************
** Server-shaped immutable candidate; no real customer data or storage is used.
*******************************************************************************/
private static UploadCandidate candidate(String label)
{
return (new UploadCandidate("document", "1", "alice", label + ".pdf", "application/pdf", "Synthetic fixture", 5L, "a".repeat(64),
"fixture-bucket", "retained/" + label, true, null, null));
}
}
@@ -1,98 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Pure validation and hashing of the immutable candidate; no database.
** NOT YET RUN: written without a build.
*******************************************************************************/
public class UploadCandidateTest
{
private static final String HASH = "a".repeat(64);
/*******************************************************************************
** A server-shaped candidate with overridable pieces.
*******************************************************************************/
private static UploadCandidate make(String fileName, String description, Long size, Boolean retained, String intakeKey, String fingerprint)
{
return (new UploadCandidate("document", "7", "alice", fileName, "application/pdf", description, size, HASH, "bucket", "attachments/key", retained,
intakeKey, fingerprint));
}
/*******************************************************************************
** Only retained uploads are journaled.
*******************************************************************************/
@Test
void nonRetainedUploadsAreRejected()
{
assertThatThrownBy(() -> make("a.pdf", null, 1L, false, null, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> make("a.pdf", null, 1L, null, null, null)).isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
** Bounds, control characters, hashes and the intake pair are enforced.
*******************************************************************************/
@Test
void membersAreBounded()
{
assertThatThrownBy(() -> make("x".repeat(256), null, 1L, true, null, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> make("bad\nname", null, 1L, true, null, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> make(" ", null, 1L, true, null, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> make("a.pdf", "d".repeat(4097), 1L, true, null, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> make("a.pdf", null, -1L, true, null, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> make("a.pdf", null, UploadCandidate.MAX_SIZE_BYTES + 1, true, null, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> make("a.pdf", null, 1L, true, HASH, null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> make("a.pdf", null, 1L, true, "short", HASH)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new UploadCandidate("document", "7", "alice", "a.pdf", "application/pdf", null, 1L, "NOT-HEX", "bucket", "key", true, null, null))
.isInstanceOf(IllegalArgumentException.class);
assertThat(make("a.pdf", "line one\nline two", 0L, true, HASH, HASH).description()).contains("\n");
}
/*******************************************************************************
** The locator hash covers bucket and key only; the manifest hash covers all.
** toString never prints the bucket, key, description or actor.
*******************************************************************************/
@Test
void hashesAreStableAndRedacted()
{
UploadCandidate one = make("a.pdf", "secret description", 1L, true, null, null);
UploadCandidate sameLocationOtherName = make("b.pdf", "secret description", 1L, true, null, null);
assertThat(one.locatorHash()).hasSize(64).isEqualTo(sameLocationOtherName.locatorHash()).isEqualTo(UploadCandidate.locatorHash("bucket", "attachments/key"));
assertThat(one.manifestHash()).hasSize(64).isNotEqualTo(sameLocationOtherName.manifestHash());
assertThat(UploadCandidate.locatorHash("ab", "c")).isNotEqualTo(UploadCandidate.locatorHash("a", "bc"));
assertThat(one.toString()).doesNotContain("bucket", "attachments/key", "secret description", "alice").contains(one.locatorHash());
}
}
@@ -1,703 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.collaboration.attachments.uploadjournal;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import com.kingsrook.qbits.collaboration.CollaborationQBitConfig;
import com.kingsrook.qbits.collaboration.attachments.uploadjournal.UploadJournalException.Reason;
import com.kingsrook.qbits.collaboration.model.CollaborationTables;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.rdbms.actions.RDBMSTransaction;
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Real file-backed H2 (WRITE_DELAY=0), real separate JDBC connections and the
** real QQQ RDBMS transaction. Only the attachment table is a synthetic stand-in
** (id and file_name) registered manually; no existing fixture is changed.
**
** These regressions accompany the separate opt-in real MySQL provider suite.
*******************************************************************************/
public class UploadJournalTest
{
private static final String ATTACHMENT_DDL = "CREATE TABLE collaboration_attachment (id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, file_name VARCHAR(255))";
@TempDir
Path directory;
private QInstance instance;
private RDBMSBackendMetaData database;
private MutableClock clock;
private JdbcUploadJournal journal;
/*******************************************************************************
** A fresh file database with the journal and a stand-in attachment table.
*******************************************************************************/
@BeforeEach
void setUp() throws Exception
{
ConnectionManager.resetConnectionProviders();
database = fileBackend("journal_" + UUID.randomUUID(), "journal", ";WRITE_DELAY=0");
instance = instanceFor(database, database);
initContext(instance);
try(Connection connection = DriverManager.getConnection(database.getJdbcUrl(), "sa", ""); Statement statement = connection.createStatement())
{
for(String sql : UploadJournalTable.h2Script().split(";"))
{
if(!sql.isBlank())
{
statement.execute(sql);
}
}
statement.execute(ATTACHMENT_DDL);
}
clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
journal = new JdbcUploadJournal(clock);
}
/*******************************************************************************
** No context or pooled connection survives a test.
*******************************************************************************/
@AfterEach
void tearDown()
{
QContext.clearTransaction();
QContext.clear();
ConnectionManager.resetConnectionProviders();
}
/*******************************************************************************
** Committed PREPARED data survives a crashed lock holder and a full database
** shutdown and reopen; nothing uncommitted leaks.
*******************************************************************************/
@Test
void preparedCandidateSurvivesCrashStyleReopen() throws Exception
{
UploadCandidate candidate = candidate("one");
UploadEntry prepared = journal.prepare(candidate);
assertThat(prepared.state()).isEqualTo(UploadJournalState.PREPARED);
assertThat(prepared.storageState()).isEqualTo(UploadStorageState.PENDING);
try(Connection crashing = DriverManager.getConnection(database.getJdbcUrl(), "sa", ""); Statement statement = crashing.createStatement())
{
crashing.setAutoCommit(false);
statement.execute("UPDATE collaboration_upload_journal SET fence = 99 WHERE id = " + prepared.id());
// closed with the row locked and never committed: the crash
}
ConnectionManager.resetConnectionProviders();
try(Connection connection = DriverManager.getConnection(database.getJdbcUrl(), "sa", ""); Statement statement = connection.createStatement())
{
statement.execute("SHUTDOWN");
}
ConnectionManager.resetConnectionProviders();
JdbcUploadJournal reopened = new JdbcUploadJournal(clock);
UploadEntry found = reopened.find(prepared.id()).orElseThrow();
assertThat(found.candidate()).isEqualTo(candidate);
assertThat(found.state()).isEqualTo(UploadJournalState.PREPARED);
assertThat(found.fence()).isZero();
assertThat(reopened.listIncomplete(10)).extracting(UploadEntry::id).containsExactly(prepared.id());
assertThat(reopened.findByLocation("bucket", "attachments/one")).isPresent();
assertThat(reopened.reserve(prepared.id(), Duration.ofMinutes(1)).fence()).isEqualTo(1L);
}
/*******************************************************************************
** A failure after the attachment insert rolls back the insert and the
** checkpoint together; a clean retry then registers exactly once.
*******************************************************************************/
@Test
void failedMetadataInsertRollsBackAttachmentAndCheckpoint() throws Exception
{
UploadReservation reservation = verified(candidate("rollback"));
AtomicInteger calls = new AtomicInteger();
UploadJournal.AttachmentRegistrar failing = (transaction, candidate, journalId) ->
{
insertAttachment(transaction, candidate);
calls.incrementAndGet();
throw (new IllegalStateException("simulated failure after insert"));
};
assertThatThrownBy(() -> journal.register(reservation, failing)).isInstanceOf(IllegalStateException.class);
assertThat(count("SELECT COUNT(*) FROM collaboration_attachment")).isZero();
UploadEntry afterFailure = journal.find(reservation.journalId()).orElseThrow();
assertThat(afterFailure.state()).isEqualTo(UploadJournalState.PREPARED);
assertThat(afterFailure.attachmentId()).isNull();
assertThat(afterFailure.storageState()).isEqualTo(UploadStorageState.VERIFIED);
UploadEntry registered = journal.register(reservation, inserting(calls));
assertThat(registered.state()).isEqualTo(UploadJournalState.REGISTERED);
assertThat(registered.attachmentId()).isNotNull();
assertThat(count("SELECT COUNT(*) FROM collaboration_attachment")).isEqualTo(1);
assertThat(count("SELECT COUNT(*) FROM collaboration_attachment WHERE id = " + registered.attachmentId())).isEqualTo(1);
}
/*******************************************************************************
** Unverified original storage is never registered, and the callback never runs.
*******************************************************************************/
@Test
void registrationRequiresVerifiedStorage() throws Exception
{
UploadEntry prepared = journal.prepare(candidate("unverified"));
UploadReservation reservation = journal.reserve(prepared.id(), Duration.ofMinutes(1));
journal.recordStorage(reservation, UploadStorageState.UNKNOWN);
AtomicInteger calls = new AtomicInteger();
assertThatThrownBy(() -> journal.register(reservation, inserting(calls))).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.STORAGE_NOT_VERIFIED));
assertThat(calls).hasValue(0);
assertThat(count("SELECT COUNT(*) FROM collaboration_attachment")).isZero();
assertThatThrownBy(() -> journal.recordStorage(reservation, UploadStorageState.PENDING)).isInstanceOf(UploadJournalException.class);
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
assertThatThrownBy(() -> journal.recordStorage(reservation, UploadStorageState.UNKNOWN)).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.ILLEGAL_STATE));
}
/*******************************************************************************
** An expired or superseded holder cannot mutate; a newer fence takes over.
*******************************************************************************/
@Test
void staleLeaseIsRejected() throws Exception
{
UploadEntry prepared = journal.prepare(candidate("stale"));
UploadReservation first = journal.reserve(prepared.id(), Duration.ofMinutes(1));
assertThatThrownBy(() -> journal.reserve(prepared.id(), Duration.ofMinutes(1))).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.LEASE_HELD));
clock.advance(Duration.ofMinutes(2));
assertThatThrownBy(() -> journal.recordStorage(first, UploadStorageState.UNKNOWN)).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.STALE_RESERVATION));
UploadReservation second = journal.reserve(prepared.id(), Duration.ofMinutes(1));
assertThat(second.fence()).isEqualTo(2L);
AtomicInteger calls = new AtomicInteger();
assertThatThrownBy(() -> journal.recordStorage(first, UploadStorageState.VERIFIED)).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.STALE_RESERVATION));
assertThatThrownBy(() -> journal.register(first, inserting(calls))).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.STALE_RESERVATION));
assertThatThrownBy(() -> journal.complete(first)).isInstanceOf(UploadJournalException.class);
assertThat(journal.release(first)).isFalse();
assertThat(calls).hasValue(0);
assertThat(count("SELECT COUNT(*) FROM collaboration_attachment")).isZero();
journal.recordStorage(second, UploadStorageState.VERIFIED);
assertThat(journal.release(second)).isTrue();
assertThat(journal.find(prepared.id()).orElseThrow().leaseUntil()).isNull();
assertThat(journal.reserve(prepared.id(), Duration.ofMinutes(1)).fence()).isEqualTo(3L);
}
/*******************************************************************************
** Renewal extends only the live exact holder and cannot revive expired work.
*******************************************************************************/
@Test
void renewPreservesFenceAndRefusesExpiredOrReplacedHolders() throws Exception
{
UploadEntry prepared = journal.prepare(candidate("renew"));
UploadReservation first = journal.reserve(prepared.id(), Duration.ofMinutes(1));
clock.advance(Duration.ofSeconds(45));
UploadReservation renewed = journal.renew(first, Duration.ofMinutes(1));
assertThat(renewed.fence()).isEqualTo(first.fence());
clock.advance(Duration.ofSeconds(30));
journal.recordStorage(renewed, UploadStorageState.VERIFIED);
clock.advance(Duration.ofMinutes(1));
assertThatThrownBy(() -> journal.renew(renewed, Duration.ofMinutes(1))).isInstanceOfSatisfying(UploadJournalException.class,
error -> assertThat(error.getReason()).isEqualTo(Reason.STALE_RESERVATION));
UploadReservation replacement = journal.reserve(prepared.id(), Duration.ofMinutes(1));
assertThat(replacement.fence()).isGreaterThan(first.fence());
assertThatThrownBy(() -> journal.renew(first, Duration.ofMinutes(1))).isInstanceOf(UploadJournalException.class);
assertThatThrownBy(() -> journal.renew(replacement, Duration.ZERO)).isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
** UTF-8 replacement must not alias distinct malformed manifests into one hash.
*******************************************************************************/
@Test
void rejectsUnpairedSurrogatesBeforeManifestHashing()
{
UploadCandidate valid = candidate("unicode");
for(String malformed : List.of("\uD800", "\uDC00", "x\uD800y"))
{
assertThatThrownBy(() -> new UploadCandidate(valid.tableName(), valid.recordId(), valid.actorId(), malformed,
valid.contentType(), valid.description(), valid.sizeBytes(), valid.sha256(), valid.bucketName(), valid.storageKey(), true, null, null))
.isInstanceOf(IllegalArgumentException.class).hasMessageContaining("malformed Unicode");
}
}
/*******************************************************************************
** Many real connections race for one reservation; exactly one wins.
*******************************************************************************/
@Test
void concurrentClaimsGrantExactlyOneReservation() throws Exception
{
UploadEntry prepared = journal.prepare(candidate("race"));
List<Object> outcomes = race(8, () -> journal.reserve(prepared.id(), Duration.ofMinutes(1)));
List<Object> winners = outcomes.stream().filter(UploadReservation.class::isInstance).toList();
assertThat(winners).hasSize(1);
for(Object outcome : outcomes)
{
if(!(outcome instanceof UploadReservation))
{
assertThat(outcome).isInstanceOf(UploadJournalException.class);
assertThat(((UploadJournalException) outcome).getReason()).isIn(Reason.LEASE_HELD, Reason.BUSY);
}
}
assertThat(journal.find(prepared.id()).orElseThrow().fence()).isEqualTo(1L);
}
/*******************************************************************************
** Concurrent identical prepares converge on one row; differing manifests,
** altered rows and reused intake keys are immutable conflicts.
*******************************************************************************/
@Test
void immutableManifestAndUniqueRaceRecovery() throws Exception
{
UploadCandidate candidate = candidate("immutable");
List<Object> outcomes = race(6, () -> journal.prepare(candidate));
Set<Integer> ids = new HashSet<>();
for(Object outcome : outcomes)
{
assertThat(outcome).isInstanceOf(UploadEntry.class);
ids.add(((UploadEntry) outcome).id());
}
assertThat(ids).hasSize(1);
assertThat(count("SELECT COUNT(*) FROM collaboration_upload_journal")).isEqualTo(1);
assertThat(journal.prepare(candidate).id()).isEqualTo(ids.iterator().next());
UploadCandidate changed = new UploadCandidate(candidate.tableName(), candidate.recordId(), "mallory", candidate.fileName(), candidate.contentType(),
candidate.description(), candidate.sizeBytes(), candidate.sha256(), candidate.bucketName(), candidate.storageKey(), true, null, null);
assertThatThrownBy(() -> journal.prepare(changed)).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.IMMUTABLE_CONFLICT));
String intake = "b".repeat(64);
journal.prepare(withIntake(candidate("intake-a"), intake));
assertThatThrownBy(() -> journal.prepare(withIntake(candidate("intake-b"), intake))).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.IMMUTABLE_CONFLICT));
try(Connection connection = DriverManager.getConnection(database.getJdbcUrl(), "sa", ""); Statement statement = connection.createStatement())
{
statement.execute("UPDATE collaboration_upload_journal SET file_name = 'evil.pdf' WHERE id = " + ids.iterator().next());
}
assertThatThrownBy(() -> journal.find(ids.iterator().next())).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.IMMUTABLE_CONFLICT));
}
/*******************************************************************************
** COMPLETE only follows registration; a registered row recovers without a
** second attachment once the lifecycle callback is retried.
*******************************************************************************/
@Test
void lifecycleCompletionCheckpointAndIdempotentRecovery() throws Exception
{
UploadReservation reservation = verified(candidate("lifecycle"));
AtomicInteger calls = new AtomicInteger();
assertThatThrownBy(() -> journal.complete(reservation)).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.ILLEGAL_STATE));
UploadEntry registered = journal.register(reservation, inserting(calls));
assertThat(registered.state()).isEqualTo(UploadJournalState.REGISTERED);
assertThat(journal.register(reservation, inserting(calls)).attachmentId()).isEqualTo(registered.attachmentId());
assertThat(calls).hasValue(1);
// The post-commit lifecycle callback failed: free the lease and recover later.
assertThat(journal.release(reservation)).isTrue();
UploadReservation recovery = journal.reserve(reservation.journalId(), Duration.ofMinutes(1));
assertThat(journal.register(recovery, inserting(calls)).attachmentId()).isEqualTo(registered.attachmentId());
assertThat(calls).hasValue(1);
assertThat(count("SELECT COUNT(*) FROM collaboration_attachment")).isEqualTo(1);
UploadEntry complete = journal.complete(recovery);
assertThat(complete.state()).isEqualTo(UploadJournalState.COMPLETE);
assertThat(complete.leaseUntil()).isNull();
assertThat(count("SELECT COUNT(*) FROM collaboration_upload_journal WHERE lease_token IS NULL AND state = 'COMPLETE'")).isEqualTo(1);
assertThat(journal.complete(recovery).state()).isEqualTo(UploadJournalState.COMPLETE);
assertThat(journal.listIncomplete(10)).isEmpty();
assertThatThrownBy(() -> journal.reserve(reservation.journalId(), Duration.ofMinutes(1))).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.ILLEGAL_STATE));
}
/*******************************************************************************
** Memory, delayed-write, mismatched and ambient-transaction setups are refused.
*******************************************************************************/
@Test
void unsupportedBackendsAndAmbientTransactionsAreRejected() throws Exception
{
RDBMSBackendMetaData memory = fileBackend("memory_" + UUID.randomUUID(), "unused", ";WRITE_DELAY=0");
memory.setJdbcUrl("jdbc:h2:mem:" + UUID.randomUUID() + ";WRITE_DELAY=0");
assertThat(JdbcUploadJournal.isQualifiedBackend(memory)).isFalse();
assertThat(JdbcUploadJournal.isQualifiedBackend(database)).isTrue();
initContext(instanceFor(memory, memory));
assertThatThrownBy(() -> journal.find(1)).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.UNSUPPORTED_BACKEND));
RDBMSBackendMetaData delayed = fileBackend("delayed_" + UUID.randomUUID(), "delayed", "");
assertThat(JdbcUploadJournal.isQualifiedBackend(delayed)).isTrue();
initContext(instanceFor(delayed, delayed));
assertThatThrownBy(() -> journal.find(1)).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.UNSUPPORTED_BACKEND));
RDBMSBackendMetaData other = fileBackend("other_" + UUID.randomUUID(), "other", ";WRITE_DELAY=0");
initContext(instanceFor(database, other));
assertThatThrownBy(() -> journal.find(1)).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.UNSUPPORTED_BACKEND));
initContext(instance);
QContext.setTransaction(new QBackendTransaction());
assertThatThrownBy(() -> journal.prepare(candidate("ambient"))).isInstanceOfSatisfying(UploadJournalException.class,
e -> assertThat(e.getReason()).isEqualTo(Reason.AMBIENT_TRANSACTION));
QContext.clearTransaction();
assertThat(count("SELECT COUNT(*) FROM collaboration_upload_journal")).isZero();
}
/*******************************************************************************
** Metadata exposes provenance and status but no raw locator or lease secret,
** and denies every direct write.
*******************************************************************************/
@Test
void metadataHidesLocatorAndDeniesDirectWrites() throws Exception
{
QTableMetaData table = UploadJournalTable.metaData(new CollaborationQBitConfig().withBackendName("backend"));
assertThat(table.getName()).isEqualTo(UploadJournalTable.NAME);
assertThat(table.getFields()).containsKeys("tableName", "recordId", "createdByUserId", "sha256", "state", "storageState", "attachmentId", "locatorHash");
assertThat(table.getFields()).doesNotContainKeys("bucketName", "storageKey", "leaseToken", "manifestHash");
assertThat(table.getFields().values()).allMatch(field -> Boolean.FALSE.equals(field.getIsEditable()));
UploadJournalSecurity security = new UploadJournalSecurity();
assertThatThrownBy(() -> security.checkPermissionsThrowing(new InsertInput(UploadJournalTable.NAME), table)).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> security.checkPermissionsThrowing(new UpdateInput(UploadJournalTable.NAME), table)).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> security.checkPermissionsThrowing(new DeleteInput(UploadJournalTable.NAME), table)).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Prepare a candidate and bring it to a live reservation with VERIFIED storage.
*******************************************************************************/
private UploadReservation verified(UploadCandidate candidate) throws QException
{
UploadEntry prepared = journal.prepare(candidate);
UploadReservation reservation = journal.reserve(prepared.id(), Duration.ofMinutes(5));
journal.recordStorage(reservation, UploadStorageState.UNKNOWN);
journal.recordStorage(reservation, UploadStorageState.VERIFIED);
return (reservation);
}
/*******************************************************************************
** Run the same call on N threads, each with its own QQQ context and connection.
** Each outcome is the result or the thrown exception.
*******************************************************************************/
private List<Object> race(int threads, Callable<Object> call) throws Exception
{
ExecutorService pool = Executors.newFixedThreadPool(threads);
try
{
CountDownLatch start = new CountDownLatch(1);
List<Future<Object>> futures = new ArrayList<>();
for(int i = 0; i < threads; i++)
{
futures.add(pool.submit(() ->
{
initContext(instance);
start.await();
try
{
return (call.call());
}
catch(UploadJournalException e)
{
return (e);
}
}));
}
start.countDown();
List<Object> outcomes = new ArrayList<>();
for(Future<Object> future : futures)
{
outcomes.add(future.get(60, TimeUnit.SECONDS));
}
return (outcomes);
}
finally
{
pool.shutdownNow();
}
}
/*******************************************************************************
** A registrar that inserts a stand-in attachment row in the journal's transaction.
*******************************************************************************/
private UploadJournal.AttachmentRegistrar inserting(AtomicInteger calls)
{
return ((transaction, candidate, journalId) ->
{
calls.incrementAndGet();
return (insertAttachment(transaction, candidate));
});
}
/*******************************************************************************
** Insert through the supplied transaction's own connection and return the id.
*******************************************************************************/
private static Integer insertAttachment(QBackendTransaction transaction, UploadCandidate candidate) throws QException
{
try(PreparedStatement statement = ((RDBMSTransaction) transaction).getConnection()
.prepareStatement("INSERT INTO collaboration_attachment (file_name) VALUES (?)", Statement.RETURN_GENERATED_KEYS))
{
statement.setString(1, candidate.fileName());
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys())
{
keys.next();
return (keys.getInt(1));
}
}
catch(SQLException e)
{
throw (new QException("Could not insert the stand-in attachment.", e));
}
}
/*******************************************************************************
** Count rows using a separate real connection, so only committed data counts.
*******************************************************************************/
private long count(String sql) throws SQLException
{
try(Connection connection = DriverManager.getConnection(database.getJdbcUrl(), "sa", ""); Statement statement = connection.createStatement();
ResultSet result = statement.executeQuery(sql))
{
result.next();
return (result.getLong(1));
}
}
/*******************************************************************************
** A server-shaped retained candidate whose location is unique to the label.
*******************************************************************************/
private static UploadCandidate candidate(String label)
{
return (new UploadCandidate("document", "1", "alice", "scan-" + label + ".pdf", "application/pdf", "A scanned form", 5L, "a".repeat(64),
"bucket", "attachments/" + label, true, null, null));
}
/*******************************************************************************
** The same candidate carrying an intake key and fingerprint.
*******************************************************************************/
private static UploadCandidate withIntake(UploadCandidate base, String intakeKey)
{
return (new UploadCandidate(base.tableName(), base.recordId(), base.actorId(), base.fileName(), base.contentType(), base.description(),
base.sizeBytes(), base.sha256(), base.bucketName(), base.storageKey(), true, intakeKey, "c".repeat(64)));
}
/*******************************************************************************
** An H2 file backend; settings are appended to the URL verbatim.
*******************************************************************************/
private RDBMSBackendMetaData fileBackend(String name, String file, String settings)
{
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName(name).withVendor("h2").withHostName("mem").withDatabaseName(file).withUsername("sa");
backend.setJdbcUrl("jdbc:h2:file:" + directory.resolve(file).toAbsolutePath().toString().replace('\\', '/') + ";DATABASE_TO_UPPER=FALSE;LOCK_TIMEOUT=5000" + settings);
return (backend);
}
/*******************************************************************************
** A synthetic instance with the journal and attachment tables registered on
** the given backends.
*******************************************************************************/
private QInstance instanceFor(RDBMSBackendMetaData journalBackend, RDBMSBackendMetaData attachmentBackend)
{
QInstance built = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
built.addBackend(journalBackend);
if(!attachmentBackend.getName().equals(journalBackend.getName()))
{
built.addBackend(attachmentBackend);
}
built.addTable(stand(UploadJournalTable.NAME, journalBackend));
built.addTable(stand(CollaborationTables.ATTACHMENT, attachmentBackend));
return (built);
}
/*******************************************************************************
** Minimal table registration; the journal itself uses only fixed SQL.
*******************************************************************************/
private static QTableMetaData stand(String name, RDBMSBackendMetaData backend)
{
return (new QTableMetaData().withName(name).withBackendName(backend.getName()).withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
}
/*******************************************************************************
** Bind a QQQ context with a signed-in user on the current thread.
*******************************************************************************/
private static void initContext(QInstance context)
{
QContext.init(context, new QSession().withUser(new QUser().withIdReference("alice")));
}
/*******************************************************************************
** A clock a test can move forward, safe across threads.
*******************************************************************************/
private static final class MutableClock extends Clock
{
private final AtomicReference<Instant> now;
/*******************************************************************************
** Start at the given instant.
*******************************************************************************/
MutableClock(Instant start)
{
this.now = new AtomicReference<>(start);
}
/*******************************************************************************
** Move time forward.
*******************************************************************************/
void advance(Duration duration)
{
now.updateAndGet(current -> current.plus(duration));
}
/*******************************************************************************
** UTC.
*******************************************************************************/
@Override
public ZoneId getZone()
{
return (ZoneOffset.UTC);
}
/*******************************************************************************
** Fixed zone only.
*******************************************************************************/
@Override
public Clock withZone(ZoneId zone)
{
return (this);
}
/*******************************************************************************
** Current test time.
*******************************************************************************/
@Override
public Instant instant()
{
return (now.get());
}
}
}