@centia-io/sdk 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,6 +115,33 @@ await flow.signIn();
115
115
  flow.signOut(); // Clears tokens/options in local storage (no redirect)
116
116
  ```
117
117
 
118
+ ### GuestFlow (Guest tokens – anonymous access)
119
+
120
+ Obtain tokens for a database's default user (the sub-user used for anonymous access) without any user credentials. Useful for public apps that need a bearer token for public data. Requires that the database has a default user; `clientSecret` is only needed when the OAuth client is not public.
121
+
122
+ Required options:
123
+ - `host`
124
+ - `clientId`
125
+ - `database`
126
+ - `clientSecret` (only for confidential clients)
127
+
128
+ Example:
129
+ ```ts
130
+ import { GuestFlow } from "@centia-io/sdk";
131
+
132
+ const flow = new GuestFlow({
133
+ host: "https://api.centia.io",
134
+ clientId: "your-client-id",
135
+ database: "your-database"
136
+ });
137
+
138
+ await flow.signIn();
139
+ // Tokens for the default user are now stored; subsequent Sql/Rpc calls
140
+ // include the Authorization header and refresh works as usual.
141
+
142
+ flow.signOut(); // Clears tokens/options in local storage (no redirect)
143
+ ```
144
+
118
145
  ### SignUp (Browser – Create a new user)
119
146
 
120
147
  Use this helper in browser applications to redirect the user to the Centia‑io sign‑up page.
@@ -575,6 +602,40 @@ await features.deleteFeature('my_schema', 'my_table', [1, 2, 3])
575
602
 
576
603
  `srs` on `postFeature`/`patchFeature` declares the SRID of the *incoming* geometry. Reading requires a key — use the SQL or WFS APIs to query whole collections. PUT is not supported.
577
604
 
605
+ ## Snapshots (GeoParquet exports)
606
+
607
+ `Snapshots` wraps the snapshot API: asynchronous GeoParquet exports of a table or view to S3, and read access to the published snapshot files. It takes a `CentiaHttpClient` and requires a Bearer token:
608
+
609
+ ```ts
610
+ import { createCentiaClient, Snapshots } from '@centia-io/sdk'
611
+
612
+ const snapshots = new Snapshots(http)
613
+
614
+ // Queue an export (202; super-user only). The export runs asynchronously.
615
+ const { id } = await snapshots.postSnapshot({ schema: 'geodanmark', relation: 'bygning', srs: 25832 })
616
+
617
+ // Poll until it finishes (succeeded/failed/superseded) — or use getSnapshot(id) yourself
618
+ const job = await snapshots.waitForSnapshot(id, { intervalMs: 2000, timeoutMs: 300_000 })
619
+ if (job.status === 'failed') throw new Error(job.error ?? 'export failed')
620
+
621
+ // List jobs, optionally filtered (super-user only)
622
+ const jobs = await snapshots.getSnapshots({ schema: 'geodanmark', relation: 'bygning' })
623
+
624
+ // Read API — any user with read access to the relation:
625
+ const published = await snapshots.getRelationSnapshots('geodanmark', 'bygning') // newest first
626
+ const meta = await snapshots.getRelationSnapshot('geodanmark', 'bygning', '2026-09-16')
627
+
628
+ // The Parquet file itself. dataUrl for DuckDB/GDAL/plain fetch; the data
629
+ // methods return the raw Response (stream or buffer it yourself) and follow
630
+ // the 302 redirect to presigned storage URLs.
631
+ const url = snapshots.getRelationSnapshotDataUrl('geodanmark', 'bygning', '2026-09-16')
632
+ const head = await snapshots.headRelationSnapshotData('geodanmark', 'bygning', '2026-09-16') // Content-Length/ETag
633
+ const part = await snapshots.getRelationSnapshotData('geodanmark', 'bygning', '2026-09-16', { range: [0, 1023] })
634
+ const file = await snapshots.getRelationSnapshotFile('geodanmark', 'bygning', '2026-09-16', 'metadata-<id>.json')
635
+ ```
636
+
637
+ The job API is super-user only (403 `SUPER_USER_ONLY`); creating throws 404 (relation not found), 409 (a snapshot of the relation is already pending or running) or 501 (snapshot storage not configured). The read API needs read access to the relation — sub-users with a deny/limit geofence rule get 403 `GEOFENCE_RULES_APPLY`. A snapshot with several data files answers 409 `MULTI_FILE_SNAPSHOT` on `/data`; list `files` in the metadata and fetch them with `getRelationSnapshotFile`.
638
+
578
639
  ## Error handling
579
640
 
580
641
  - Network/HTTP errors: thrown as `Error` with the status/body text when available.
@@ -341,6 +341,9 @@ var Gc2Service = class {
341
341
  isSignUpOptions(options) {
342
342
  return "parentDb" in options;
343
343
  }
344
+ isGuestFlowOptions(options) {
345
+ return "database" in options && !("username" in options);
346
+ }
344
347
  buildUrl(path) {
345
348
  if (path.startsWith("http://") || path.startsWith("https://")) return path;
346
349
  return `${this.host}${path}`;
@@ -453,12 +456,24 @@ var Gc2Service = class {
453
456
  database
454
457
  });
455
458
  }
456
- async getRefreshToken(token) {
459
+ async getGuestToken() {
457
460
  var _this5 = this;
458
- var _this$options$tokenUr3;
459
- const path = (_this$options$tokenUr3 = _this5.options.tokenUri) !== null && _this$options$tokenUr3 !== void 0 ? _this$options$tokenUr3 : `${_this5.host}/api/v4/oauth`;
461
+ let database;
462
+ if (_this5.isGuestFlowOptions(_this5.options)) database = _this5.options.database;
463
+ else throw new Error("GuestFlow options required for this operation");
464
+ const path = `${_this5.host}/api/v4/oauth/guest`;
460
465
  return _this5.request(_this5.buildUrl(path), "POST", {
466
+ database,
461
467
  client_id: _this5.options.clientId,
468
+ client_secret: _this5.options.clientSecret
469
+ });
470
+ }
471
+ async getRefreshToken(token) {
472
+ var _this6 = this;
473
+ var _this$options$tokenUr3;
474
+ const path = (_this$options$tokenUr3 = _this6.options.tokenUri) !== null && _this$options$tokenUr3 !== void 0 ? _this$options$tokenUr3 : `${_this6.host}/api/v4/oauth`;
475
+ return _this6.request(_this6.buildUrl(path), "POST", {
476
+ client_id: _this6.options.clientId,
462
477
  grant_type: "refresh_token",
463
478
  refresh_token: token
464
479
  });
@@ -576,6 +591,48 @@ var PasswordFlow = class {
576
591
  }
577
592
  };
578
593
 
594
+ //#endregion
595
+ //#region src/GuestFlow.ts
596
+ /**
597
+ * @author Martin Høgh <mh@mapcentia.com>
598
+ * @copyright 2013-2026 MapCentia ApS
599
+ * @license https://opensource.org/license/mit The MIT License
600
+ *
601
+ */
602
+ /**
603
+ * Guest token flow. Issues access/refresh tokens for the database's default
604
+ * user (the sub-user used for anonymous access) without any user credentials.
605
+ * `clientSecret` is only required when the OAuth client is not public.
606
+ */
607
+ var GuestFlow = class {
608
+ constructor(options) {
609
+ this.options = options;
610
+ this.service = new Gc2Service(options);
611
+ }
612
+ async signIn() {
613
+ var _this = this;
614
+ const { access_token, refresh_token } = await _this.service.getGuestToken();
615
+ setTokens({
616
+ accessToken: access_token,
617
+ refreshToken: refresh_token
618
+ });
619
+ setOptions({
620
+ clientId: _this.options.clientId,
621
+ host: _this.options.host,
622
+ redirectUri: "",
623
+ clientSecret: _this.options.clientSecret
624
+ });
625
+ }
626
+ signOut() {
627
+ this.clear();
628
+ }
629
+ clear() {
630
+ clearTokens();
631
+ clearOptions();
632
+ clearNonce();
633
+ }
634
+ };
635
+
579
636
  //#endregion
580
637
  //#region src/http/errors.ts
581
638
  /**
@@ -650,6 +707,52 @@ var CentiaHttpClient = class {
650
707
  getHeader: (name) => response.headers.get(name)
651
708
  };
652
709
  }
710
+ /**
711
+ * Execute a request and return the raw Response without consuming the body.
712
+ * For binary endpoints (file downloads): follows redirects (a presigned
713
+ * storage URL — the fetch spec strips Authorization on cross-origin
714
+ * redirects), sends no Content-Type, and supports extra request headers
715
+ * such as Range. Throws CentiaApiError on a status not in expectedStatus.
716
+ */
717
+ async requestRaw(opts) {
718
+ var _this3 = this;
719
+ var _opts$accept, _opts$expectedStatus2;
720
+ const url = _this3.buildUrl(opts.path, opts.query);
721
+ const headers = { "Accept": (_opts$accept = opts.accept) !== null && _opts$accept !== void 0 ? _opts$accept : "*/*" };
722
+ if (_this3.auth.getAccessToken) {
723
+ const token = await _this3.auth.getAccessToken();
724
+ if (token) headers["Authorization"] = `Bearer ${token}`;
725
+ }
726
+ if (_this3.auth.getHeaders) Object.assign(headers, await _this3.auth.getHeaders());
727
+ if (_this3.userAgent && typeof navigator === "undefined") headers["User-Agent"] = _this3.userAgent;
728
+ Object.assign(headers, opts.headers);
729
+ const response = await _this3.fetchFn(url, {
730
+ method: opts.method,
731
+ headers,
732
+ redirect: "follow"
733
+ });
734
+ if (!((_opts$expectedStatus2 = opts.expectedStatus) !== null && _opts$expectedStatus2 !== void 0 ? _opts$expectedStatus2 : [200]).includes(response.status)) {
735
+ var _ref, _parsed$message, _response$headers$get;
736
+ let bodyText = "";
737
+ try {
738
+ bodyText = await response.text();
739
+ } catch (_unused) {}
740
+ let parsed = null;
741
+ if (bodyText) try {
742
+ parsed = JSON.parse(bodyText);
743
+ } catch (_unused2) {}
744
+ throw new CentiaApiError({
745
+ message: ((_ref = (_parsed$message = parsed === null || parsed === void 0 ? void 0 : parsed.message) !== null && _parsed$message !== void 0 ? _parsed$message : parsed === null || parsed === void 0 ? void 0 : parsed.error) !== null && _ref !== void 0 ? _ref : bodyText) || `Unexpected status ${response.status}`,
746
+ status: response.status,
747
+ code: parsed === null || parsed === void 0 ? void 0 : parsed.code,
748
+ details: parsed,
749
+ requestId: (_response$headers$get = response.headers.get("x-request-id")) !== null && _response$headers$get !== void 0 ? _response$headers$get : void 0,
750
+ method: opts.method,
751
+ url
752
+ });
753
+ }
754
+ return response;
755
+ }
653
756
  buildUrl(path, query) {
654
757
  const cleanPath = path.replace(/^\/+/, "");
655
758
  let url = `${this.baseUrl}/${cleanPath}`;
@@ -660,19 +763,19 @@ var CentiaHttpClient = class {
660
763
  return url;
661
764
  }
662
765
  async buildHeaders(opts) {
663
- var _this3 = this;
664
- var _opts$accept;
665
- const headers = { "Accept": (_opts$accept = opts.accept) !== null && _opts$accept !== void 0 ? _opts$accept : "application/json" };
666
- if (_this3.auth.getAccessToken) {
667
- const token = await _this3.auth.getAccessToken();
766
+ var _this4 = this;
767
+ var _opts$accept2;
768
+ const headers = { "Accept": (_opts$accept2 = opts.accept) !== null && _opts$accept2 !== void 0 ? _opts$accept2 : "application/json" };
769
+ if (_this4.auth.getAccessToken) {
770
+ const token = await _this4.auth.getAccessToken();
668
771
  if (token) headers["Authorization"] = `Bearer ${token}`;
669
772
  }
670
- if (_this3.auth.getHeaders) {
671
- const authHeaders = await _this3.auth.getHeaders();
773
+ if (_this4.auth.getHeaders) {
774
+ const authHeaders = await _this4.auth.getHeaders();
672
775
  Object.assign(headers, authHeaders);
673
776
  }
674
- if (_this3.userAgent && typeof navigator === "undefined") headers["User-Agent"] = _this3.userAgent;
675
- const ct = _this3.resolveContentType(opts.contentType);
777
+ if (_this4.userAgent && typeof navigator === "undefined") headers["User-Agent"] = _this4.userAgent;
778
+ const ct = _this4.resolveContentType(opts.contentType);
676
779
  if (ct) headers["Content-Type"] = ct;
677
780
  return headers;
678
781
  }
@@ -681,24 +784,24 @@ var CentiaHttpClient = class {
681
784
  return contentType !== null && contentType !== void 0 ? contentType : "application/json";
682
785
  }
683
786
  async handleResponse(response, opts, url) {
684
- var _opts$expectedStatus2, _parsed;
685
- const expectedStatus = (_opts$expectedStatus2 = opts.expectedStatus) !== null && _opts$expectedStatus2 !== void 0 ? _opts$expectedStatus2 : 200;
787
+ var _opts$expectedStatus3, _parsed;
788
+ const expectedStatus = (_opts$expectedStatus3 = opts.expectedStatus) !== null && _opts$expectedStatus3 !== void 0 ? _opts$expectedStatus3 : 200;
686
789
  let bodyText = "";
687
790
  try {
688
791
  bodyText = await response.text();
689
- } catch (_unused) {}
792
+ } catch (_unused3) {}
690
793
  let parsed = null;
691
794
  if (bodyText) try {
692
795
  parsed = JSON.parse(bodyText);
693
- } catch (_unused2) {}
796
+ } catch (_unused4) {}
694
797
  if (response.status !== expectedStatus) {
695
- var _ref, _parsed$message, _response$headers$get;
798
+ var _ref2, _parsed$message2, _response$headers$get2;
696
799
  throw new CentiaApiError({
697
- message: ((_ref = (_parsed$message = parsed === null || parsed === void 0 ? void 0 : parsed.message) !== null && _parsed$message !== void 0 ? _parsed$message : parsed === null || parsed === void 0 ? void 0 : parsed.error) !== null && _ref !== void 0 ? _ref : bodyText) || `Unexpected status ${response.status}`,
800
+ message: ((_ref2 = (_parsed$message2 = parsed === null || parsed === void 0 ? void 0 : parsed.message) !== null && _parsed$message2 !== void 0 ? _parsed$message2 : parsed === null || parsed === void 0 ? void 0 : parsed.error) !== null && _ref2 !== void 0 ? _ref2 : bodyText) || `Unexpected status ${response.status}`,
698
801
  status: response.status,
699
802
  code: parsed === null || parsed === void 0 ? void 0 : parsed.code,
700
803
  details: parsed,
701
- requestId: (_response$headers$get = response.headers.get("x-request-id")) !== null && _response$headers$get !== void 0 ? _response$headers$get : void 0,
804
+ requestId: (_response$headers$get2 = response.headers.get("x-request-id")) !== null && _response$headers$get2 !== void 0 ? _response$headers$get2 : void 0,
702
805
  method: opts.method,
703
806
  url
704
807
  });
@@ -3329,6 +3432,150 @@ var Features = class {
3329
3432
  }
3330
3433
  };
3331
3434
 
3435
+ //#endregion
3436
+ //#region src/snapshots/Snapshots.ts
3437
+ /**
3438
+ * Client for the snapshot API: GeoParquet exports of a relation.
3439
+ *
3440
+ * The job API (`/api/v4/snapshots`) queues and inspects exports and is
3441
+ * super-user only; exports run asynchronously — poll with `getSnapshot` or
3442
+ * use `waitForSnapshot`. The read API
3443
+ * (`/api/v4/schemas/{schema}/relations/{relation}/snapshots`) serves the
3444
+ * published snapshots to any user with read access to the relation, with
3445
+ * HEAD + byte-range support so Parquet readers can fetch footers and row
3446
+ * groups selectively.
3447
+ *
3448
+ * ```ts
3449
+ * const snapshots = new Snapshots(createCentiaClient({ baseUrl, auth }));
3450
+ * const { id } = await snapshots.postSnapshot({ schema: 'geodanmark', relation: 'bygning' });
3451
+ * const done = await snapshots.waitForSnapshot(id);
3452
+ * ```
3453
+ */
3454
+ var Snapshots = class {
3455
+ constructor(client) {
3456
+ this.client = client;
3457
+ }
3458
+ relationPath(schema, relation, rest = "") {
3459
+ return `api/v4/schemas/${encodeURIComponent(schema)}/relations/${encodeURIComponent(relation)}/snapshots${rest}`;
3460
+ }
3461
+ /**
3462
+ * Queue a Parquet snapshot of a table or view. Returns 202 with the job id.
3463
+ * Throws `CentiaApiError`: 403 (super-user only), 404 (relation not found),
3464
+ * 409 (a snapshot of the relation is already pending or running),
3465
+ * 501 (snapshot storage not configured).
3466
+ */
3467
+ async postSnapshot(body) {
3468
+ return this.client.request({
3469
+ path: "api/v4/snapshots",
3470
+ method: "POST",
3471
+ body,
3472
+ expectedStatus: 202
3473
+ });
3474
+ }
3475
+ /** Get a snapshot job by id. Super-user only. */
3476
+ async getSnapshot(id) {
3477
+ return this.client.request({
3478
+ path: `api/v4/snapshots/${encodeURIComponent(id)}`,
3479
+ method: "GET"
3480
+ });
3481
+ }
3482
+ /** List the newest snapshot jobs (50), optionally filtered by schema/relation. Super-user only. */
3483
+ async getSnapshots(options) {
3484
+ var _this3 = this;
3485
+ const query = {};
3486
+ if ((options === null || options === void 0 ? void 0 : options.schema) !== void 0) query.schema = options.schema;
3487
+ if ((options === null || options === void 0 ? void 0 : options.relation) !== void 0) query.relation = options.relation;
3488
+ return _this3.client.request({
3489
+ path: "api/v4/snapshots",
3490
+ method: "GET",
3491
+ query: Object.keys(query).length > 0 ? query : void 0
3492
+ });
3493
+ }
3494
+ /**
3495
+ * Poll a snapshot job until it reaches a terminal status (succeeded, failed
3496
+ * or superseded) and return it — check `status`/`error` on the result.
3497
+ * Throws an `Error` when `timeoutMs` elapses first.
3498
+ */
3499
+ async waitForSnapshot(id, options) {
3500
+ var _this4 = this;
3501
+ var _options$intervalMs, _options$timeoutMs;
3502
+ const intervalMs = (_options$intervalMs = options === null || options === void 0 ? void 0 : options.intervalMs) !== null && _options$intervalMs !== void 0 ? _options$intervalMs : 2e3;
3503
+ const timeoutMs = (_options$timeoutMs = options === null || options === void 0 ? void 0 : options.timeoutMs) !== null && _options$timeoutMs !== void 0 ? _options$timeoutMs : 3e5;
3504
+ const deadline = Date.now() + timeoutMs;
3505
+ for (;;) {
3506
+ const job = await _this4.getSnapshot(id);
3507
+ if (job.status === "succeeded" || job.status === "failed" || job.status === "superseded") return job;
3508
+ if (Date.now() + intervalMs > deadline) throw new Error(`Timed out after ${timeoutMs} ms waiting for snapshot ${id} (status: ${job.status})`);
3509
+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
3510
+ }
3511
+ }
3512
+ /** List the published snapshots of a relation, newest first. */
3513
+ async getRelationSnapshots(schema, relation) {
3514
+ var _this5 = this;
3515
+ return _this5.client.request({
3516
+ path: _this5.relationPath(schema, relation),
3517
+ method: "GET"
3518
+ });
3519
+ }
3520
+ /** Get one published snapshot (metadata) by date (`YYYY-MM-DD`). */
3521
+ async getRelationSnapshot(schema, relation, date) {
3522
+ var _this6 = this;
3523
+ return _this6.client.request({
3524
+ path: _this6.relationPath(schema, relation, `/${encodeURIComponent(date)}`),
3525
+ method: "GET"
3526
+ });
3527
+ }
3528
+ /**
3529
+ * Absolute URL of the snapshot's Parquet file, without fetching it — for
3530
+ * DuckDB/GDAL or a plain fetch. Authorization travels in the request
3531
+ * header, so protected relations need the caller to attach the token.
3532
+ */
3533
+ getRelationSnapshotDataUrl(schema, relation, date) {
3534
+ return `${this.client.baseUrl}/${this.relationPath(schema, relation, `/${encodeURIComponent(date)}/data`)}`;
3535
+ }
3536
+ /**
3537
+ * Fetch the snapshot's Parquet file and return the raw `Response` (stream
3538
+ * or buffer it yourself). Follows the 302 redirect the server answers in
3539
+ * redirect mode. Pass `range` for a single byte range (206). Throws
3540
+ * `CentiaApiError`: 403, 404, 409 (several files; use
3541
+ * getRelationSnapshotFile), 416, 502.
3542
+ */
3543
+ async getRelationSnapshotData(schema, relation, date, options) {
3544
+ var _this7 = this;
3545
+ return _this7.rawGet(_this7.relationPath(schema, relation, `/${encodeURIComponent(date)}/data`), "GET", options);
3546
+ }
3547
+ /** HEAD request for the snapshot's Parquet file — Content-Length, Accept-Ranges and ETag without the body. */
3548
+ async headRelationSnapshotData(schema, relation, date) {
3549
+ var _this8 = this;
3550
+ return _this8.rawGet(_this8.relationPath(schema, relation, `/${encodeURIComponent(date)}/data`), "HEAD");
3551
+ }
3552
+ /**
3553
+ * Fetch one file of the snapshot by catalog name (`data-<id>.parquet`,
3554
+ * `metadata-<id>.json`). Same redirect/range semantics as
3555
+ * getRelationSnapshotData.
3556
+ */
3557
+ async getRelationSnapshotFile(schema, relation, date, file, options) {
3558
+ var _this9 = this;
3559
+ return _this9.rawGet(_this9.relationPath(schema, relation, `/${encodeURIComponent(date)}/files/${encodeURIComponent(file)}`), "GET", options);
3560
+ }
3561
+ /** HEAD request for one file of the snapshot. */
3562
+ async headRelationSnapshotFile(schema, relation, date, file) {
3563
+ var _this10 = this;
3564
+ return _this10.rawGet(_this10.relationPath(schema, relation, `/${encodeURIComponent(date)}/files/${encodeURIComponent(file)}`), "HEAD");
3565
+ }
3566
+ async rawGet(path, method, options) {
3567
+ var _this11 = this;
3568
+ const headers = {};
3569
+ if (options === null || options === void 0 ? void 0 : options.range) headers["Range"] = `bytes=${options.range[0]}-${options.range[1]}`;
3570
+ return _this11.client.requestRaw({
3571
+ path,
3572
+ method,
3573
+ headers: Object.keys(headers).length > 0 ? headers : void 0,
3574
+ expectedStatus: [200, 206]
3575
+ });
3576
+ }
3577
+ };
3578
+
3332
3579
  //#endregion
3333
3580
  //#region src/auth/errors.ts
3334
3581
  /**
@@ -3456,6 +3703,7 @@ exports.Claims = Claims;
3456
3703
  exports.CodeFlow = CodeFlow;
3457
3704
  exports.Features = Features;
3458
3705
  exports.Gql = Gql;
3706
+ exports.GuestFlow = GuestFlow;
3459
3707
  exports.Keyvalue = Keyvalue;
3460
3708
  exports.Mapcache = Mapcache;
3461
3709
  exports.Meta = Meta;
@@ -3467,6 +3715,7 @@ exports.PasswordFlow = PasswordFlow;
3467
3715
  exports.Rpc = Rpc;
3468
3716
  exports.SessionExpiredError = SessionExpiredError;
3469
3717
  exports.SignUp = SignUp;
3718
+ exports.Snapshots = Snapshots;
3470
3719
  exports.Sql = Sql;
3471
3720
  exports.SqlNoToken = SqlNoToken;
3472
3721
  exports.Stats = Stats;