Skip to content

Commit f60be36

Browse files
committed
Automatically populate a collections temporal extent when retrieving (if not provided on ingest)
1 parent c999648 commit f60be36

File tree

5 files changed

+266
-3
lines changed

5 files changed

+266
-3
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- Automatic temporal extent calculation for collections. When serving collections via the `/collections`
13+
and `/collections/{collectionId}` endpoints, if a collection does not have a temporal extent defined,
14+
the server will automatically calculate it from the earliest and latest items in the collection. To use
15+
this feature, simply omit the `extent.temporal.interval` field when ingesting a collection.
16+
817
## [4.4.0] - 2025-09-10
918

1019
## Changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,8 +1203,6 @@ ingestion will either fail (in the case of a single Item ingest) or if auto-crea
12031203
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
12041204
to match the time of the new update.
12051205

1206-
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.
1207-
12081206
Messages published to the post-ingest SNS topic include the following atributes that can be used for filtering:
12091207

12101208
| attribute | type | values |
@@ -1213,6 +1211,12 @@ Messages published to the post-ingest SNS topic include the following atributes
12131211
| ingestStatus | String | `successful` or `failed` |
12141212
| collection | String | |
12151213

1214+
### Automatic Temporal Extent
1215+
1216+
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.
1217+
1218+
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.
1219+
12161220
### Ingest actions
12171221

12181222
In addition to ingesting Item and Collection JSON, the ingestion pipeline can also execute

src/lib/api.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,35 @@ const deleteUnusedFields = (collection) => {
12531253
delete collection.aggregations
12541254
}
12551255

1256+
/**
1257+
* Populate temporal extent for a collection from its items if not already defined
1258+
* @param {Object} backend - Database backend
1259+
* @param {Object} collection - Collection object to populate
1260+
* @param {string} [collectionId] - Collection ID (defaults to collection.id)
1261+
* @returns {Promise<void>}
1262+
*/
1263+
const populateTemporalExtentIfMissing = async (backend, collection, collectionId = undefined) => {
1264+
const id = collectionId || collection.id
1265+
1266+
// Check if collection already has a temporal extent defined
1267+
const hasTemporalExtent = collection.extent?.temporal?.interval?.[0]?.[0] !== undefined
1268+
|| collection.extent?.temporal?.interval?.[0]?.[1] !== undefined
1269+
1270+
if (!hasTemporalExtent) {
1271+
const temporalExtent = await backend.getTemporalExtentFromItems(id)
1272+
if (temporalExtent) {
1273+
// Initialize extent structure if it doesn't exist
1274+
if (!collection.extent) {
1275+
collection.extent = {}
1276+
}
1277+
if (!collection.extent.temporal) {
1278+
collection.extent.temporal = {}
1279+
}
1280+
collection.extent.temporal.interval = temporalExtent
1281+
}
1282+
}
1283+
}
1284+
12561285
const getCollections = async function (backend, endpoint, parameters, headers) {
12571286
// TODO: implement proper pagination, as this will only return up to
12581287
// COLLECTION_LIMIT collections
@@ -1266,6 +1295,10 @@ const getCollections = async function (backend, endpoint, parameters, headers) {
12661295
(c) => isCollectionIdAllowed(allowedCollectionIds, c.id)
12671296
)
12681297

1298+
// Populate temporal extent for each collection from items only if not already defined
1299+
await Promise.all(collections.map((collection) =>
1300+
populateTemporalExtentIfMissing(backend, collection, undefined)))
1301+
12691302
for (const collection of collections) {
12701303
deleteUnusedFields(collection)
12711304
}
@@ -1311,6 +1344,9 @@ const getCollection = async function (backend, collectionId, endpoint, parameter
13111344
return new NotFoundError()
13121345
}
13131346

1347+
// Populate temporal extent from items only if not already defined
1348+
await populateTemporalExtentIfMissing(backend, result, collectionId)
1349+
13141350
deleteUnusedFields(result)
13151351

13161352
const col = addCollectionLinks([result], endpoint)

src/lib/database.js

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,69 @@ async function healthCheck() {
10271027
return client.cat.health()
10281028
}
10291029

1030+
/**
1031+
* Calculate temporal extent for a collection by finding the earliest and latest items
1032+
* @param {string} collectionId - The collection ID
1033+
* @returns {Promise<Array|null>} Returns [[startDate, endDate]] or null if no items/datetime
1034+
*/
1035+
async function getTemporalExtentFromItems(collectionId) {
1036+
try {
1037+
const client = await _client()
1038+
if (client === undefined) throw new Error('Client is undefined')
1039+
1040+
// Get earliest item by sorting ascending
1041+
const minParams = await constructSearchParams(
1042+
{ collections: [collectionId] },
1043+
undefined,
1044+
1 // Only need the first item
1045+
)
1046+
minParams.body.sort = [{ 'properties.datetime': { order: 'asc' } }]
1047+
minParams.body._source = ['properties.datetime']
1048+
1049+
// Get latest item by sorting descending
1050+
const maxParams = await constructSearchParams(
1051+
{ collections: [collectionId] },
1052+
undefined,
1053+
1 // Only need the first item
1054+
)
1055+
maxParams.body.sort = [{ 'properties.datetime': { order: 'desc' } }]
1056+
maxParams.body._source = ['properties.datetime']
1057+
1058+
// Execute both queries in parallel
1059+
const [minResponse, maxResponse] = await Promise.all([
1060+
client.search({
1061+
ignore_unavailable: true,
1062+
allow_no_indices: true,
1063+
...minParams
1064+
}),
1065+
client.search({
1066+
ignore_unavailable: true,
1067+
allow_no_indices: true,
1068+
...maxParams
1069+
})
1070+
])
1071+
1072+
const minItem = minResponse.body.hits.hits[0]?._source
1073+
const maxItem = maxResponse.body.hits.hits[0]?._source
1074+
1075+
// If no items or no datetime values, return [[null, null]]
1076+
if (!minItem?.properties?.datetime || !maxItem?.properties?.datetime) {
1077+
return [[null, null]]
1078+
}
1079+
1080+
const startDate = minItem.properties.datetime
1081+
const endDate = maxItem.properties.datetime
1082+
1083+
return [[startDate, endDate]]
1084+
} catch (error) {
1085+
const errorMessage = error instanceof Error ? error.message : String(error)
1086+
logger.error(
1087+
`Error calculating temporal extent for collection ${collectionId}: ${errorMessage}`
1088+
)
1089+
return null
1090+
}
1091+
}
1092+
10301093
export default {
10311094
getCollections,
10321095
getCollection,
@@ -1042,5 +1105,6 @@ export default {
10421105
aggregate,
10431106
constructSearchParams,
10441107
buildDatetimeQuery,
1045-
healthCheck
1108+
healthCheck,
1109+
getTemporalExtentFromItems
10461110
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// @ts-nocheck
2+
3+
import test from 'ava'
4+
import { deleteAllIndices, refreshIndices } from '../helpers/database.js'
5+
import { ingestItem } from '../helpers/ingest.js'
6+
import { randomId, loadFixture } from '../helpers/utils.js'
7+
import { setup } from '../helpers/system-tests.js'
8+
9+
test.before(async (t) => {
10+
await deleteAllIndices()
11+
const standUpResult = await setup()
12+
13+
t.context = standUpResult
14+
t.context.collectionId = randomId('collection')
15+
16+
const collection = await loadFixture(
17+
'landsat-8-l1-collection.json',
18+
{ id: t.context.collectionId }
19+
)
20+
21+
await ingestItem({
22+
ingestQueueUrl: t.context.ingestQueueUrl,
23+
ingestTopicArn: t.context.ingestTopicArn,
24+
item: collection
25+
})
26+
27+
// Ingest items with different dates
28+
const item1 = await loadFixture('stac/LC80100102015002LGN00.json', {
29+
collection: t.context.collectionId,
30+
properties: {
31+
datetime: '2015-01-02T15:49:05.000Z'
32+
}
33+
})
34+
35+
const item2 = await loadFixture('stac/LC80100102015002LGN00.json', {
36+
collection: t.context.collectionId,
37+
id: 'item-2',
38+
properties: {
39+
datetime: '2020-06-15T10:30:00.000Z'
40+
}
41+
})
42+
43+
const item3 = await loadFixture('stac/LC80100102015002LGN00.json', {
44+
collection: t.context.collectionId,
45+
id: 'item-3',
46+
properties: {
47+
datetime: '2018-03-20T08:15:00.000Z'
48+
}
49+
})
50+
51+
await ingestItem({
52+
ingestQueueUrl: t.context.ingestQueueUrl,
53+
ingestTopicArn: t.context.ingestTopicArn,
54+
item: item1
55+
})
56+
57+
await ingestItem({
58+
ingestQueueUrl: t.context.ingestQueueUrl,
59+
ingestTopicArn: t.context.ingestTopicArn,
60+
item: item2
61+
})
62+
63+
await ingestItem({
64+
ingestQueueUrl: t.context.ingestQueueUrl,
65+
ingestTopicArn: t.context.ingestTopicArn,
66+
item: item3
67+
})
68+
69+
await refreshIndices()
70+
})
71+
72+
test.after.always(async (t) => {
73+
if (t.context.api) await t.context.api.close()
74+
})
75+
76+
test('GET /collections/:collectionId returns temporal extent from items', async (t) => {
77+
const { collectionId } = t.context
78+
79+
const response = await t.context.api.client.get(`collections/${collectionId}`,
80+
{ resolveBodyOnly: false })
81+
82+
t.is(response.statusCode, 200)
83+
t.is(response.body.id, collectionId)
84+
85+
// Check that extent.temporal.interval exists and is populated
86+
t.truthy(response.body.extent)
87+
t.truthy(response.body.extent.temporal)
88+
t.truthy(response.body.extent.temporal.interval)
89+
t.is(response.body.extent.temporal.interval.length, 1)
90+
91+
const [startDate, endDate] = response.body.extent.temporal.interval[0]
92+
93+
// Verify the start date is the earliest item datetime (2015-01-02)
94+
t.is(startDate, '2015-01-02T15:49:05.000Z')
95+
96+
// Verify the end date is the latest item datetime (2020-06-15)
97+
t.is(endDate, '2020-06-15T10:30:00.000Z')
98+
})
99+
100+
test('GET /collections returns temporal extent for all collections', async (t) => {
101+
const response = await t.context.api.client.get('collections',
102+
{ resolveBodyOnly: false })
103+
104+
t.is(response.statusCode, 200)
105+
t.truthy(response.body.collections)
106+
t.true(response.body.collections.length > 0)
107+
108+
// Find our test collection
109+
const collection = response.body.collections.find((c) => c.id === t.context.collectionId)
110+
t.truthy(collection)
111+
112+
// Check that extent.temporal.interval exists and is populated
113+
t.truthy(collection.extent)
114+
t.truthy(collection.extent.temporal)
115+
t.truthy(collection.extent.temporal.interval)
116+
t.is(collection.extent.temporal.interval.length, 1)
117+
118+
const [startDate, endDate] = collection.extent.temporal.interval[0]
119+
120+
// Verify the dates match the items
121+
t.is(startDate, '2015-01-02T15:49:05.000Z')
122+
t.is(endDate, '2020-06-15T10:30:00.000Z')
123+
})
124+
125+
test('Collection with no items has null temporal extent', async (t) => {
126+
// Create a new collection with no items
127+
const emptyCollectionId = randomId('empty-collection')
128+
const emptyCollection = await loadFixture(
129+
'landsat-8-l1-collection.json',
130+
{ id: emptyCollectionId }
131+
)
132+
133+
await ingestItem({
134+
ingestQueueUrl: t.context.ingestQueueUrl,
135+
ingestTopicArn: t.context.ingestTopicArn,
136+
item: emptyCollection
137+
})
138+
139+
await refreshIndices()
140+
141+
const response = await t.context.api.client.get(`collections/${emptyCollectionId}`,
142+
{ resolveBodyOnly: false })
143+
144+
t.is(response.statusCode, 200)
145+
t.is(response.body.id, emptyCollectionId)
146+
147+
// For a collection with no items, temporal extent should still exist from the original collection
148+
// but our code should gracefully handle this (return null or keep original)
149+
t.truthy(response.body.extent)
150+
})

0 commit comments

Comments
 (0)