Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Automatic temporal extent calculation for collections. When serving collections via the `/collections`
and `/collections/{collectionId}` endpoints, if a collection does not have a temporal extent defined,
the server will automatically calculate it from the earliest and latest items in the collection. To use
this feature, simply omit the `extent.temporal.interval` field when ingesting a collection.

## [4.4.0] - 2025-09-10

## Changed
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1203,16 +1203,20 @@ ingestion will either fail (in the case of a single Item ingest) or if auto-crea
If a collection or item is ingested, and an item with that id already exists in STAC, the new item will completely replace the old item, except the `created` property will be retained and the `updated` property updated
to match the time of the new update.

After a collection or item is ingested, the status of the ingest (success or failure) along with details of the collection or item are sent to a post-ingest SNS topic. To take action on items after they are ingested subscribe an endpoint to this topic.

Messages published to the post-ingest SNS topic include the following atributes that can be used for filtering:
Messages published to the post-ingest SNS topic include the following attributes that can be used for filtering:

| attribute | type | values |
| ------------ | ------ | ------------------------ |
| recordType | String | `Collection` or `Item` |
| ingestStatus | String | `successful` or `failed` |
| collection | String | |

### Automatic Temporal Extent

When ingesting Collections, the `extent.temporal.interval` field can be omitted to enable automatic temporal extent calculation. When a collection is requested via the API, if it doesn't have a temporal extent defined, stac-server will automatically calculate it by finding the earliest and latest `datetime` values from the items in that collection. Collections with no items will have a temporal extent of `[[null, null]]`. This feature allows temporal extents to stay current as items are added or removed without requiring manual collection updates. The temporal extent is calculated dynamically each time the collection is requested, so it automatically reflects the current state of items without requiring collection updates or persisting changes to the collection document.

After a collection or item is ingested, the status of the ingest (success or failure) along with details of the collection or item are sent to a post-ingest SNS topic. To take action on items after they are ingested subscribe an endpoint to this topic.

### Ingest actions

In addition to ingesting Item and Collection JSON, the ingestion pipeline can also execute
Expand Down
35 changes: 35 additions & 0 deletions src/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,34 @@ const deleteUnusedFields = (collection) => {
delete collection.aggregations
}

/**
* Populate temporal extent for a collection from its items if not already defined
* @param {Object} backend - Database backend
* @param {Object} collection - Collection object
* @returns {Promise<void>}
*/
const populateTemporalExtentIfMissing = async (backend, collection) => {
const id = collection.id

// Check if collection already has a temporal extent defined
const hasTemporalExtent = collection.extent?.temporal?.interval?.[0]?.[0] !== undefined
|| collection.extent?.temporal?.interval?.[0]?.[1] !== undefined
Comment on lines +1266 to +1267
Copy link

Copilot AI Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is insufficient to determine if temporal extent is defined. A collection with extent.temporal.interval set to [[null, null]] will fail this check (both values are not undefined), but this should be treated as missing temporal extent since null values indicate no data. The check should verify that at least one of the values is non-null: collection.extent?.temporal?.interval?.[0]?.[0] || collection.extent?.temporal?.interval?.[0]?.[1]

Suggested change
const hasTemporalExtent = collection.extent?.temporal?.interval?.[0]?.[0] !== undefined
|| collection.extent?.temporal?.interval?.[0]?.[1] !== undefined
const start = collection.extent?.temporal?.interval?.[0]?.[0];
const end = collection.extent?.temporal?.interval?.[0]?.[1];
const hasTemporalExtent = start != null || end != null;

Copilot uses AI. Check for mistakes.

if (!hasTemporalExtent) {
const temporalExtent = await backend.getTemporalExtentFromItems(id)
if (temporalExtent) {
// Initialize extent structure if it doesn't exist
if (!collection.extent) {
collection.extent = {}
}
if (!collection.extent.temporal) {
collection.extent.temporal = {}
}
collection.extent.temporal.interval = temporalExtent
}
}
}

const getCollections = async function (backend, endpoint, parameters, headers) {
// TODO: implement proper pagination, as this will only return up to
// COLLECTION_LIMIT collections
Expand All @@ -1266,6 +1294,10 @@ const getCollections = async function (backend, endpoint, parameters, headers) {
(c) => isCollectionIdAllowed(allowedCollectionIds, c.id)
)

// Populate temporal extent for each collection from items only if not already defined
await Promise.all(collections.map((collection) =>
populateTemporalExtentIfMissing(backend, collection)))

for (const collection of collections) {
deleteUnusedFields(collection)
}
Expand Down Expand Up @@ -1311,6 +1343,9 @@ const getCollection = async function (backend, collectionId, endpoint, parameter
return new NotFoundError()
}

// Populate temporal extent from items only if not already defined
await populateTemporalExtentIfMissing(backend, result)

deleteUnusedFields(result)

const col = addCollectionLinks([result], endpoint)
Expand Down
66 changes: 65 additions & 1 deletion src/lib/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,69 @@ async function healthCheck() {
return client.cat.health()
}

/**
* Calculate temporal extent for a collection by finding the earliest and latest items
* @param {string} collectionId - The collection ID
* @returns {Promise<Array|null>} Returns [[startDate, endDate]] or null if no items/datetime
*/
async function getTemporalExtentFromItems(collectionId) {
try {
const client = await _client()
if (client === undefined) throw new Error('Client is undefined')

// Get earliest item by sorting ascending
const minParams = await constructSearchParams(
{ collections: [collectionId] },
undefined,
1 // Only need the first item
)
minParams.body.sort = [{ 'properties.datetime': { order: 'asc' } }]
minParams.body._source = ['properties.datetime']

// Get latest item by sorting descending
const maxParams = await constructSearchParams(
{ collections: [collectionId] },
undefined,
1 // Only need the first item
)
maxParams.body.sort = [{ 'properties.datetime': { order: 'desc' } }]
maxParams.body._source = ['properties.datetime']

// Execute both queries in parallel
const [minResponse, maxResponse] = await Promise.all([
client.search({
ignore_unavailable: true,
allow_no_indices: true,
...minParams
}),
client.search({
ignore_unavailable: true,
allow_no_indices: true,
...maxParams
})
])

const minItem = minResponse.body.hits.hits[0]?._source
const maxItem = maxResponse.body.hits.hits[0]?._source

// If no items or no datetime values, return [[null, null]]
if (!minItem?.properties?.datetime || !maxItem?.properties?.datetime) {
return [[null, null]]
}

const startDate = minItem.properties.datetime
const endDate = maxItem.properties.datetime

return [[startDate, endDate]]
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logger.error(
`Error calculating temporal extent for collection ${collectionId}: ${errorMessage}`
)
return null
}
}

export default {
getCollections,
getCollection,
Expand All @@ -1042,5 +1105,6 @@ export default {
aggregate,
constructSearchParams,
buildDatetimeQuery,
healthCheck
healthCheck,
getTemporalExtentFromItems
}
150 changes: 150 additions & 0 deletions tests/system/test-api-temporal-extent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// @ts-nocheck

import test from 'ava'
import { deleteAllIndices, refreshIndices } from '../helpers/database.js'
import { ingestItem } from '../helpers/ingest.js'
import { randomId, loadFixture } from '../helpers/utils.js'
import { setup } from '../helpers/system-tests.js'

test.before(async (t) => {
await deleteAllIndices()
const standUpResult = await setup()

t.context = standUpResult
t.context.collectionId = randomId('collection')

const collection = await loadFixture(
'landsat-8-l1-collection.json',
{ id: t.context.collectionId }
)

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: collection
})

// Ingest items with different dates
const item1 = await loadFixture('stac/LC80100102015002LGN00.json', {
collection: t.context.collectionId,
properties: {
datetime: '2015-01-02T15:49:05.000Z'
}
})

const item2 = await loadFixture('stac/LC80100102015002LGN00.json', {
collection: t.context.collectionId,
id: 'item-2',
properties: {
datetime: '2020-06-15T10:30:00.000Z'
}
})

const item3 = await loadFixture('stac/LC80100102015002LGN00.json', {
collection: t.context.collectionId,
id: 'item-3',
properties: {
datetime: '2018-03-20T08:15:00.000Z'
}
})

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: item1
})

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: item2
})

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: item3
})

await refreshIndices()
})

test.after.always(async (t) => {
if (t.context.api) await t.context.api.close()
})

test('GET /collections/:collectionId returns temporal extent from items', async (t) => {
const { collectionId } = t.context

const response = await t.context.api.client.get(`collections/${collectionId}`,
{ resolveBodyOnly: false })

t.is(response.statusCode, 200)
t.is(response.body.id, collectionId)

// Check that extent.temporal.interval exists and is populated
t.truthy(response.body.extent)
t.truthy(response.body.extent.temporal)
t.truthy(response.body.extent.temporal.interval)
t.is(response.body.extent.temporal.interval.length, 1)

const [startDate, endDate] = response.body.extent.temporal.interval[0]

// Verify the start date is the earliest item datetime (2015-01-02)
t.is(startDate, '2015-01-02T15:49:05.000Z')

// Verify the end date is the latest item datetime (2020-06-15)
t.is(endDate, '2020-06-15T10:30:00.000Z')
})

test('GET /collections returns temporal extent for all collections', async (t) => {
const response = await t.context.api.client.get('collections',
{ resolveBodyOnly: false })

t.is(response.statusCode, 200)
t.truthy(response.body.collections)
t.true(response.body.collections.length > 0)

// Find our test collection
const collection = response.body.collections.find((c) => c.id === t.context.collectionId)
t.truthy(collection)

// Check that extent.temporal.interval exists and is populated
t.truthy(collection.extent)
t.truthy(collection.extent.temporal)
t.truthy(collection.extent.temporal.interval)
t.is(collection.extent.temporal.interval.length, 1)

const [startDate, endDate] = collection.extent.temporal.interval[0]

// Verify the dates match the items
t.is(startDate, '2015-01-02T15:49:05.000Z')
t.is(endDate, '2020-06-15T10:30:00.000Z')
})

test('Collection with no items has null temporal extent', async (t) => {
// Create a new collection with no items
const emptyCollectionId = randomId('empty-collection')
const emptyCollection = await loadFixture(
'landsat-8-l1-collection.json',
{ id: emptyCollectionId }
)

await ingestItem({
ingestQueueUrl: t.context.ingestQueueUrl,
ingestTopicArn: t.context.ingestTopicArn,
item: emptyCollection
})

await refreshIndices()

const response = await t.context.api.client.get(`collections/${emptyCollectionId}`,
{ resolveBodyOnly: false })

t.is(response.statusCode, 200)
t.is(response.body.id, emptyCollectionId)

// For a collection with no items, temporal extent should still exist from the original collection
// but our code should gracefully handle this (return null or keep original)
t.truthy(response.body.extent)
})
Loading